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 { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { AttendanceGrid } from './attendance-grid';
interface PageProps { interface PageProps {
params: Promise<{ account: string; courseId: string }>; params: Promise<{ account: string; courseId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>; 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 ( return (
<CmsPageShell account={account} title="Anwesenheit"> <CmsPageShell account={account} title="Anwesenheit">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
@@ -59,7 +67,6 @@ export default async function AttendancePage({
</p> </p>
</div> </div>
{/* Session Selector */}
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<EmptyState <EmptyState
icon={<Calendar className="h-8 w-8" />} icon={<Calendar className="h-8 w-8" />}
@@ -96,7 +103,6 @@ export default async function AttendancePage({
</CardContent> </CardContent>
</Card> </Card>
{/* Attendance Grid */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -105,48 +111,16 @@ export default async function AttendancePage({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{participants.length === 0 ? ( {selectedSessionId ? (
<p className="text-muted-foreground py-6 text-center text-sm"> <AttendanceGrid
Keine Teilnehmer in diesem Kurs sessionId={selectedSessionId}
</p> 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> <p className="text-muted-foreground py-6 text-center text-sm">
))} Bitte wählen Sie einen Termin aus
</tbody> </p>
</table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </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, Euro,
User, User,
Clock, Clock,
Pencil,
} from 'lucide-react'; } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api'; 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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { DeleteCourseButton } from './delete-course-button';
interface PageProps { interface PageProps {
params: Promise<{ account: string; courseId: string }>; params: Promise<{ account: string; courseId: string }>;
} }
@@ -60,6 +63,17 @@ export default async function CourseDetailPage({ params }: PageProps) {
return ( return (
<CmsPageShell account={account} title={String(courseData.name)}> <CmsPageShell account={account} title={String(courseData.name)}>
<div className="flex w-full flex-col gap-6"> <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 */} {/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card> <Card>

View File

@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
} }
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; 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; 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 { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const { data: acct } = await client 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 courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const monthParam = search.month as string | undefined;
const month = now.getMonth(); 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 daysInMonth = getDaysInMonth(year, month);
const firstWeekday = getFirstWeekday(year, month); const firstWeekday = getFirstWeekday(year, month);
@@ -139,15 +153,31 @@ export default async function CourseCalendarPage({ params }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" size="icon" disabled> <Link
href={`/home/${account}/courses/calendar?month=${
month === 0
? `${year - 1}-12`
: `${year}-${String(month).padStart(2, '0')}`
}`}
>
<Button variant="ghost" size="icon">
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
</Link>
<CardTitle> <CardTitle>
{MONTH_NAMES[month]} {year} {MONTH_NAMES[month]} {year}
</CardTitle> </CardTitle>
<Button variant="ghost" size="icon" disabled> <Link
href={`/home/${account}/courses/calendar?month=${
month === 11
? `${year + 1}-01`
: `${year}-${String(month + 2).padStart(2, '0')}`
}`}
>
<Button variant="ghost" size="icon">
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</Link>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-1 h-4 w-4" />
Neue Kategorie
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neue Kategorie</DialogTitle>
<DialogDescription>
Erstellen Sie eine neue Kurskategorie.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="cat-name">Name</Label>
<Input
id="cat-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. Sprachkurse"
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cat-description">Beschreibung (optional)</Label>
<Input
id="cat-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kurze Beschreibung"
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? 'Wird erstellt...' : 'Erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,14 +1,15 @@
import { FolderTree, Plus } from 'lucide-react'; import { FolderTree } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { CreateCategoryDialog } from './create-category-dialog';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
} }
@@ -33,10 +34,7 @@ export default async function CategoriesPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Kurskategorien verwalten</p> <p className="text-muted-foreground">Kurskategorien verwalten</p>
<Button data-test="categories-new-btn"> <CreateCategoryDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
Neue Kategorie
</Button>
</div> </div>
{categories.length === 0 ? ( {categories.length === 0 ? (

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-1 h-4 w-4" />
Neuer Dozent
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neuer Dozent</DialogTitle>
<DialogDescription>
Einen neuen Dozenten zum Dozentenpool hinzufuegen.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inst-first-name">Vorname</Label>
<Input
id="inst-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Vorname"
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inst-last-name">Nachname</Label>
<Input
id="inst-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Nachname"
required
minLength={1}
maxLength={128}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inst-email">E-Mail (optional)</Label>
<Input
id="inst-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="dozent@beispiel.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="inst-phone">Telefon (optional)</Label>
<Input
id="inst-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+49 123 456789"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="inst-qualifications">
Qualifikationen (optional)
</Label>
<Textarea
id="inst-qualifications"
value={qualifications}
onChange={(e) => setQualifications(e.target.value)}
placeholder="z. B. Zertifizierter Trainer, Erste-Hilfe-Ausbilder"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inst-hourly-rate">Stundensatz (optional)</Label>
<Input
id="inst-hourly-rate"
type="number"
min={0}
step={0.01}
value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button
type="submit"
disabled={isPending || !firstName.trim() || !lastName.trim()}
>
{isPending ? 'Wird erstellt...' : 'Erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,14 +1,15 @@
import { GraduationCap, Plus } from 'lucide-react'; import { GraduationCap } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { CreateInstructorDialog } from './create-instructor-dialog';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
} }
@@ -33,10 +34,7 @@ export default async function InstructorsPage({ params }: PageProps) {
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-muted-foreground">Dozentenpool verwalten</p> <p className="text-muted-foreground">Dozentenpool verwalten</p>
<Button data-test="instructors-new-btn"> <CreateInstructorDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
Neuer Dozent
</Button>
</div> </div>
{instructors.length === 0 ? ( {instructors.length === 0 ? (

View File

@@ -0,0 +1,132 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus } from 'lucide-react';
import { createLocation } 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 CreateLocationDialogProps {
accountId: string;
}
export function CreateLocationDialog({ accountId }: CreateLocationDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [room, setRoom] = useState('');
const [capacity, setCapacity] = useState('');
const { execute, isPending } = useActionWithToast(createLocation, {
successMessage: 'Ort erstellt',
errorMessage: 'Fehler beim Erstellen des Ortes',
onSuccess: () => {
setOpen(false);
setName('');
setAddress('');
setRoom('');
setCapacity('');
router.refresh();
},
});
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
execute({
accountId,
name: name.trim(),
address: address.trim() || undefined,
room: room.trim() || undefined,
capacity: capacity ? Number(capacity) : undefined,
});
},
[execute, accountId, name, address, room, capacity],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}>
<Plus className="mr-1 h-4 w-4" />
Neuer Ort
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Neuer Ort</DialogTitle>
<DialogDescription>
Einen neuen Kurs- oder Veranstaltungsort hinzufuegen.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="loc-name">Name</Label>
<Input
id="loc-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. Vereinsheim"
required
minLength={1}
maxLength={128}
/>
</div>
<div className="space-y-2">
<Label htmlFor="loc-address">Adresse (optional)</Label>
<Input
id="loc-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Musterstr. 1, 12345 Musterstadt"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="loc-room">Raum (optional)</Label>
<Input
id="loc-room"
value={room}
onChange={(e) => setRoom(e.target.value)}
placeholder="z. B. Raum 101"
/>
</div>
<div className="space-y-2">
<Label htmlFor="loc-capacity">Kapazitaet (optional)</Label>
<Input
id="loc-capacity"
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="z. B. 30"
/>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? 'Wird erstellt...' : 'Erstellen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,14 +1,15 @@
import { MapPin, Plus } from 'lucide-react'; import { MapPin } from 'lucide-react';
import { createCourseManagementApi } from '@kit/course-management/api'; import { createCourseManagementApi } from '@kit/course-management/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found'; import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state'; import { EmptyState } from '~/components/empty-state';
import { CreateLocationDialog } from './create-location-dialog';
interface PageProps { interface PageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
} }
@@ -35,10 +36,7 @@ export default async function LocationsPage({ params }: PageProps) {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Kurs- und Veranstaltungsorte verwalten Kurs- und Veranstaltungsorte verwalten
</p> </p>
<Button data-test="locations-new-btn"> <CreateLocationDialog accountId={acct.id} />
<Plus className="mr-2 h-4 w-4" />
Neuer Ort
</Button>
</div> </div>
{locations.length === 0 ? ( {locations.length === 0 ? (

View File

@@ -0,0 +1,61 @@
'use client';
import { useRouter } from 'next/navigation';
import { Trash2 } from 'lucide-react';
import { deleteEvent } from '@kit/event-management/actions/event-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 {
eventId: string;
accountSlug: string;
}
export function DeleteEventButton({ eventId, accountSlug }: Props) {
const router = useRouter();
const { execute, isPending } = useActionWithToast(deleteEvent, {
successMessage: 'Veranstaltung wurde abgesagt',
errorMessage: 'Fehler beim Absagen',
onSuccess: () => router.push(`/home/${accountSlug}/events`),
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isPending}>
<Trash2 className="mr-2 h-4 w-4" />
Absagen
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Veranstaltung absagen?</AlertDialogTitle>
<AlertDialogDescription>
Die Veranstaltung 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({ eventId })}>
Absagen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,56 @@
import { createEventManagementApi } from '@kit/event-management/api';
import { CreateEventForm } from '@kit/event-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; eventId: string }>;
}
export default async function EditEventPage({ params }: PageProps) {
const { account, eventId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const event = await api.getEvent(eventId);
if (!event) return <AccountNotFound />;
const e = event as Record<string, unknown>;
return (
<CmsPageShell account={account} title={`${String(e.name)} — Bearbeiten`}>
<CreateEventForm
accountId={acct.id}
account={account}
eventId={eventId}
initialData={{
name: String(e.name ?? ''),
description: String(e.description ?? ''),
eventDate: String(e.event_date ?? ''),
eventTime: String(e.event_time ?? ''),
endDate: String(e.end_date ?? ''),
location: String(e.location ?? ''),
capacity: e.capacity != null ? Number(e.capacity) : undefined,
minAge: e.min_age != null ? Number(e.min_age) : undefined,
maxAge: e.max_age != null ? Number(e.max_age) : undefined,
fee: Number(e.fee ?? 0),
status: String(e.status ?? 'planned'),
registrationDeadline: String(e.registration_deadline ?? ''),
contactName: String(e.contact_name ?? ''),
contactEmail: String(e.contact_email ?? ''),
contactPhone: String(e.contact_phone ?? ''),
}}
/>
</CmsPageShell>
);
}

View File

@@ -4,8 +4,8 @@ import {
CalendarDays, CalendarDays,
MapPin, MapPin,
Users, Users,
Euro,
Clock, Clock,
Pencil,
UserPlus, UserPlus,
} from 'lucide-react'; } from 'lucide-react';
@@ -17,7 +17,8 @@ import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { EmptyState } from '~/components/empty-state';
import { DeleteEventButton } from './delete-event-button';
interface PageProps { interface PageProps {
params: Promise<{ account: string; eventId: string }>; params: Promise<{ account: string; eventId: string }>;
@@ -61,6 +62,17 @@ export default async function EventDetailPage({ params }: PageProps) {
return ( return (
<CmsPageShell account={account} title={String(eventData.name)}> <CmsPageShell account={account} title={String(eventData.name)}>
<div className="flex w-full flex-col gap-6"> <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}/events/${eventId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Bearbeiten
</Link>
</Button>
<DeleteEventButton eventId={eventId} accountSlug={account} />
</div>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View File

@@ -0,0 +1,49 @@
import { notFound } from 'next/navigation';
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateSpeciesForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; speciesId: string }>;
}
export default async function EditSpeciesPage({ params }: Props) {
const { account, speciesId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
let species;
try {
species = await api.getSpecies(speciesId);
} catch {
notFound();
}
return (
<CmsPageShell account={account} title="Fischart bearbeiten">
<FischereiTabNavigation account={account} activeTab="species" />
<CreateSpeciesForm
accountId={acct.id}
account={account}
species={species}
/>
</CmsPageShell>
);
}

View File

@@ -63,6 +63,7 @@ export default async function SpeciesPage({ params, searchParams }: Props) {
page={page} page={page}
pageSize={50} pageSize={50}
account={account} account={account}
accountId={acct.id}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -0,0 +1,67 @@
import { notFound } from 'next/navigation';
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateStockingForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; stockingId: string }>;
}
export default async function EditStockingPage({ params }: Props) {
const { account, stockingId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
let stocking;
try {
stocking = await api.getStocking(stockingId);
} catch {
notFound();
}
// Load waters and species lists for form dropdowns
const [watersResult, speciesResult] = await Promise.all([
api.listWaters(acct.id, { pageSize: 200 }),
api.listSpecies(acct.id, { pageSize: 200 }),
]);
const waters = watersResult.data.map((w: Record<string, unknown>) => ({
id: String(w.id),
name: String(w.name),
}));
const speciesList = speciesResult.data.map((s: Record<string, unknown>) => ({
id: String(s.id),
name: String(s.name),
}));
return (
<CmsPageShell account={account} title="Besatz bearbeiten">
<FischereiTabNavigation account={account} activeTab="stocking" />
<CreateStockingForm
accountId={acct.id}
account={account}
waters={waters}
species={speciesList}
stocking={stocking}
/>
</CmsPageShell>
);
}

View File

@@ -87,6 +87,7 @@ export default async function StockingPage({ params, searchParams }: Props) {
page={page} page={page}
pageSize={25} pageSize={25}
account={account} account={account}
accountId={acct.id}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -0,0 +1,45 @@
import { notFound } from 'next/navigation';
import { createFischereiApi } from '@kit/fischerei/api';
import {
FischereiTabNavigation,
CreateWaterForm,
} from '@kit/fischerei/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; waterId: string }>;
}
export default async function EditWaterPage({ params }: Props) {
const { account, waterId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createFischereiApi(client);
let water;
try {
water = await api.getWater(waterId);
} catch {
notFound();
}
return (
<CmsPageShell account={account} title="Gewässer bearbeiten">
<FischereiTabNavigation account={account} activeTab="waters" />
<CreateWaterForm accountId={acct.id} account={account} water={water} />
</CmsPageShell>
);
}

View File

@@ -44,6 +44,7 @@ export default async function WatersPage({ params, searchParams }: Props) {
page={page} page={page}
pageSize={25} pageSize={25}
account={account} account={account}
accountId={acct.id}
/> />
</CmsPageShell> </CmsPageShell>
); );

View File

@@ -33,24 +33,7 @@ export default async function RecordDetailPage({
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>; if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
const fields = ( const fields = moduleWithFields.fields;
moduleWithFields as unknown as {
fields: Array<{
name: string;
display_name: string;
field_type: string;
is_required: boolean;
placeholder: string | null;
help_text: string | null;
is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string;
sort_order: number;
show_in_form: boolean;
width: string;
}>;
}
).fields;
return ( return (
<CmsPageShell <CmsPageShell

View File

@@ -19,12 +19,7 @@ export default async function ImportPage({ params }: ImportPageProps) {
const moduleWithFields = await api.modules.getModuleWithFields(moduleId); const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>; if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const fields = const fields = moduleWithFields.fields ?? [];
(
moduleWithFields as unknown as {
fields: Array<{ name: string; display_name: string }>;
}
).fields ?? [];
return ( return (
<CmsPageShell <CmsPageShell

View File

@@ -0,0 +1,74 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { ModuleTable } from '@kit/module-builder/components';
type FieldDef = Parameters<typeof ModuleTable>[0]['fields'][number];
interface ModuleRecordsTableProps {
fields: FieldDef[];
records: Array<{
id: string;
data: Record<string, unknown>;
status: string;
created_at: string;
updated_at: string;
}>;
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
account: string;
moduleId: string;
currentSort?: { field: string; direction: 'asc' | 'desc' };
}
export function ModuleRecordsTable({
fields,
records,
pagination,
account,
moduleId,
currentSort,
}: ModuleRecordsTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === '') {
params.delete(key);
} else {
params.set(key, value);
}
}
const qs = params.toString();
router.push(qs ? `${pathname}?${qs}` : pathname);
},
[router, pathname, searchParams],
);
return (
<ModuleTable
fields={fields}
records={records}
pagination={pagination}
currentSort={currentSort}
onPageChange={(page) => updateParams({ page: String(page) })}
onSort={(field, direction) =>
updateParams({ sort: field, dir: direction, page: null })
}
onRowClick={(recordId) =>
router.push(`/home/${account}/modules/${moduleId}/${recordId}`)
}
/>
);
}

View File

@@ -27,24 +27,7 @@ export default async function NewRecordPage({ params }: NewRecordPageProps) {
const moduleWithFields = await api.modules.getModuleWithFields(moduleId); const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
if (!moduleWithFields) return <div>Modul nicht gefunden</div>; if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const fields = ( const fields = moduleWithFields.fields;
moduleWithFields as unknown as {
fields: Array<{
name: string;
display_name: string;
field_type: string;
is_required: boolean;
placeholder: string | null;
help_text: string | null;
is_readonly: boolean;
select_options: Array<{ label: string; value: string }> | null;
section: string;
sort_order: number;
show_in_form: boolean;
width: string;
}>;
}
).fields;
return ( return (
<CmsPageShell <CmsPageShell

View File

@@ -7,6 +7,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { decodeFilters } from './_lib/filter-params'; import { decodeFilters } from './_lib/filter-params';
import { ModuleRecordsTable } from './module-records-table';
import { ModuleSearchBar } from './module-search-bar'; import { ModuleSearchBar } from './module-search-bar';
interface ModuleDetailPageProps { interface ModuleDetailPageProps {
@@ -33,34 +34,34 @@ export default async function ModuleDetailPage({
const pageSize = const pageSize =
Number(search.pageSize) || moduleWithFields.default_page_size || 25; Number(search.pageSize) || moduleWithFields.default_page_size || 25;
const sortField =
(search.sort as string) ?? moduleWithFields.default_sort_field ?? undefined;
const sortDirection =
(search.dir as 'asc' | 'desc') ??
(moduleWithFields.default_sort_direction as 'asc' | 'desc') ??
'asc';
const filters = decodeFilters(search.f as string | undefined); const filters = decodeFilters(search.f as string | undefined);
const result = await api.query.query({ const result = await api.query.query({
moduleId, moduleId,
page, page,
pageSize, pageSize,
sortField: sortField,
(search.sort as string) ?? sortDirection,
moduleWithFields.default_sort_field ??
undefined,
sortDirection:
(search.dir as 'asc' | 'desc') ??
(moduleWithFields.default_sort_direction as 'asc' | 'desc') ??
'asc',
search: (search.q as string) ?? undefined, search: (search.q as string) ?? undefined,
filters, filters,
}); });
const fields = ( const allFields = moduleWithFields.fields;
moduleWithFields as unknown as {
fields: Array<{ const records = (result.data ?? []).map((row: Record<string, unknown>) => ({
name: string; id: String(row.id ?? ''),
display_name: string; data: (row.data ?? {}) as Record<string, unknown>,
show_in_filter: boolean; status: String(row.status ?? 'active'),
show_in_search: boolean; created_at: String(row.created_at ?? ''),
}>; updated_at: String(row.updated_at ?? ''),
} }));
).fields;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -83,18 +84,18 @@ export default async function ModuleDetailPage({
</Button> </Button>
</div> </div>
<ModuleSearchBar fields={fields} /> <ModuleSearchBar fields={allFields} />
<div className="text-muted-foreground text-sm"> <ModuleRecordsTable
{result.pagination.total} Datensätze Seite {result.pagination.page}{' '} fields={allFields}
von {result.pagination.totalPages} records={records}
</div> pagination={result.pagination}
account={account}
<div className="rounded-lg border"> moduleId={moduleId}
<pre className="max-h-96 overflow-auto p-4 text-xs"> currentSort={
{JSON.stringify(result.data, null, 2)} sortField ? { field: sortField, direction: sortDirection } : undefined
</pre> }
</div> />
</div> </div>
); );
} }

View File

@@ -0,0 +1,146 @@
'use client';
import { useRouter } from 'next/navigation';
import { Settings2 } from 'lucide-react';
import { updateModule } from '@kit/module-builder/actions/module-actions';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
const FEATURE_TOGGLES = [
{ key: 'enableSearch', dbKey: 'enable_search', label: 'Suche' },
{ key: 'enableFilter', dbKey: 'enable_filter', label: 'Filter' },
{ key: 'enableExport', dbKey: 'enable_export', label: 'Export' },
{ key: 'enableImport', dbKey: 'enable_import', label: 'Import' },
{ key: 'enablePrint', dbKey: 'enable_print', label: 'Drucken' },
{ key: 'enableCopy', dbKey: 'enable_copy', label: 'Kopieren' },
{ key: 'enableHistory', dbKey: 'enable_history', label: 'Verlauf' },
{ key: 'enableSoftDelete', dbKey: 'enable_soft_delete', label: 'Papierkorb' },
{ key: 'enableLock', dbKey: 'enable_lock', label: 'Sperren' },
] as const;
interface ModuleSettingsFormProps {
moduleId: string;
initialData: {
name: string;
displayName: string;
description: string;
icon: string;
defaultPageSize: number;
features: Record<string, boolean>;
};
}
export function ModuleSettingsForm({
moduleId,
initialData,
}: ModuleSettingsFormProps) {
const router = useRouter();
const { execute, isPending } = useActionWithToast(updateModule, {
successMessage: 'Einstellungen gespeichert',
errorMessage: 'Fehler beim Speichern',
onSuccess: () => router.refresh(),
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings2 className="h-4 w-4" />
Allgemein
</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
execute({
moduleId,
displayName: fd.get('displayName') as string,
description: (fd.get('description') as string) || undefined,
icon: (fd.get('icon') as string) || undefined,
defaultPageSize: Number(fd.get('defaultPageSize')) || 25,
...Object.fromEntries(
FEATURE_TOGGLES.map(({ key }) => [key, fd.get(key) === 'on']),
),
});
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="displayName">Anzeigename</Label>
<Input
id="displayName"
name="displayName"
defaultValue={initialData.displayName}
/>
</div>
<div className="space-y-2">
<Label>Systemname</Label>
<Input
defaultValue={initialData.name}
readOnly
className="bg-muted"
/>
</div>
<div className="col-span-full space-y-2">
<Label htmlFor="description">Beschreibung</Label>
<Input
id="description"
name="description"
defaultValue={initialData.description}
/>
</div>
<div className="space-y-2">
<Label htmlFor="icon">Symbol</Label>
<Input id="icon" name="icon" defaultValue={initialData.icon} />
</div>
<div className="space-y-2">
<Label htmlFor="defaultPageSize">Seitengröße</Label>
<Input
id="defaultPageSize"
name="defaultPageSize"
type="number"
defaultValue={String(initialData.defaultPageSize)}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
{FEATURE_TOGGLES.map(({ key, dbKey, label }) => {
const isEnabled = initialData.features[dbKey] ?? false;
return (
<label
key={key}
className="flex cursor-pointer items-center gap-1.5"
>
<input
type="checkbox"
name={key}
defaultChecked={isEnabled}
className="h-4 w-4 rounded border-gray-300"
/>
<Badge variant={isEnabled ? 'default' : 'secondary'}>
{label}
</Badge>
</label>
);
})}
</div>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -1,16 +1,14 @@
import { Settings2, List, Shield } from 'lucide-react'; import { List, Shield } from 'lucide-react';
import { createModuleBuilderApi } from '@kit/module-builder/api'; import { createModuleBuilderApi } from '@kit/module-builder/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { CmsPageShell } from '~/components/cms-page-shell'; import { CmsPageShell } from '~/components/cms-page-shell';
import { DeleteModuleButton } from './delete-module-button'; import { DeleteModuleButton } from './delete-module-button';
import { ModuleSettingsForm } from './module-settings-form';
interface ModuleSettingsPageProps { interface ModuleSettingsPageProps {
params: Promise<{ account: string; moduleId: string }>; params: Promise<{ account: string; moduleId: string }>;
@@ -27,8 +25,24 @@ export default async function ModuleSettingsPage({
if (!moduleWithFields) return <div>Modul nicht gefunden</div>; if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
const mod = moduleWithFields; const mod = moduleWithFields;
const fields = const fields = mod.fields ?? [];
(mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
const featureKeys = [
'enable_search',
'enable_filter',
'enable_export',
'enable_import',
'enable_print',
'enable_copy',
'enable_history',
'enable_soft_delete',
'enable_lock',
] as const;
const features: Record<string, boolean> = {};
for (const key of featureKeys) {
features[key] = Boolean(mod[key]);
}
return ( return (
<CmsPageShell <CmsPageShell
@@ -36,71 +50,17 @@ export default async function ModuleSettingsPage({
title={`${String(mod.display_name)} — Einstellungen`} title={`${String(mod.display_name)} — Einstellungen`}
> >
<div className="space-y-6"> <div className="space-y-6">
{/* General Settings */} <ModuleSettingsForm
<Card> moduleId={moduleId}
<CardHeader> initialData={{
<CardTitle className="flex items-center gap-2"> name: String(mod.name),
<Settings2 className="h-4 w-4" /> displayName: String(mod.display_name),
Allgemein description: String(mod.description ?? ''),
</CardTitle> icon: String(mod.icon ?? 'table'),
</CardHeader> defaultPageSize: Number(mod.default_page_size ?? 25),
<CardContent className="space-y-4"> features,
<div className="grid gap-4 sm:grid-cols-2"> }}
<div className="space-y-2">
<Label>Anzeigename</Label>
<Input defaultValue={String(mod.display_name)} />
</div>
<div className="space-y-2">
<Label>Systemname</Label>
<Input
defaultValue={String(mod.name)}
readOnly
className="bg-muted"
/> />
</div>
<div className="col-span-full space-y-2">
<Label>Beschreibung</Label>
<Input defaultValue={String(mod.description ?? '')} />
</div>
<div className="space-y-2">
<Label>Symbol</Label>
<Input defaultValue={String(mod.icon ?? 'table')} />
</div>
<div className="space-y-2">
<Label>Seitengröße</Label>
<Input
type="number"
defaultValue={String(mod.default_page_size ?? 25)}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
{[
{ key: 'enable_search', label: 'Suche' },
{ key: 'enable_filter', label: 'Filter' },
{ key: 'enable_export', label: 'Export' },
{ key: 'enable_import', label: 'Import' },
{ key: 'enable_print', label: 'Drucken' },
{ key: 'enable_copy', label: 'Kopieren' },
{ key: 'enable_history', label: 'Verlauf' },
{ key: 'enable_soft_delete', label: 'Papierkorb' },
{ key: 'enable_lock', label: 'Sperren' },
].map(({ key, label }) => (
<Badge
key={key}
variant={
(mod as Record<string, unknown>)[key]
? 'default'
: 'secondary'
}
>
{(mod as Record<string, unknown>)[key] ? '✓' : '✗'} {label}
</Badge>
))}
</div>
<Button>Einstellungen speichern</Button>
</CardContent>
</Card>
{/* Field Definitions */} {/* Field Definitions */}
<Card> <Card>
@@ -109,7 +69,6 @@ export default async function ModuleSettingsPage({
<List className="h-4 w-4" /> <List className="h-4 w-4" />
Felder ({fields.length}) Felder ({fields.length})
</CardTitle> </CardTitle>
<Button size="sm">+ Feld hinzufügen</Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">

View File

@@ -3,7 +3,6 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -17,53 +16,100 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateCourseSchema } from '../schema/course.schema'; import {
import { createCourse } from '../server/actions/course-actions'; CreateCourseSchema,
UpdateCourseSchema,
} from '../schema/course.schema';
import { createCourse, updateCourse } from '../server/actions/course-actions';
interface Props { interface Props {
accountId: string; accountId: string;
account: string; account: string;
/** If provided, form operates in edit mode */
courseId?: string;
initialData?: {
courseNumber: string;
name: string;
description: string;
startDate: string;
endDate: string;
fee: number;
reducedFee: number;
capacity: number;
minParticipants: number;
status: string;
registrationDeadline: string;
notes: string;
};
} }
export function CreateCourseForm({ accountId, account }: Props) { export function CreateCourseForm({
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateCourseSchema),
defaultValues: {
accountId, accountId,
courseNumber: '', account,
name: '', courseId,
description: '', initialData,
startDate: '', }: Props) {
endDate: '', const router = useRouter();
fee: 0, const isEdit = Boolean(courseId);
reducedFee: 0,
capacity: 20, const form = useForm({
minParticipants: 5, resolver: zodResolver(isEdit ? UpdateCourseSchema : CreateCourseSchema),
status: 'planned' as const, defaultValues: {
registrationDeadline: '', ...(isEdit ? { courseId } : { accountId }),
notes: '', courseNumber: initialData?.courseNumber ?? '',
name: initialData?.name ?? '',
description: initialData?.description ?? '',
startDate: initialData?.startDate ?? '',
endDate: initialData?.endDate ?? '',
fee: initialData?.fee ?? 0,
reducedFee: initialData?.reducedFee ?? 0,
capacity: initialData?.capacity ?? 20,
minParticipants: initialData?.minParticipants ?? 5,
status: (initialData?.status ?? 'planned') as
| 'planned'
| 'open'
| 'running'
| 'completed'
| 'cancelled',
registrationDeadline: initialData?.registrationDeadline ?? '',
notes: initialData?.notes ?? '',
}, },
}); });
const { execute, isPending } = useAction(createCourse, { const { execute: execCreate, isPending: isCreating } = useActionWithToast(
onSuccess: ({ data }) => { createCourse,
if (data?.success) { {
toast.success('Kurs erfolgreich erstellt'); successMessage: 'Kurs erfolgreich erstellt',
router.push(`/home/${account}/courses`); errorMessage: 'Fehler beim Erstellen des Kurses',
onSuccess: () => router.push(`/home/${account}/courses`),
},
);
const { execute: execUpdate, isPending: isUpdating } = useActionWithToast(
updateCourse,
{
successMessage: 'Kurs aktualisiert',
errorMessage: 'Fehler beim Aktualisieren',
onSuccess: () => router.push(`/home/${account}/courses/${courseId}`),
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && courseId) {
execUpdate({ ...data, courseId } as any);
} else {
execCreate({ ...data, accountId } as any);
} }
}, };
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen des Kurses');
},
});
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((data) => execute(data))} onSubmit={form.handleSubmit(handleSubmit as any)}
className="space-y-6" className="space-y-6"
> >
<Card> <Card>
@@ -167,7 +213,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Kapazität</CardTitle> <CardTitle>Kapazität & Gebühren</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField <FormField
@@ -211,7 +257,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
name="fee" name="fee"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Gebühr ()</FormLabel> <FormLabel>Gebühr (EUR)</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
@@ -230,7 +276,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
name="reducedFee" name="reducedFee"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Ermäßigte Gebühr ()</FormLabel> <FormLabel>Ermäßigte Gebühr (EUR)</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
@@ -274,7 +320,6 @@ export function CreateCourseForm({ accountId, account }: Props) {
</FormItem> </FormItem>
)} )}
/> />
<div className="sm:col-span-1">
<FormField <FormField
control={form.control} control={form.control}
name="notes" name="notes"
@@ -291,25 +336,19 @@ export function CreateCourseForm({ accountId, account }: Props) {
</FormItem> </FormItem>
)} )}
/> />
</div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button type="button" variant="outline" onClick={() => router.back()}>
type="button"
variant="outline"
onClick={() => router.back()}
data-test="course-cancel-btn"
>
Abbrechen Abbrechen
</Button> </Button>
<Button <Button type="submit" disabled={isPending}>
type="submit" {isPending
disabled={isPending} ? 'Wird gespeichert...'
data-test="course-submit-btn" : isEdit
> ? 'Kurs aktualisieren'
{isPending ? 'Wird erstellt...' : 'Kurs erstellen'} : 'Kurs erstellen'}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -39,6 +39,7 @@ export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({ export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
courseId: z.string().uuid(), courseId: z.string().uuid(),
}); });
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
export const EnrollParticipantSchema = z.object({ export const EnrollParticipantSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),

View File

@@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { import {
CreateCourseSchema, CreateCourseSchema,
UpdateCourseSchema,
EnrollParticipantSchema, EnrollParticipantSchema,
CreateSessionSchema, CreateSessionSchema,
CreateCategorySchema, CreateCategorySchema,
@@ -29,6 +30,32 @@ export const createCourse = authActionClient
return { success: true, data: result }; return { success: true, data: result };
}); });
export const updateCourse = authActionClient
.inputSchema(UpdateCourseSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.update' }, 'Updating course...');
const result = await api.updateCourse(input);
logger.info({ name: 'course.update' }, 'Course updated');
return { success: true, data: result };
});
export const deleteCourse = authActionClient
.inputSchema(z.object({ courseId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.delete' }, 'Archiving course...');
await api.deleteCourse(input.courseId);
logger.info({ name: 'course.delete' }, 'Course archived');
return { success: true };
});
export const enrollParticipant = authActionClient export const enrollParticipant = authActionClient
.inputSchema(EnrollParticipantSchema) .inputSchema(EnrollParticipantSchema)
.action(async ({ parsedInput: input, ctx }) => { .action(async ({ parsedInput: input, ctx }) => {

View File

@@ -4,6 +4,7 @@ import type { Database } from '@kit/supabase/database';
import type { import type {
CreateCourseInput, CreateCourseInput,
UpdateCourseInput,
EnrollParticipantInput, EnrollParticipantInput,
} from '../schema/course.schema'; } from '../schema/course.schema';
@@ -78,6 +79,51 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
return data; return data;
}, },
async updateCourse(input: UpdateCourseInput) {
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.courseNumber !== undefined)
update.course_number = input.courseNumber || null;
if (input.description !== undefined)
update.description = input.description || null;
if (input.categoryId !== undefined)
update.category_id = input.categoryId || null;
if (input.instructorId !== undefined)
update.instructor_id = input.instructorId || null;
if (input.locationId !== undefined)
update.location_id = input.locationId || null;
if (input.startDate !== undefined)
update.start_date = input.startDate || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.reducedFee !== undefined)
update.reduced_fee = input.reducedFee ?? null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minParticipants !== undefined)
update.min_participants = input.minParticipants;
if (input.status !== undefined) update.status = input.status;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.notes !== undefined) update.notes = input.notes || null;
const { data, error } = await client
.from('courses')
.update(update)
.eq('id', input.courseId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteCourse(courseId: string) {
const { error } = await client
.from('courses')
.update({ status: 'cancelled' })
.eq('id', courseId);
if (error) throw error;
},
// --- Enrollment --- // --- Enrollment ---
async enrollParticipant(input: EnrollParticipantInput) { async enrollParticipant(input: EnrollParticipantInput) {
// Check capacity // Check capacity

View File

@@ -3,7 +3,6 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -17,58 +16,104 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { CreateEventSchema } from '../schema/event.schema'; import { CreateEventSchema, UpdateEventSchema } from '../schema/event.schema';
import { createEvent } from '../server/actions/event-actions'; import { createEvent, updateEvent } from '../server/actions/event-actions';
interface Props { interface Props {
accountId: string; accountId: string;
account: string; account: string;
/** If provided, form operates in edit mode */
eventId?: string;
initialData?: {
name: string;
description: string;
eventDate: string;
eventTime: string;
endDate: string;
location: string;
capacity: number | undefined;
minAge: number | undefined;
maxAge: number | undefined;
fee: number;
status: string;
registrationDeadline: string;
contactName: string;
contactEmail: string;
contactPhone: string;
};
} }
export function CreateEventForm({ accountId, account }: Props) { export function CreateEventForm({
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateEventSchema),
defaultValues: {
accountId, accountId,
name: '', account,
description: '', eventId,
eventDate: '', initialData,
eventTime: '', }: Props) {
endDate: '', const router = useRouter();
location: '', const isEdit = Boolean(eventId);
capacity: undefined as number | undefined,
minAge: undefined as number | undefined, const form = useForm({
maxAge: undefined as number | undefined, resolver: zodResolver(isEdit ? UpdateEventSchema : CreateEventSchema),
fee: 0, defaultValues: {
status: 'planned' as const, ...(isEdit ? { eventId } : { accountId }),
registrationDeadline: '', name: initialData?.name ?? '',
contactName: '', description: initialData?.description ?? '',
contactEmail: '', eventDate: initialData?.eventDate ?? '',
contactPhone: '', eventTime: initialData?.eventTime ?? '',
endDate: initialData?.endDate ?? '',
location: initialData?.location ?? '',
capacity: initialData?.capacity ?? (undefined as number | undefined),
minAge: initialData?.minAge ?? (undefined as number | undefined),
maxAge: initialData?.maxAge ?? (undefined as number | undefined),
fee: initialData?.fee ?? 0,
status: (initialData?.status ?? 'planned') as
| 'planned'
| 'open'
| 'full'
| 'running'
| 'completed'
| 'cancelled',
registrationDeadline: initialData?.registrationDeadline ?? '',
contactName: initialData?.contactName ?? '',
contactEmail: initialData?.contactEmail ?? '',
contactPhone: initialData?.contactPhone ?? '',
}, },
}); });
const { execute, isPending } = useAction(createEvent, { const { execute: execCreate, isPending: isCreating } = useActionWithToast(
onSuccess: ({ data }) => { createEvent,
if (data?.success) { {
toast.success('Veranstaltung erfolgreich erstellt'); successMessage: 'Veranstaltung erfolgreich erstellt',
router.push(`/home/${account}/events`); errorMessage: 'Fehler beim Erstellen der Veranstaltung',
} onSuccess: () => router.push(`/home/${account}/events`),
}, },
onError: ({ error }) => {
toast.error(
error.serverError ?? 'Fehler beim Erstellen der Veranstaltung',
); );
const { execute: execUpdate, isPending: isUpdating } = useActionWithToast(
updateEvent,
{
successMessage: 'Veranstaltung aktualisiert',
errorMessage: 'Fehler beim Aktualisieren',
onSuccess: () => router.push(`/home/${account}/events/${eventId}`),
}, },
}); );
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && eventId) {
execUpdate({ ...data, eventId } as any);
} else {
execCreate({ ...data, accountId } as any);
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((data) => execute(data))} onSubmit={form.handleSubmit(handleSubmit as any)}
className="space-y-6" className="space-y-6"
> >
<Card> <Card>
@@ -241,7 +286,7 @@ export function CreateEventForm({ accountId, account }: Props) {
name="fee" name="fee"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Gebühr ()</FormLabel> <FormLabel>Gebühr (EUR)</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
@@ -365,7 +410,11 @@ export function CreateEventForm({ accountId, account }: Props) {
disabled={isPending} disabled={isPending}
data-test="event-submit-btn" data-test="event-submit-btn"
> >
{isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'} {isPending
? 'Wird gespeichert...'
: isEdit
? 'Veranstaltung aktualisieren'
: 'Veranstaltung erstellen'}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -29,6 +29,11 @@ export const CreateEventSchema = z.object({
}); });
export type CreateEventInput = z.infer<typeof CreateEventSchema>; export type CreateEventInput = z.infer<typeof CreateEventSchema>;
export const UpdateEventSchema = CreateEventSchema.partial().extend({
eventId: z.string().uuid(),
});
export type UpdateEventInput = z.infer<typeof UpdateEventSchema>;
export const EventRegistrationSchema = z.object({ export const EventRegistrationSchema = z.object({
eventId: z.string().uuid(), eventId: z.string().uuid(),
firstName: z.string().min(1), firstName: z.string().min(1),

View File

@@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { import {
CreateEventSchema, CreateEventSchema,
UpdateEventSchema,
EventRegistrationSchema, EventRegistrationSchema,
CreateHolidayPassSchema, CreateHolidayPassSchema,
} from '../../schema/event.schema'; } from '../../schema/event.schema';
@@ -26,6 +27,32 @@ export const createEvent = authActionClient
return { success: true, data: result }; return { success: true, data: result };
}); });
export const updateEvent = authActionClient
.inputSchema(UpdateEventSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.update' }, 'Updating event...');
const result = await api.updateEvent(input);
logger.info({ name: 'event.update' }, 'Event updated');
return { success: true, data: result };
});
export const deleteEvent = authActionClient
.inputSchema(z.object({ eventId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.delete' }, 'Cancelling event...');
await api.deleteEvent(input.eventId);
logger.info({ name: 'event.delete' }, 'Event cancelled');
return { success: true };
});
export const registerForEvent = authActionClient export const registerForEvent = authActionClient
.inputSchema(EventRegistrationSchema) .inputSchema(EventRegistrationSchema)
.action(async ({ parsedInput: input, ctx }) => { .action(async ({ parsedInput: input, ctx }) => {

View File

@@ -2,7 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database'; import type { Database } from '@kit/supabase/database';
import type { CreateEventInput } from '../schema/event.schema'; import type {
CreateEventInput,
UpdateEventInput,
} from '../schema/event.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -69,7 +72,7 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
account_id: input.accountId, account_id: input.accountId,
name: input.name, name: input.name,
description: input.description || null, description: input.description || null,
event_date: input.eventDate || null, event_date: input.eventDate,
event_time: input.eventTime || null, event_time: input.eventTime || null,
end_date: input.endDate || null, end_date: input.endDate || null,
location: input.location || null, location: input.location || null,
@@ -89,6 +92,50 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
return data; return data;
}, },
async updateEvent(input: UpdateEventInput) {
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.description !== undefined)
update.description = input.description || null;
if (input.eventDate !== undefined)
update.event_date = input.eventDate || null;
if (input.eventTime !== undefined)
update.event_time = input.eventTime || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.location !== undefined)
update.location = input.location || null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minAge !== undefined) update.min_age = input.minAge ?? null;
if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.status !== undefined) update.status = input.status;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.contactName !== undefined)
update.contact_name = input.contactName || null;
if (input.contactEmail !== undefined)
update.contact_email = input.contactEmail || null;
if (input.contactPhone !== undefined)
update.contact_phone = input.contactPhone || null;
const { data, error } = await client
.from('events')
.update(update)
.eq('id', input.eventId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteEvent(eventId: string) {
const { error } = await client
.from('events')
.update({ status: 'cancelled' })
.eq('id', eventId);
if (error) throw error;
},
async registerForEvent(input: { async registerForEvent(input: {
eventId: string; eventId: string;
firstName: string; firstName: string;

View File

@@ -20,7 +20,10 @@ import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { CreateFishSpeciesSchema } from '../schema/fischerei.schema'; import { CreateFishSpeciesSchema } from '../schema/fischerei.schema';
import { createSpecies } from '../server/actions/fischerei-actions'; import {
createSpecies,
updateSpecies,
} from '../server/actions/fischerei-actions';
interface CreateSpeciesFormProps { interface CreateSpeciesFormProps {
accountId: string; accountId: string;
@@ -65,22 +68,50 @@ export function CreateSpeciesForm({
}, },
}); });
const { execute, isPending } = useAction(createSpecies, { const { execute: executeCreate, isPending: isCreating } = useAction(
createSpecies,
{
onSuccess: ({ data }) => { onSuccess: ({ data }) => {
if (data?.success) { if (data?.success) {
toast.success(isEdit ? 'Fischart aktualisiert' : 'Fischart erstellt'); toast.success('Fischart erstellt');
router.push(`/home/${account}/fischerei/species`); router.push(`/home/${account}/fischerei/species`);
} }
}, },
onError: ({ error }) => { onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern'); toast.error(error.serverError ?? 'Fehler beim Speichern');
}, },
}); },
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateSpecies,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Fischart aktualisiert');
router.push(`/home/${account}/fischerei/species`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && species?.id) {
executeUpdate({ ...data, speciesId: String(species.id) } as any);
} else {
executeCreate(data as any);
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((data) => execute(data))} onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6" className="space-y-6"
> >
{/* Card 1: Grunddaten */} {/* Card 1: Grunddaten */}

View File

@@ -21,13 +21,17 @@ import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { CreateStockingSchema } from '../schema/fischerei.schema'; import { CreateStockingSchema } from '../schema/fischerei.schema';
import { createStocking } from '../server/actions/fischerei-actions'; import {
createStocking,
updateStocking,
} from '../server/actions/fischerei-actions';
interface CreateStockingFormProps { interface CreateStockingFormProps {
accountId: string; accountId: string;
account: string; account: string;
waters: Array<{ id: string; name: string }>; waters: Array<{ id: string; name: string }>;
species: Array<{ id: string; name: string }>; species: Array<{ id: string; name: string }>;
stocking?: Record<string, unknown>;
} }
export function CreateStockingForm({ export function CreateStockingForm({
@@ -35,26 +39,45 @@ export function CreateStockingForm({
account, account,
waters, waters,
species, species,
stocking,
}: CreateStockingFormProps) { }: CreateStockingFormProps) {
const router = useRouter(); const router = useRouter();
const isEdit = !!stocking;
const form = useForm({ const form = useForm({
resolver: zodResolver(CreateStockingSchema), resolver: zodResolver(CreateStockingSchema),
defaultValues: { defaultValues: {
accountId, accountId,
waterId: '', waterId: (stocking?.water_id as string) ?? '',
speciesId: '', speciesId: (stocking?.species_id as string) ?? '',
stockingDate: todayISO(), stockingDate: (stocking?.stocking_date as string) ?? todayISO(),
quantity: 0, quantity: stocking?.quantity != null ? Number(stocking.quantity) : 0,
weightKg: undefined as number | undefined, weightKg:
ageClass: 'sonstige' as const, stocking?.weight_kg != null
costEuros: undefined as number | undefined, ? Number(stocking.weight_kg)
supplierId: undefined as string | undefined, : (undefined as number | undefined),
remarks: '', ageClass: ((stocking?.age_class as string) ?? 'sonstige') as
| 'brut'
| 'soemmerlinge'
| 'einsoemmerig'
| 'zweisoemmerig'
| 'dreisoemmerig'
| 'vorgestreckt'
| 'setzlinge'
| 'laichfische'
| 'sonstige',
costEuros:
stocking?.cost_euros != null
? Number(stocking.cost_euros)
: (undefined as number | undefined),
supplierId: (stocking?.supplier_id as string | undefined) ?? undefined,
remarks: (stocking?.remarks as string) ?? '',
}, },
}); });
const { execute, isPending } = useAction(createStocking, { const { execute: executeCreate, isPending: isCreating } = useAction(
createStocking,
{
onSuccess: ({ data }) => { onSuccess: ({ data }) => {
if (data?.success) { if (data?.success) {
toast.success('Besatz eingetragen'); toast.success('Besatz eingetragen');
@@ -64,12 +87,38 @@ export function CreateStockingForm({
onError: ({ error }) => { onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern'); toast.error(error.serverError ?? 'Fehler beim Speichern');
}, },
}); },
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateStocking,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Besatz aktualisiert');
router.push(`/home/${account}/fischerei/stocking`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && stocking?.id) {
executeUpdate({ ...data, stockingId: String(stocking.id) } as any);
} else {
executeCreate(data as any);
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((data) => execute(data))} onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6" className="space-y-6"
> >
<Card> <Card>
@@ -260,7 +309,11 @@ export function CreateStockingForm({
disabled={isPending} disabled={isPending}
data-test="stocking-submit-btn" data-test="stocking-submit-btn"
> >
{isPending ? 'Wird gespeichert...' : 'Besatz eintragen'} {isPending
? 'Wird gespeichert...'
: isEdit
? 'Besatz aktualisieren'
: 'Besatz eintragen'}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -20,7 +20,7 @@ import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { CreateWaterSchema } from '../schema/fischerei.schema'; import { CreateWaterSchema } from '../schema/fischerei.schema';
import { createWater } from '../server/actions/fischerei-actions'; import { createWater, updateWater } from '../server/actions/fischerei-actions';
interface CreateWaterFormProps { interface CreateWaterFormProps {
accountId: string; accountId: string;
@@ -65,22 +65,50 @@ export function CreateWaterForm({
}, },
}); });
const { execute, isPending } = useAction(createWater, { const { execute: executeCreate, isPending: isCreating } = useAction(
createWater,
{
onSuccess: ({ data }) => { onSuccess: ({ data }) => {
if (data?.success) { if (data?.success) {
toast.success(isEdit ? 'Gewässer aktualisiert' : 'Gewässer erstellt'); toast.success('Gewässer erstellt');
router.push(`/home/${account}/fischerei/waters`); router.push(`/home/${account}/fischerei/waters`);
} }
}, },
onError: ({ error }) => { onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern'); toast.error(error.serverError ?? 'Fehler beim Speichern');
}, },
}); },
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateWater,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Gewässer aktualisiert');
router.push(`/home/${account}/fischerei/waters`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && water?.id) {
executeUpdate({ ...data, waterId: String(water.id) } as any);
} else {
executeCreate(data as any);
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((data) => execute(data))} onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6" className="space-y-6"
> >
{/* Card 1: Grunddaten */} {/* Card 1: Grunddaten */}

View File

@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import { 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';
interface DeleteConfirmButtonProps {
title: string;
description: string;
isPending?: boolean;
onConfirm: () => void;
}
export function DeleteConfirmButton({
title,
description,
isPending,
onConfirm,
}: DeleteConfirmButtonProps) {
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
data-test="delete-btn"
disabled={isPending}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Trash2 className="text-destructive h-4 w-4" />
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
{isPending ? 'Wird gelöscht...' : 'Löschen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -5,13 +5,17 @@ import { useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react'; import { Pencil, Plus } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { deleteSpecies } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface SpeciesDataTableProps { interface SpeciesDataTableProps {
data: Array<Record<string, unknown>>; data: Array<Record<string, unknown>>;
@@ -19,6 +23,7 @@ interface SpeciesDataTableProps {
page: number; page: number;
pageSize: number; pageSize: number;
account: string; account: string;
accountId: string;
} }
export function SpeciesDataTable({ export function SpeciesDataTable({
@@ -27,10 +32,19 @@ export function SpeciesDataTable({
page, page,
pageSize, pageSize,
account, account,
accountId,
}: SpeciesDataTableProps) { }: SpeciesDataTableProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const currentSearch = searchParams.get('q') ?? ''; const currentSearch = searchParams.get('q') ?? '';
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteSpecies,
{
successMessage: 'Fischart gelöscht',
onSuccess: () => router.refresh(),
},
);
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({ const form = useForm({
@@ -132,6 +146,7 @@ export function SpeciesDataTable({
<th className="p-3 text-right font-medium"> <th className="p-3 text-right font-medium">
Max. Fang/Tag Max. Fang/Tag
</th> </th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -162,6 +177,33 @@ export function SpeciesDataTable({
? String(species.max_catch_per_day) ? String(species.max_catch_per_day)
: '—'} : '—'}
</td> </td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
data-test="species-edit-btn"
onClick={() =>
router.push(
`/home/${account}/fischerei/species/${String(species.id)}/edit`,
)
}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteConfirmButton
title="Fischart löschen"
description="Möchten Sie diese Fischart wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
speciesId: String(species.id),
accountId,
})
}
/>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -5,7 +5,7 @@ import { useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react'; import { Pencil, Plus } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates'; import { formatDate } from '@kit/shared/dates';
@@ -14,8 +14,11 @@ import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { AGE_CLASS_LABELS } from '../lib/fischerei-constants'; import { AGE_CLASS_LABELS } from '../lib/fischerei-constants';
import { deleteStocking } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface StockingDataTableProps { interface StockingDataTableProps {
data: Array<Record<string, unknown>>; data: Array<Record<string, unknown>>;
@@ -23,6 +26,7 @@ interface StockingDataTableProps {
page: number; page: number;
pageSize: number; pageSize: number;
account: string; account: string;
accountId: string;
} }
export function StockingDataTable({ export function StockingDataTable({
@@ -31,11 +35,20 @@ export function StockingDataTable({
page, page,
pageSize, pageSize,
account, account,
accountId,
}: StockingDataTableProps) { }: StockingDataTableProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteStocking,
{
successMessage: 'Besatz gelöscht',
onSuccess: () => router.refresh(),
},
);
const updateParams = useCallback( const updateParams = useCallback(
(updates: Record<string, string>) => { (updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -102,6 +115,7 @@ export function StockingDataTable({
<th className="p-3 text-right font-medium">Gewicht (kg)</th> <th className="p-3 text-right font-medium">Gewicht (kg)</th>
<th className="p-3 text-left font-medium">Altersklasse</th> <th className="p-3 text-left font-medium">Altersklasse</th>
<th className="p-3 text-right font-medium">Kosten ()</th> <th className="p-3 text-right font-medium">Kosten ()</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -145,6 +159,33 @@ export function StockingDataTable({
? formatCurrencyAmount(row.cost_euros as number) ? formatCurrencyAmount(row.cost_euros as number)
: '—'} : '—'}
</td> </td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
data-test="stocking-edit-btn"
onClick={() =>
router.push(
`/home/${account}/fischerei/stocking/${String(row.id)}/edit`,
)
}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteConfirmButton
title="Besatz löschen"
description="Möchten Sie diesen Besatzeintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
stockingId: String(row.id),
accountId,
})
}
/>
</div>
</td>
</tr> </tr>
); );
})} })}

View File

@@ -5,7 +5,7 @@ import { useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Plus } from 'lucide-react'; import { Pencil, Plus } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { formatNumber } from '@kit/shared/formatters'; import { formatNumber } from '@kit/shared/formatters';
@@ -13,8 +13,11 @@ import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { WATER_TYPE_LABELS } from '../lib/fischerei-constants'; import { WATER_TYPE_LABELS } from '../lib/fischerei-constants';
import { deleteWater } from '../server/actions/fischerei-actions';
import { DeleteConfirmButton } from './delete-confirm-button';
interface WatersDataTableProps { interface WatersDataTableProps {
data: Array<Record<string, unknown>>; data: Array<Record<string, unknown>>;
@@ -22,6 +25,7 @@ interface WatersDataTableProps {
page: number; page: number;
pageSize: number; pageSize: number;
account: string; account: string;
accountId: string;
} }
const WATER_TYPE_OPTIONS = [ const WATER_TYPE_OPTIONS = [
@@ -43,10 +47,19 @@ export function WatersDataTable({
page, page,
pageSize, pageSize,
account, account,
accountId,
}: WatersDataTableProps) { }: WatersDataTableProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
deleteWater,
{
successMessage: 'Gewässer gelöscht',
onSuccess: () => router.refresh(),
},
);
const currentSearch = searchParams.get('q') ?? ''; const currentSearch = searchParams.get('q') ?? '';
const currentType = searchParams.get('type') ?? ''; const currentType = searchParams.get('type') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -170,6 +183,7 @@ export function WatersDataTable({
<th className="p-3 text-left font-medium">Typ</th> <th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Fläche (ha)</th> <th className="p-3 text-right font-medium">Fläche (ha)</th>
<th className="p-3 text-left font-medium">Ort</th> <th className="p-3 text-left font-medium">Ort</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -208,6 +222,34 @@ export function WatersDataTable({
<td className="text-muted-foreground p-3"> <td className="text-muted-foreground p-3">
{String(water.location ?? '—')} {String(water.location ?? '—')}
</td> </td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
data-test="water-edit-btn"
onClick={(e) => {
e.stopPropagation();
router.push(
`/home/${account}/fischerei/waters/${String(water.id)}/edit`,
);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteConfirmButton
title="Gewässer löschen"
description="Möchten Sie dieses Gewässer wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
isPending={isDeleting}
onConfirm={() =>
executeDelete({
waterId: String(water.id),
accountId,
})
}
/>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -473,6 +473,16 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
return { data: data ?? [], total: count ?? 0, page, pageSize }; return { data: data ?? [], total: count ?? 0, page, pageSize };
}, },
async getStocking(stockingId: string) {
const { data, error } = await client
.from('fish_stocking')
.select('*')
.eq('id', stockingId)
.single();
if (error) throw error;
return data;
},
async createStocking(input: CreateStockingInput, userId: string) { async createStocking(input: CreateStockingInput, userId: string) {
const { data, error } = await client const { data, error } = await client
.from('fish_stocking') .from('fish_stocking')

View File

@@ -15,6 +15,7 @@
"./hooks/*": "./src/hooks/*.ts", "./hooks/*": "./src/hooks/*.ts",
"./components": "./src/components/index.ts", "./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts", "./actions/*": "./src/server/actions/*.ts",
"./types": "./src/types.ts",
"./services/*": "./src/server/services/*.ts" "./services/*": "./src/server/services/*.ts"
}, },
"scripts": { "scripts": {

View File

@@ -33,19 +33,7 @@ export const createRecord = authActionClient
} }
// Validate data against field definitions // Validate data against field definitions
const fields = ( const { fields } = moduleWithFields;
moduleWithFields as unknown as {
fields: Array<{
name: string;
field_type: string;
is_required: boolean;
min_value?: number | null;
max_value?: number | null;
max_length?: number | null;
regex_pattern?: string | null;
}>;
}
).fields;
const validation = validateRecordData( const validation = validateRecordData(
input.data as Record<string, unknown>, input.data as Record<string, unknown>,
fields as Parameters<typeof validateRecordData>[1], fields as Parameters<typeof validateRecordData>[1],
@@ -98,19 +86,7 @@ export const updateRecord = authActionClient
throw new Error('Module not found'); throw new Error('Module not found');
} }
const fields = ( const { fields } = moduleWithFields;
moduleWithFields as unknown as {
fields: Array<{
name: string;
field_type: string;
is_required: boolean;
min_value?: number | null;
max_value?: number | null;
max_length?: number | null;
regex_pattern?: string | null;
}>;
}
).fields;
const validation = validateRecordData( const validation = validateRecordData(
input.data as Record<string, unknown>, input.data as Record<string, unknown>,
fields as Parameters<typeof validateRecordData>[1], fields as Parameters<typeof validateRecordData>[1],

View File

@@ -6,6 +6,7 @@ import type {
CreateModuleInput, CreateModuleInput,
UpdateModuleInput, UpdateModuleInput,
} from '../../schema/module.schema'; } from '../../schema/module.schema';
import type { ModuleWithFields } from '../../types';
/** /**
* Service for managing module definitions (CRUD). * Service for managing module definitions (CRUD).
@@ -38,15 +39,20 @@ export function createModuleDefinitionService(
return data; return data;
}, },
async getModuleWithFields(moduleId: string) { async getModuleWithFields(
moduleId: string,
): Promise<ModuleWithFields | null> {
const { data, error } = await client const { data, error } = await client
.from('modules') .from('modules')
.select('*, fields:module_fields(*)') .select('*, fields:module_fields(*)')
.eq('id', moduleId) .eq('id', moduleId)
.single(); .single();
if (error) throw error; if (error) {
return data; if (error.code === 'PGRST116') return null;
throw error;
}
return data as unknown as ModuleWithFields;
}, },
async createModule(input: CreateModuleInput) { async createModule(input: CreateModuleInput) {

View File

@@ -0,0 +1,12 @@
import type { Tables } from '@kit/supabase/database';
/** A module row from the database */
export type Module = Tables<'modules'>;
/** A module field row from the database */
export type ModuleField = Tables<'module_fields'>;
/** Module with its field definitions joined */
export interface ModuleWithFields extends Module {
fields: ModuleField[];
}