feat: add update and delete functionality for courses, events, and species; enhance attendance tracking and category creation
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
<table className="w-full text-sm">
|
Bitte wählen Sie einen Termin aus
|
||||||
<thead>
|
</p>
|
||||||
<tr className="bg-muted/50 border-b">
|
|
||||||
<th className="p-3 text-left font-medium">
|
|
||||||
Teilnehmer
|
|
||||||
</th>
|
|
||||||
<th className="p-3 text-center font-medium">
|
|
||||||
Anwesend
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{participants.map((p: Record<string, unknown>) => (
|
|
||||||
<tr
|
|
||||||
key={String(p.id)}
|
|
||||||
className="hover:bg-muted/30 border-b"
|
|
||||||
>
|
|
||||||
<td className="p-3 font-medium">
|
|
||||||
{String(p.last_name ?? '')},{' '}
|
|
||||||
{String(p.first_name ?? '')}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={
|
|
||||||
attendanceMap.get(String(p.id)) ?? false
|
|
||||||
}
|
|
||||||
className="h-4 w-4 rounded border-gray-300"
|
|
||||||
aria-label={`Anwesenheit ${String(p.last_name)}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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 "Abgesagt" gesetzt. Diese
|
||||||
|
Aktion kann rückgängig gemacht werden.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => execute({ courseId })}>
|
||||||
|
Absagen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
<ChevronLeft className="h-4 w-4" />
|
href={`/home/${account}/courses/calendar?month=${
|
||||||
</Button>
|
month === 0
|
||||||
|
? `${year - 1}-12`
|
||||||
|
: `${year}-${String(month).padStart(2, '0')}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{MONTH_NAMES[month]} {year}
|
{MONTH_NAMES[month]} {year}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="icon" disabled>
|
<Link
|
||||||
<ChevronRight className="h-4 w-4" />
|
href={`/home/${account}/courses/calendar?month=${
|
||||||
</Button>
|
month === 11
|
||||||
|
? `${year + 1}-01`
|
||||||
|
: `${year}-${String(month + 2).padStart(2, '0')}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 "Abgesagt" gesetzt.
|
||||||
|
Diese Aktion kann rückgängig gemacht werden.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => execute({ eventId })}>
|
||||||
|
Absagen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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({
|
||||||
|
accountId,
|
||||||
|
account,
|
||||||
|
courseId,
|
||||||
|
initialData,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isEdit = Boolean(courseId);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(CreateCourseSchema),
|
resolver: zodResolver(isEdit ? UpdateCourseSchema : CreateCourseSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
accountId,
|
...(isEdit ? { courseId } : { accountId }),
|
||||||
courseNumber: '',
|
courseNumber: initialData?.courseNumber ?? '',
|
||||||
name: '',
|
name: initialData?.name ?? '',
|
||||||
description: '',
|
description: initialData?.description ?? '',
|
||||||
startDate: '',
|
startDate: initialData?.startDate ?? '',
|
||||||
endDate: '',
|
endDate: initialData?.endDate ?? '',
|
||||||
fee: 0,
|
fee: initialData?.fee ?? 0,
|
||||||
reducedFee: 0,
|
reducedFee: initialData?.reducedFee ?? 0,
|
||||||
capacity: 20,
|
capacity: initialData?.capacity ?? 20,
|
||||||
minParticipants: 5,
|
minParticipants: initialData?.minParticipants ?? 5,
|
||||||
status: 'planned' as const,
|
status: (initialData?.status ?? 'planned') as
|
||||||
registrationDeadline: '',
|
| 'planned'
|
||||||
notes: '',
|
| '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`),
|
||||||
},
|
},
|
||||||
onError: ({ error }) => {
|
);
|
||||||
toast.error(error.serverError ?? 'Fehler beim Erstellen des Kurses');
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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,42 +320,35 @@ 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"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>Notizen</FormLabel>
|
||||||
<FormLabel>Notizen</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<textarea
|
||||||
<textarea
|
{...field}
|
||||||
{...field}
|
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
|
||||||
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
accountId,
|
||||||
|
account,
|
||||||
|
eventId,
|
||||||
|
initialData,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isEdit = Boolean(eventId);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(CreateEventSchema),
|
resolver: zodResolver(isEdit ? UpdateEventSchema : CreateEventSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
accountId,
|
...(isEdit ? { eventId } : { accountId }),
|
||||||
name: '',
|
name: initialData?.name ?? '',
|
||||||
description: '',
|
description: initialData?.description ?? '',
|
||||||
eventDate: '',
|
eventDate: initialData?.eventDate ?? '',
|
||||||
eventTime: '',
|
eventTime: initialData?.eventTime ?? '',
|
||||||
endDate: '',
|
endDate: initialData?.endDate ?? '',
|
||||||
location: '',
|
location: initialData?.location ?? '',
|
||||||
capacity: undefined as number | undefined,
|
capacity: initialData?.capacity ?? (undefined as number | undefined),
|
||||||
minAge: undefined as number | undefined,
|
minAge: initialData?.minAge ?? (undefined as number | undefined),
|
||||||
maxAge: undefined as number | undefined,
|
maxAge: initialData?.maxAge ?? (undefined as number | undefined),
|
||||||
fee: 0,
|
fee: initialData?.fee ?? 0,
|
||||||
status: 'planned' as const,
|
status: (initialData?.status ?? 'planned') as
|
||||||
registrationDeadline: '',
|
| 'planned'
|
||||||
contactName: '',
|
| 'open'
|
||||||
contactEmail: '',
|
| 'full'
|
||||||
contactPhone: '',
|
| '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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
onSuccess: ({ data }) => {
|
createSpecies,
|
||||||
if (data?.success) {
|
{
|
||||||
toast.success(isEdit ? 'Fischart aktualisiert' : 'Fischart erstellt');
|
onSuccess: ({ data }) => {
|
||||||
router.push(`/home/${account}/fischerei/species`);
|
if (data?.success) {
|
||||||
}
|
toast.success('Fischart erstellt');
|
||||||
|
router.push(`/home/${account}/fischerei/species`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Speichern');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError: ({ error }) => {
|
);
|
||||||
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 */}
|
||||||
|
|||||||
@@ -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,41 +39,86 @@ 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(
|
||||||
onSuccess: ({ data }) => {
|
createStocking,
|
||||||
if (data?.success) {
|
{
|
||||||
toast.success('Besatz eingetragen');
|
onSuccess: ({ data }) => {
|
||||||
router.push(`/home/${account}/fischerei/stocking`);
|
if (data?.success) {
|
||||||
}
|
toast.success('Besatz eingetragen');
|
||||||
|
router.push(`/home/${account}/fischerei/stocking`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Speichern');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError: ({ error }) => {
|
);
|
||||||
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>
|
||||||
|
|||||||
@@ -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(
|
||||||
onSuccess: ({ data }) => {
|
createWater,
|
||||||
if (data?.success) {
|
{
|
||||||
toast.success(isEdit ? 'Gewässer aktualisiert' : 'Gewässer erstellt');
|
onSuccess: ({ data }) => {
|
||||||
router.push(`/home/${account}/fischerei/waters`);
|
if (data?.success) {
|
||||||
}
|
toast.success('Gewässer erstellt');
|
||||||
|
router.push(`/home/${account}/fischerei/waters`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Speichern');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError: ({ error }) => {
|
);
|
||||||
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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
12
packages/features/module-builder/src/types.ts
Normal file
12
packages/features/module-builder/src/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user