feat: add update and delete functionality for courses, events, and species; enhance attendance tracking and category creation
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-01 16:03:50 +02:00
parent 7b078f298b
commit c6b2824da8
48 changed files with 2036 additions and 390 deletions

View File

@@ -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<string, boolean>;
}
export function AttendanceGrid({
sessionId,
participants,
initialAttendance,
}: AttendanceGridProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [attendance, setAttendance] = useState<Map<string, boolean>>(
() => 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 (
<div className="space-y-4">
{participants.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Keine Teilnehmer in diesem Kurs
</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Teilnehmer</th>
<th className="p-3 text-center font-medium">Anwesend</th>
</tr>
</thead>
<tbody>
{participants.map((p) => (
<tr
key={p.id}
className="hover:bg-muted/30 cursor-pointer border-b"
onClick={() => toggle(p.id)}
>
<td className="p-3 font-medium">
{p.lastName}, {p.firstName}
</td>
<td className="p-3 text-center">
<input
type="checkbox"
checked={attendance.get(p.id) ?? false}
onChange={() => toggle(p.id)}
className="h-4 w-4 rounded border-gray-300"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{participants.length > 0 && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving || isPending}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? 'Wird gespeichert...' : 'Anwesenheit speichern'}
</Button>
</div>
)}
</div>
);
}

View File

@@ -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<Record<string, string | string[] | undefined>>;
@@ -49,6 +51,12 @@ export default async function AttendancePage({
]),
);
const participantList = participants.map((p: Record<string, unknown>) => ({
id: String(p.id),
firstName: String(p.first_name ?? ''),
lastName: String(p.last_name ?? ''),
}));
return (
<CmsPageShell account={account} title="Anwesenheit">
<div className="flex w-full flex-col gap-6">
@@ -59,7 +67,6 @@ export default async function AttendancePage({
</p>
</div>
{/* Session Selector */}
{sessions.length === 0 ? (
<EmptyState
icon={<Calendar className="h-8 w-8" />}
@@ -96,7 +103,6 @@ export default async function AttendancePage({
</CardContent>
</Card>
{/* Attendance Grid */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -105,48 +111,16 @@ export default async function AttendancePage({
</CardTitle>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Keine Teilnehmer in diesem Kurs
</p>
{selectedSessionId ? (
<AttendanceGrid
sessionId={selectedSessionId}
participants={participantList}
initialAttendance={attendanceMap}
/>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">
Teilnehmer
</th>
<th className="p-3 text-center font-medium">
Anwesend
</th>
</tr>
</thead>
<tbody>
{participants.map((p: Record<string, unknown>) => (
<tr
key={String(p.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(p.last_name ?? '')},{' '}
{String(p.first_name ?? '')}
</td>
<td className="p-3 text-center">
<input
type="checkbox"
defaultChecked={
attendanceMap.get(String(p.id)) ?? false
}
className="h-4 w-4 rounded border-gray-300"
aria-label={`Anwesenheit ${String(p.last_name)}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-muted-foreground py-6 text-center text-sm">
Bitte wählen Sie einen Termin aus
</p>
)}
</CardContent>
</Card>

View File

@@ -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 (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isPending}>
<Trash2 className="mr-2 h-4 w-4" />
Kurs absagen
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Kurs absagen?</AlertDialogTitle>
<AlertDialogDescription>
Der Kurs wird auf den Status &quot;Abgesagt&quot; gesetzt. Diese
Aktion kann rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={() => execute({ courseId })}>
Absagen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -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 <AccountNotFound />;
const api = createCourseManagementApi(client);
const course = await api.getCourse(courseId);
if (!course) return <AccountNotFound />;
const c = course as Record<string, unknown>;
return (
<CmsPageShell account={account} title={`${String(c.name)} — Bearbeiten`}>
<CreateCourseForm
accountId={acct.id}
account={account}
courseId={courseId}
initialData={{
courseNumber: String(c.course_number ?? ''),
name: String(c.name ?? ''),
description: String(c.description ?? ''),
startDate: String(c.start_date ?? ''),
endDate: String(c.end_date ?? ''),
fee: Number(c.fee ?? 0),
reducedFee: Number(c.reduced_fee ?? 0),
capacity: Number(c.capacity ?? 20),
minParticipants: Number(c.min_participants ?? 5),
status: String(c.status ?? 'planned'),
registrationDeadline: String(c.registration_deadline ?? ''),
notes: String(c.notes ?? ''),
}}
/>
</CmsPageShell>
);
}

View File

@@ -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 (
<CmsPageShell account={account} title={String(courseData.name)}>
<div className="flex w-full flex-col gap-6">
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/home/${account}/courses/${courseId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Bearbeiten
</Link>
</Button>
<DeleteCourseButton courseId={courseId} accountSlug={account} />
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>