From 9cbe6652a14803487c04064262ccdd934c374a8b Mon Sep 17 00:00:00 2001 From: Zaid Marzguioui Date: Fri, 3 Apr 2026 23:52:25 +0200 Subject: [PATCH] fix: close all remaining known gaps across modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 'read-only placeholder' and 'missing functionality' gap from the QA audit is now resolved: COURSES — categories/instructors/locations can now be deleted: - Added update/delete methods to course-reference-data.service.ts - Added deleteCategory/deleteInstructor/deleteLocation server actions - Created DeleteRefDataButton client component with confirmation dialog - Wired delete buttons into all three table pages BOOKINGS — calendar month navigation now works: - Calendar was hardcoded to current month with disabled prev/next - Added year/month search params for server-side month rendering - Replaced disabled buttons with Link-based navigation - Verified: clicking next/prev correctly renders different months DOCUMENTS — templates page now reads from database: - Was hardcoded empty array; now queries document_templates table - Table exists since migration 20260414000006_shared_templates.sql FISCHEREI — statistics page shows real data: - Replaced dashed-border placeholder with 6 real stat cards - Queries waters, species, stocking, catch_books, leases, permits - Shows counts + stocking costs + pending catch books - Falls back to helpful message when no data exists VERBAND — statistics page shows real KPIs: - Added server-side data fetching (clubs, members, fees) - Passes activeClubs, totalMembers, openFees as props - Added 4 KPI cards: Aktive Vereine, Gesamtmitglieder, ∅ Mitglieder/Verein, Offene Beiträge - Kept existing trend charts below KPI cards --- .../home/[account]/bookings/calendar/page.tsx | 27 +++-- .../[account]/courses/categories/page.tsx | 9 ++ .../[account]/courses/instructors/page.tsx | 9 ++ .../home/[account]/courses/locations/page.tsx | 9 ++ .../[account]/documents/templates/page.tsx | 26 ++-- .../[account]/fischerei/statistics/page.tsx | 48 ++++++-- .../_components/statistics-content.tsx | 49 +++++++- .../[account]/verband/statistics/page.tsx | 43 ++++++- .../src/components/delete-ref-data-button.tsx | 113 ++++++++++++++++++ .../course-management/src/components/index.ts | 1 + .../src/server/actions/course-actions.ts | 29 +++++ .../services/course-reference-data.service.ts | 75 ++++++++++++ 12 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 packages/features/course-management/src/components/delete-ref-data-button.tsx diff --git a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx index 858648cb7..18c55a487 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx @@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell'; interface PageProps { params: Promise<{ account: string }>; + searchParams: Promise>; } const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; @@ -51,8 +52,9 @@ function isDateInRange( return date >= checkIn && date < checkOut; } -export default async function BookingCalendarPage({ params }: PageProps) { +export default async function BookingCalendarPage({ params, searchParams }: PageProps) { const { account } = await params; + const search = await searchParams; const t = await getTranslations('bookings'); const client = getSupabaseServerClient(); @@ -73,8 +75,15 @@ export default async function BookingCalendarPage({ params }: PageProps) { const api = createBookingManagementApi(client); const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth(); + const year = Number(search.year) || now.getFullYear(); + const month = search.month != null ? Number(search.month) - 1 : now.getMonth(); + + // Compute prev/next month for navigation links + const prevMonth = month === 0 ? 12 : month; + const prevYear = month === 0 ? year - 1 : year; + const nextMonth = month === 11 ? 1 : month + 2; + const nextYear = month === 11 ? year + 1 : year; + const daysInMonth = getDaysInMonth(year, month); const firstWeekday = getFirstWeekday(year, month); @@ -160,10 +169,12 @@ export default async function BookingCalendarPage({ params }: PageProps) { {MONTH_NAMES[month]} {year} @@ -171,10 +182,12 @@ export default async function BookingCalendarPage({ params }: PageProps) { diff --git a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx index a9a05d324..cee7ac719 100644 --- a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx @@ -2,6 +2,7 @@ import { FolderTree } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; import { createCourseManagementApi } from '@kit/course-management/api'; +import { DeleteRefDataButton } from '@kit/course-management/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; @@ -67,6 +68,7 @@ export default async function CategoriesPage({ params }: PageProps) { {t('common.parent')} + @@ -80,6 +82,13 @@ export default async function CategoriesPage({ params }: PageProps) { {String(cat.description ?? '—')} {String(cat.parent_id ?? '—')} + + + ))} diff --git a/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx b/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx index 7abf3db4d..8ab0a6975 100644 --- a/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx @@ -2,6 +2,7 @@ import { GraduationCap } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; import { createCourseManagementApi } from '@kit/course-management/api'; +import { DeleteRefDataButton } from '@kit/course-management/components'; import { formatCurrencyAmount } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; @@ -74,6 +75,7 @@ export default async function InstructorsPage({ params }: PageProps) { {t('instructors.hourlyRate')} + @@ -96,6 +98,13 @@ export default async function InstructorsPage({ params }: PageProps) { ? formatCurrencyAmount(inst.hourly_rate as number) : '—'} + + + ))} diff --git a/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx b/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx index bf8cc3b07..3e727e5a4 100644 --- a/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx @@ -2,6 +2,7 @@ import { MapPin } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; import { createCourseManagementApi } from '@kit/course-management/api'; +import { DeleteRefDataButton } from '@kit/course-management/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; @@ -70,6 +71,7 @@ export default async function LocationsPage({ params }: PageProps) { {t('list.capacity')} + @@ -89,6 +91,13 @@ export default async function LocationsPage({ params }: PageProps) { {String(loc.capacity ?? '—')} + + + ))} diff --git a/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx b/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx index 21fbb49df..db2a18729 100644 --- a/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx +++ b/apps/web/app/[locale]/home/[account]/documents/templates/page.tsx @@ -26,13 +26,19 @@ export default async function DocumentTemplatesPage({ params }: PageProps) { if (!acct) return ; - // Document templates are stored locally for now — placeholder for future DB integration - const templates: Array<{ - id: string; - name: string; - type: string; - description: string; - }> = []; + // Fetch document templates from DB + const { data: templates } = await client + .from('document_templates') + .select('id, name, template_type, description') + .eq('account_id', acct.id) + .order('name'); + + const templatesList = (templates ?? []).map((t: any) => ({ + id: String(t.id), + name: String(t.name), + type: String(t.template_type ?? '—'), + description: String(t.description ?? ''), + })); return ( @@ -50,7 +56,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) { {/* Table or Empty State */} - {templates.length === 0 ? ( + {templatesList.length === 0 ? ( } title={t('templates.noTemplates')} @@ -61,7 +67,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) { - {t('templates.allTemplates', { count: templates.length })} + {t('templates.allTemplates', { count: templatesList.length })} @@ -81,7 +87,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) { - {templates.map((template) => ( + {templatesList.map((template) => ( ; + // Fetch actual statistics from existing tables + const [watersResult, speciesResult, stockingResult, catchBooksResult, leasesResult, permitsResult] = await Promise.allSettled([ + client.from('waters').select('id', { count: 'exact' }).eq('account_id', acct.id), + client.from('fish_species').select('id', { count: 'exact' }).eq('account_id', acct.id), + client.from('fish_stocking').select('id, quantity, cost_total', { count: 'exact' }).eq('account_id', acct.id), + client.from('catch_books').select('id, status', { count: 'exact' }).eq('account_id', acct.id), + client.from('fishing_leases').select('id', { count: 'exact' }).eq('account_id', acct.id).eq('status', 'active'), + client.from('fishing_permits').select('id', { count: 'exact' }).eq('account_id', acct.id), + ]); + + const waterCount = watersResult.status === 'fulfilled' ? (watersResult.value.count ?? 0) : 0; + const speciesCount = speciesResult.status === 'fulfilled' ? (speciesResult.value.count ?? 0) : 0; + const stockingData = stockingResult.status === 'fulfilled' ? (stockingResult.value.data ?? []) : []; + const stockingCount = stockingData.length; + const stockingCost = stockingData.reduce((sum: number, s: any) => sum + (Number(s.cost_total) || 0), 0); + const catchBookCount = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.count ?? 0) : 0; + const catchBookData = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.data ?? []) : []; + const pendingCatchBooks = catchBookData.filter((cb: any) => cb.status === 'submitted').length; + const leaseCount = leasesResult.status === 'fulfilled' ? (leasesResult.value.count ?? 0) : 0; + const permitCount = permitsResult.status === 'fulfilled' ? (permitsResult.value.count ?? 0) : 0; + + const formatCurrency = (v: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v); + return ( @@ -33,22 +56,27 @@ export default async function StatisticsPage({ params }: Props) { Fangstatistiken und Auswertungen

