From c6b2824da8da38282336a87168d0dc8343d49d99 Mon Sep 17 00:00:00 2001 From: "T. Zehetbauer" <125989630+4thTomost@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:03:50 +0200 Subject: [PATCH] feat: add update and delete functionality for courses, events, and species; enhance attendance tracking and category creation --- .../[courseId]/attendance/attendance-grid.tsx | 115 +++++++++++ .../courses/[courseId]/attendance/page.tsx | 60 ++---- .../[courseId]/delete-course-button.tsx | 61 ++++++ .../courses/[courseId]/edit/page.tsx | 53 +++++ .../[account]/courses/[courseId]/page.tsx | 14 ++ .../home/[account]/courses/calendar/page.tsx | 48 ++++- .../categories/create-category-dialog.tsx | 104 ++++++++++ .../[account]/courses/categories/page.tsx | 10 +- .../instructors/create-instructor-dialog.tsx | 182 ++++++++++++++++++ .../[account]/courses/instructors/page.tsx | 10 +- .../locations/create-location-dialog.tsx | 132 +++++++++++++ .../home/[account]/courses/locations/page.tsx | 10 +- .../events/[eventId]/delete-event-button.tsx | 61 ++++++ .../[account]/events/[eventId]/edit/page.tsx | 56 ++++++ .../home/[account]/events/[eventId]/page.tsx | 16 +- .../species/[speciesId]/edit/page.tsx | 49 +++++ .../home/[account]/fischerei/species/page.tsx | 1 + .../stocking/[stockingId]/edit/page.tsx | 67 +++++++ .../[account]/fischerei/stocking/page.tsx | 1 + .../fischerei/waters/[waterId]/edit/page.tsx | 45 +++++ .../home/[account]/fischerei/waters/page.tsx | 1 + .../modules/[moduleId]/[recordId]/page.tsx | 19 +- .../modules/[moduleId]/import/page.tsx | 7 +- .../[moduleId]/module-records-table.tsx | 74 +++++++ .../[account]/modules/[moduleId]/new/page.tsx | 19 +- .../[account]/modules/[moduleId]/page.tsx | 59 +++--- .../settings/module-settings-form.tsx | 146 ++++++++++++++ .../modules/[moduleId]/settings/page.tsx | 103 +++------- .../src/components/create-course-form.tsx | 163 ++++++++++------ .../src/schema/course.schema.ts | 1 + .../src/server/actions/course-actions.ts | 27 +++ .../course-management/src/server/api.ts | 46 +++++ .../src/components/create-event-form.tsx | 121 ++++++++---- .../src/schema/event.schema.ts | 5 + .../src/server/actions/event-actions.ts | 27 +++ .../event-management/src/server/api.ts | 51 ++++- .../src/components/create-species-form.tsx | 53 +++-- .../src/components/create-stocking-form.tsx | 95 +++++++-- .../src/components/create-water-form.tsx | 50 +++-- .../src/components/delete-confirm-button.tsx | 70 +++++++ .../src/components/species-data-table.tsx | 44 ++++- .../src/components/stocking-data-table.tsx | 43 ++++- .../src/components/waters-data-table.tsx | 44 ++++- packages/features/fischerei/src/server/api.ts | 10 + packages/features/module-builder/package.json | 1 + .../src/server/actions/record-actions.ts | 28 +-- .../services/module-definition.service.ts | 12 +- packages/features/module-builder/src/types.ts | 12 ++ 48 files changed, 2036 insertions(+), 390 deletions(-) create mode 100644 apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/attendance-grid.tsx create mode 100644 apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx create mode 100644 apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/courses/categories/create-category-dialog.tsx create mode 100644 apps/web/app/[locale]/home/[account]/courses/instructors/create-instructor-dialog.tsx create mode 100644 apps/web/app/[locale]/home/[account]/courses/locations/create-location-dialog.tsx create mode 100644 apps/web/app/[locale]/home/[account]/events/[eventId]/delete-event-button.tsx create mode 100644 apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/species/[speciesId]/edit/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/stocking/[stockingId]/edit/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/fischerei/waters/[waterId]/edit/page.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/module-records-table.tsx create mode 100644 apps/web/app/[locale]/home/[account]/modules/[moduleId]/settings/module-settings-form.tsx create mode 100644 packages/features/fischerei/src/components/delete-confirm-button.tsx create mode 100644 packages/features/module-builder/src/types.ts diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/attendance-grid.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/attendance-grid.tsx new file mode 100644 index 000000000..c64d18236 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/attendance-grid.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Save } from 'lucide-react'; + +import { markAttendance } from '@kit/course-management/actions/course-actions'; +import { Button } from '@kit/ui/button'; +import { toast } from '@kit/ui/sonner'; + +interface Participant { + id: string; + firstName: string; + lastName: string; +} + +interface AttendanceGridProps { + sessionId: string; + participants: Participant[]; + initialAttendance: Map; +} + +export function AttendanceGrid({ + sessionId, + participants, + initialAttendance, +}: AttendanceGridProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [attendance, setAttendance] = useState>( + () => new Map(initialAttendance), + ); + const [isSaving, setIsSaving] = useState(false); + + const toggle = (participantId: string) => { + setAttendance((prev) => { + const next = new Map(prev); + next.set(participantId, !prev.get(participantId)); + return next; + }); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const promises = participants.map((p) => + markAttendance({ + sessionId, + participantId: p.id, + present: attendance.get(p.id) ?? false, + }), + ); + await Promise.all(promises); + toast.success('Anwesenheit gespeichert'); + startTransition(() => router.refresh()); + } catch { + toast.error('Fehler beim Speichern der Anwesenheit'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {participants.length === 0 ? ( +

+ Keine Teilnehmer in diesem Kurs +

+ ) : ( +
+ + + + + + + + + {participants.map((p) => ( + toggle(p.id)} + > + + + + ))} + +
TeilnehmerAnwesend
+ {p.lastName}, {p.firstName} + + toggle(p.id)} + className="h-4 w-4 rounded border-gray-300" + /> +
+
+ )} + + {participants.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx index 1ff22d5d3..6ff5086d9 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx @@ -10,6 +10,8 @@ import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { AttendanceGrid } from './attendance-grid'; + interface PageProps { params: Promise<{ account: string; courseId: string }>; searchParams: Promise>; @@ -49,6 +51,12 @@ export default async function AttendancePage({ ]), ); + const participantList = participants.map((p: Record) => ({ + id: String(p.id), + firstName: String(p.first_name ?? ''), + lastName: String(p.last_name ?? ''), + })); + return (
@@ -59,7 +67,6 @@ export default async function AttendancePage({

- {/* Session Selector */} {sessions.length === 0 ? ( } @@ -96,7 +103,6 @@ export default async function AttendancePage({ - {/* Attendance Grid */} @@ -105,48 +111,16 @@ export default async function AttendancePage({ - {participants.length === 0 ? ( -

- Keine Teilnehmer in diesem Kurs -

+ {selectedSessionId ? ( + ) : ( -
- - - - - - - - - {participants.map((p: Record) => ( - - - - - ))} - -
- Teilnehmer - - Anwesend -
- {String(p.last_name ?? '')},{' '} - {String(p.first_name ?? '')} - - -
-
+

+ Bitte wählen Sie einen Termin aus +

)}
diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx new file mode 100644 index 000000000..95a912516 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/delete-course-button.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Trash2 } from 'lucide-react'; + +import { deleteCourse } from '@kit/course-management/actions/course-actions'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kit/ui/alert-dialog'; +import { Button } from '@kit/ui/button'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; + +interface Props { + courseId: string; + accountSlug: string; +} + +export function DeleteCourseButton({ courseId, accountSlug }: Props) { + const router = useRouter(); + + const { execute, isPending } = useActionWithToast(deleteCourse, { + successMessage: 'Kurs wurde abgesagt', + errorMessage: 'Fehler beim Absagen', + onSuccess: () => router.push(`/home/${accountSlug}/courses`), + }); + + return ( + + + + + + + Kurs absagen? + + Der Kurs wird auf den Status "Abgesagt" gesetzt. Diese + Aktion kann rückgängig gemacht werden. + + + + Abbrechen + execute({ courseId })}> + Absagen + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx new file mode 100644 index 000000000..794ac5ccf --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx @@ -0,0 +1,53 @@ +import { createCourseManagementApi } from '@kit/course-management/api'; +import { CreateCourseForm } from '@kit/course-management/components'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { AccountNotFound } from '~/components/account-not-found'; +import { CmsPageShell } from '~/components/cms-page-shell'; + +interface PageProps { + params: Promise<{ account: string; courseId: string }>; +} + +export default async function EditCoursePage({ params }: PageProps) { + const { account, courseId } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const api = createCourseManagementApi(client); + const course = await api.getCourse(courseId); + if (!course) return ; + + const c = course as Record; + + return ( + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx index cbc504366..f491506db 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx @@ -7,6 +7,7 @@ import { Euro, User, Clock, + Pencil, } from 'lucide-react'; import { createCourseManagementApi } from '@kit/course-management/api'; @@ -19,6 +20,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; +import { DeleteCourseButton } from './delete-course-button'; + interface PageProps { params: Promise<{ account: string; courseId: string }>; } @@ -60,6 +63,17 @@ export default async function CourseDetailPage({ params }: PageProps) { return (
+ {/* Action Buttons */} +
+ + +
+ {/* Summary Cards */}
diff --git a/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx index 42a3363b9..7fcbc60e3 100644 --- a/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/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']; @@ -42,8 +43,12 @@ function getFirstWeekday(year: number, month: number): number { return day === 0 ? 6 : day - 1; } -export default async function CourseCalendarPage({ params }: PageProps) { +export default async function CourseCalendarPage({ + params, + searchParams, +}: PageProps) { const { account } = await params; + const search = await searchParams; const client = getSupabaseServerClient(); const { data: acct } = await client @@ -58,8 +63,17 @@ export default async function CourseCalendarPage({ params }: PageProps) { const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 }); const now = new Date(); - const year = now.getFullYear(); - const month = now.getMonth(); + const monthParam = search.month as string | undefined; + let year: number; + let month: number; + if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) { + const [y, m] = monthParam.split('-').map(Number); + year = y!; + month = m! - 1; + } else { + year = now.getFullYear(); + month = now.getMonth(); + } const daysInMonth = getDaysInMonth(year, month); const firstWeekday = getFirstWeekday(year, month); @@ -139,15 +153,31 @@ export default async function CourseCalendarPage({ params }: PageProps) {
- + + + {MONTH_NAMES[month]} {year} - + + +
diff --git a/apps/web/app/[locale]/home/[account]/courses/categories/create-category-dialog.tsx b/apps/web/app/[locale]/home/[account]/courses/categories/create-category-dialog.tsx new file mode 100644 index 000000000..0169965cb --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/courses/categories/create-category-dialog.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Plus } from 'lucide-react'; + +import { createCategory } from '@kit/course-management/actions/course-actions'; +import { Button } from '@kit/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@kit/ui/dialog'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; + +interface CreateCategoryDialogProps { + accountId: string; +} + +export function CreateCategoryDialog({ accountId }: CreateCategoryDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + const { execute, isPending } = useActionWithToast(createCategory, { + successMessage: 'Kategorie erstellt', + errorMessage: 'Fehler beim Erstellen der Kategorie', + onSuccess: () => { + setOpen(false); + setName(''); + setDescription(''); + router.refresh(); + }, + }); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + execute({ + accountId, + name: name.trim(), + description: description.trim() || undefined, + }); + }, + [execute, accountId, name, description], + ); + + return ( + + }> + + Neue Kategorie + + +
+ + Neue Kategorie + + Erstellen Sie eine neue Kurskategorie. + + +
+
+ + setName(e.target.value)} + placeholder="z. B. Sprachkurse" + required + minLength={1} + maxLength={128} + /> +
+
+ + setDescription(e.target.value)} + placeholder="Kurze Beschreibung" + /> +
+
+ + + +
+
+
+ ); +} 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 3217ecfd2..81a674cfa 100644 --- a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx @@ -1,14 +1,15 @@ -import { FolderTree, Plus } from 'lucide-react'; +import { FolderTree } from 'lucide-react'; import { createCourseManagementApi } from '@kit/course-management/api'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { Button } from '@kit/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { AccountNotFound } from '~/components/account-not-found'; import { CmsPageShell } from '~/components/cms-page-shell'; import { EmptyState } from '~/components/empty-state'; +import { CreateCategoryDialog } from './create-category-dialog'; + interface PageProps { params: Promise<{ account: string }>; } @@ -33,10 +34,7 @@ export default async function CategoriesPage({ params }: PageProps) {

Kurskategorien verwalten

- +
{categories.length === 0 ? ( diff --git a/apps/web/app/[locale]/home/[account]/courses/instructors/create-instructor-dialog.tsx b/apps/web/app/[locale]/home/[account]/courses/instructors/create-instructor-dialog.tsx new file mode 100644 index 000000000..878ac4b7a --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/courses/instructors/create-instructor-dialog.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Plus } from 'lucide-react'; + +import { createInstructor } from '@kit/course-management/actions/course-actions'; +import { Button } from '@kit/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@kit/ui/dialog'; +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { Textarea } from '@kit/ui/textarea'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; + +interface CreateInstructorDialogProps { + accountId: string; +} + +export function CreateInstructorDialog({ + accountId, +}: CreateInstructorDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + const [qualifications, setQualifications] = useState(''); + const [hourlyRate, setHourlyRate] = useState(''); + + const { execute, isPending } = useActionWithToast(createInstructor, { + successMessage: 'Dozent erstellt', + errorMessage: 'Fehler beim Erstellen des Dozenten', + onSuccess: () => { + setOpen(false); + setFirstName(''); + setLastName(''); + setEmail(''); + setPhone(''); + setQualifications(''); + setHourlyRate(''); + router.refresh(); + }, + }); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!firstName.trim() || !lastName.trim()) return; + execute({ + accountId, + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email.trim() || undefined, + phone: phone.trim() || undefined, + qualifications: qualifications.trim() || undefined, + hourlyRate: hourlyRate ? Number(hourlyRate) : undefined, + }); + }, + [ + execute, + accountId, + firstName, + lastName, + email, + phone, + qualifications, + hourlyRate, + ], + ); + + return ( + + }> + + Neuer Dozent + + +
+ + Neuer Dozent + + Einen neuen Dozenten zum Dozentenpool hinzufuegen. + + +
+
+
+ + setFirstName(e.target.value)} + placeholder="Vorname" + required + minLength={1} + maxLength={128} + /> +
+
+ + setLastName(e.target.value)} + placeholder="Nachname" + required + minLength={1} + maxLength={128} + /> +
+
+
+
+ + setEmail(e.target.value)} + placeholder="dozent@beispiel.de" + /> +
+
+ + setPhone(e.target.value)} + placeholder="+49 123 456789" + /> +
+
+
+ +