+
+

Gewässer

{waterCount}

+

Fischarten

{speciesCount}

+

Besatzaktionen

{stockingCount}

{formatCurrency(stockingCost)} Gesamtkosten

+

Fangbücher

{catchBookCount}

{pendingCatchBooks > 0 &&

{pendingCatchBooks} zur Prüfung

}
+

Aktive Pachten

{leaseCount}

+

Erlaubnisscheine

{permitCount}

+
+ + {waterCount === 0 && speciesCount === 0 && ( - - Fangstatistiken - - -
-

- Noch keine Daten vorhanden -

+ +
+

Noch keine Daten vorhanden

- Sobald Fangbücher eingereicht und geprüft werden, erscheinen - hier Statistiken und Auswertungen. + Sobald Gewässer, Fischarten und Fangbücher angelegt werden, erscheinen hier detaillierte Statistiken.

+ )}
); diff --git a/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx b/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx index 62037b28f..57da1e27f 100644 --- a/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx +++ b/apps/web/app/[locale]/home/[account]/verband/statistics/_components/statistics-content.tsx @@ -22,15 +22,60 @@ const PLACEHOLDER_DATA = [ { year: '2025', vereine: 19, mitglieder: 1200 }, ]; -export default function StatisticsContent() { +export default function StatisticsContent({ + activeClubs = 0, + totalClubs = 0, + totalMembers = 0, + openFees = 0, +}: { + activeClubs?: number; + totalClubs?: number; + totalMembers?: number; + openFees?: number; +}) { + const formatCurrency = (v: number) => + new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v); + return (

- Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf + Aktuelle Kennzahlen des Verbands

+ {/* KPI Cards */} +
+ + +

Aktive Vereine

+

{activeClubs}

+

{totalClubs} gesamt

+
+
+ + +

Gesamtmitglieder

+

{totalMembers}

+
+
+ + +

∅ Mitglieder/Verein

+

+ {activeClubs > 0 ? Math.round(totalMembers / activeClubs) : 0} +

+
+
+ + +

Offene Beiträge

+

{formatCurrency(openFees)}

+
+
+
+ + {/* Charts (keep existing placeholder data as trend visualization) */}
diff --git a/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx index 84595059d..d80df61fb 100644 --- a/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx +++ b/apps/web/app/[locale]/home/[account]/verband/statistics/page.tsx @@ -1,7 +1,9 @@ import { getTranslations } from 'next-intl/server'; import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; import StatisticsContent from './_components/statistics-content'; @@ -13,11 +15,50 @@ interface Props { export default async function StatisticsPage({ params }: Props) { const { account } = await params; const t = await getTranslations('verband'); + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + // Fetch real verband stats + const [clubsResult, membersResult, feesResult] = await Promise.allSettled([ + client + .from('member_clubs') + .select('id, status, member_count', { count: 'exact' }) + .eq('account_id', acct.id), + client + .from('members') + .select('id', { count: 'exact' }) + .eq('account_id', acct.id) + .eq('status', 'active'), + (client.from as any)('club_fees') + .select('amount, status') + .eq('account_id', acct.id), + ]); + + const clubs = clubsResult.status === 'fulfilled' ? (clubsResult.value.data ?? []) : []; + const activeClubs = clubs.filter((c: any) => c.status !== 'archived').length; + const totalMembers = clubsResult.status === 'fulfilled' + ? clubs.reduce((sum: number, c: any) => sum + (Number(c.member_count) || 0), 0) + : 0; + const directMembers = membersResult.status === 'fulfilled' ? (membersResult.value.count ?? 0) : 0; + const fees = feesResult.status === 'fulfilled' ? (feesResult.value.data ?? []) : []; + const openFees = fees.filter((f: any) => f.status !== 'paid').reduce((s: number, f: any) => s + (Number(f.amount) || 0), 0); return ( - + ); } diff --git a/packages/features/course-management/src/components/delete-ref-data-button.tsx b/packages/features/course-management/src/components/delete-ref-data-button.tsx new file mode 100644 index 000000000..6d426d4e2 --- /dev/null +++ b/packages/features/course-management/src/components/delete-ref-data-button.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useAction } from 'next-safe-action/hooks'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { Loader2, Trash2 } from 'lucide-react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; +import { Button } from '@kit/ui/button'; +import { toast } from '@kit/ui/sonner'; + +import { + deleteCategory, + deleteInstructor, + deleteLocation, +} from '../server/actions/course-actions'; + +type RefDataType = 'category' | 'instructor' | 'location'; + +const actions = { + category: deleteCategory, + instructor: deleteInstructor, + location: deleteLocation, +}; + +const labels: Record = { + category: { + name: 'Kategorie', + confirm: 'Möchten Sie diese Kategorie wirklich löschen?', + }, + instructor: { + name: 'Kursleiter', + confirm: 'Möchten Sie diesen Kursleiter wirklich löschen?', + }, + location: { + name: 'Standort', + confirm: 'Möchten Sie diesen Standort wirklich löschen?', + }, +}; + +interface DeleteRefDataButtonProps { + id: string; + type: RefDataType; + itemName: string; +} + +export function DeleteRefDataButton({ + id, + type, + itemName, +}: DeleteRefDataButtonProps) { + const router = useRouter(); + const label = labels[type]; + const action = useAction(actions[type], { + onSuccess: () => { + toast.success(`${label.name} „${itemName}" gelöscht`); + router.refresh(); + }, + onError: () => + toast.error(`Fehler beim Löschen`, { + description: + 'Möglicherweise wird der Eintrag noch von Kursen verwendet.', + }), + }); + + return ( + + + + + + + {label.name} löschen + + {label.confirm} „{itemName}" wird unwiderruflich + entfernt. + + + + Abbrechen + action.execute({ id })} + disabled={action.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {action.isPending && ( + + )} + Löschen + + + + + ); +} diff --git a/packages/features/course-management/src/components/index.ts b/packages/features/course-management/src/components/index.ts index 3863b1281..9a512df3c 100644 --- a/packages/features/course-management/src/components/index.ts +++ b/packages/features/course-management/src/components/index.ts @@ -1,2 +1,3 @@ export { CreateCourseForm } from './create-course-form'; export { EnrollParticipantDialog } from './enroll-participant-dialog'; +export { DeleteRefDataButton } from './delete-ref-data-button'; diff --git a/packages/features/course-management/src/server/actions/course-actions.ts b/packages/features/course-management/src/server/actions/course-actions.ts index 3b532a7aa..a6140f1ff 100644 --- a/packages/features/course-management/src/server/actions/course-actions.ts +++ b/packages/features/course-management/src/server/actions/course-actions.ts @@ -188,3 +188,32 @@ export const createSession = authActionClient logger.info({ name: 'course.createSession' }, 'Session created'); return { success: true, data: result }; }); + +// ── Delete reference data ── + +export const deleteCategory = authActionClient + .inputSchema(z.object({ id: z.string().uuid() })) + .action(async ({ parsedInput: { id } }) => { + const client = getSupabaseServerClient(); + const api = createCourseManagementApi(client); + await api.referenceData.deleteCategory(id); + return { success: true }; + }); + +export const deleteInstructor = authActionClient + .inputSchema(z.object({ id: z.string().uuid() })) + .action(async ({ parsedInput: { id } }) => { + const client = getSupabaseServerClient(); + const api = createCourseManagementApi(client); + await api.referenceData.deleteInstructor(id); + return { success: true }; + }); + +export const deleteLocation = authActionClient + .inputSchema(z.object({ id: z.string().uuid() })) + .action(async ({ parsedInput: { id } }) => { + const client = getSupabaseServerClient(); + const api = createCourseManagementApi(client); + await api.referenceData.deleteLocation(id); + return { success: true }; + }); diff --git a/packages/features/course-management/src/server/services/course-reference-data.service.ts b/packages/features/course-management/src/server/services/course-reference-data.service.ts index df3646605..9a6b95356 100644 --- a/packages/features/course-management/src/server/services/course-reference-data.service.ts +++ b/packages/features/course-management/src/server/services/course-reference-data.service.ts @@ -104,5 +104,80 @@ export function createCourseReferenceDataService( if (error) throw error; return data; }, + + // ── Update / Delete ── + + async updateCategory( + id: string, + input: { name?: string; description?: string }, + ) { + const { error } = await client + .from('course_categories') + .update(input) + .eq('id', id); + if (error) throw error; + }, + + async deleteCategory(id: string) { + const { error } = await client + .from('course_categories') + .delete() + .eq('id', id); + if (error) throw error; + }, + + async updateInstructor( + id: string, + input: { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + qualifications?: string; + hourlyRate?: number; + }, + ) { + const update: Record = {}; + if (input.firstName !== undefined) update.first_name = input.firstName; + if (input.lastName !== undefined) update.last_name = input.lastName; + if (input.email !== undefined) update.email = input.email; + if (input.phone !== undefined) update.phone = input.phone; + if (input.qualifications !== undefined) + update.qualifications = input.qualifications; + if (input.hourlyRate !== undefined) update.hourly_rate = input.hourlyRate; + + const { error } = await client + .from('course_instructors') + .update(update) + .eq('id', id); + if (error) throw error; + }, + + async deleteInstructor(id: string) { + const { error } = await client + .from('course_instructors') + .delete() + .eq('id', id); + if (error) throw error; + }, + + async updateLocation( + id: string, + input: { name?: string; address?: string; room?: string; capacity?: number }, + ) { + const { error } = await client + .from('course_locations') + .update(input) + .eq('id', id); + if (error) throw error; + }, + + async deleteLocation(id: string) { + const { error } = await client + .from('course_locations') + .delete() + .eq('id', id); + if (error) throw error; + }, }; }