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 { EmptyState } from '~/components/empty-state';
|
||||
|
||||
import { AttendanceGrid } from './attendance-grid';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; courseId: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
@@ -49,6 +51,12 @@ export default async function AttendancePage({
|
||||
]),
|
||||
);
|
||||
|
||||
const participantList = participants.map((p: Record<string, unknown>) => ({
|
||||
id: String(p.id),
|
||||
firstName: String(p.first_name ?? ''),
|
||||
lastName: String(p.last_name ?? ''),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Anwesenheit">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
@@ -59,7 +67,6 @@ export default async function AttendancePage({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session Selector */}
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Calendar className="h-8 w-8" />}
|
||||
@@ -96,7 +103,6 @@ export default async function AttendancePage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attendance Grid */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -105,48 +111,16 @@ export default async function AttendancePage({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{participants.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Keine Teilnehmer in diesem Kurs
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-3 text-left font-medium">
|
||||
Teilnehmer
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
Anwesend
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p: Record<string, unknown>) => (
|
||||
<tr
|
||||
key={String(p.id)}
|
||||
className="hover:bg-muted/30 border-b"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{String(p.last_name ?? '')},{' '}
|
||||
{String(p.first_name ?? '')}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={
|
||||
attendanceMap.get(String(p.id)) ?? false
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
aria-label={`Anwesenheit ${String(p.last_name)}`}
|
||||
{selectedSessionId ? (
|
||||
<AttendanceGrid
|
||||
sessionId={selectedSessionId}
|
||||
participants={participantList}
|
||||
initialAttendance={attendanceMap}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
Bitte wählen Sie einen Termin aus
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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,
|
||||
User,
|
||||
Clock,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||
@@ -19,6 +20,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { DeleteCourseButton } from './delete-course-button';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; courseId: string }>;
|
||||
}
|
||||
@@ -60,6 +63,17 @@ export default async function CourseDetailPage({ params }: PageProps) {
|
||||
return (
|
||||
<CmsPageShell account={account} title={String(courseData.name)}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/home/${account}/courses/${courseId}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
<DeleteCourseButton courseId={courseId} accountSlug={account} />
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
@@ -42,8 +43,12 @@ function getFirstWeekday(year: number, month: number): number {
|
||||
return day === 0 ? 6 : day - 1;
|
||||
}
|
||||
|
||||
export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
export default async function CourseCalendarPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
@@ -58,8 +63,17 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const monthParam = search.month as string | undefined;
|
||||
let year: number;
|
||||
let month: number;
|
||||
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
|
||||
const [y, m] = monthParam.split('-').map(Number);
|
||||
year = y!;
|
||||
month = m! - 1;
|
||||
} else {
|
||||
year = now.getFullYear();
|
||||
month = now.getMonth();
|
||||
}
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstWeekday = getFirstWeekday(year, month);
|
||||
|
||||
@@ -139,15 +153,31 @@ export default async function CourseCalendarPage({ params }: PageProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<Link
|
||||
href={`/home/${account}/courses/calendar?month=${
|
||||
month === 0
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(month).padStart(2, '0')}`
|
||||
}`}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<CardTitle>
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<Link
|
||||
href={`/home/${account}/courses/calendar?month=${
|
||||
month === 11
|
||||
? `${year + 1}-01`
|
||||
: `${year}-${String(month + 2).padStart(2, '0')}`
|
||||
}`}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
import { CreateCategoryDialog } from './create-category-dialog';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
@@ -33,10 +34,7 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground">Kurskategorien verwalten</p>
|
||||
<Button data-test="categories-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Kategorie
|
||||
</Button>
|
||||
<CreateCategoryDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
import { CreateInstructorDialog } from './create-instructor-dialog';
|
||||
|
||||
interface PageProps {
|
||||
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 items-center justify-between">
|
||||
<p className="text-muted-foreground">Dozentenpool verwalten</p>
|
||||
<Button data-test="instructors-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Dozent
|
||||
</Button>
|
||||
<CreateInstructorDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
import { CreateLocationDialog } from './create-location-dialog';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
@@ -35,10 +36,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
<p className="text-muted-foreground">
|
||||
Kurs- und Veranstaltungsorte verwalten
|
||||
</p>
|
||||
<Button data-test="locations-new-btn">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Ort
|
||||
</Button>
|
||||
<CreateLocationDialog accountId={acct.id} />
|
||||
</div>
|
||||
|
||||
{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,
|
||||
MapPin,
|
||||
Users,
|
||||
Euro,
|
||||
Clock,
|
||||
Pencil,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -17,7 +17,8 @@ import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
|
||||
import { DeleteEventButton } from './delete-event-button';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string; eventId: string }>;
|
||||
@@ -61,6 +62,17 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
return (
|
||||
<CmsPageShell account={account} title={String(eventData.name)}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/home/${account}/events/${eventId}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
<DeleteEventButton eventId={eventId} accountSlug={account} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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}
|
||||
pageSize={50}
|
||||
account={account}
|
||||
accountId={acct.id}
|
||||
/>
|
||||
</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}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
accountId={acct.id}
|
||||
/>
|
||||
</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}
|
||||
pageSize={25}
|
||||
account={account}
|
||||
accountId={acct.id}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
|
||||
@@ -33,24 +33,7 @@ export default async function RecordDetailPage({
|
||||
|
||||
if (!moduleWithFields || !record) return <div>Nicht gefunden</div>;
|
||||
|
||||
const 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;
|
||||
const fields = moduleWithFields.fields;
|
||||
|
||||
return (
|
||||
<CmsPageShell
|
||||
|
||||
@@ -19,12 +19,7 @@ export default async function ImportPage({ params }: ImportPageProps) {
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const fields =
|
||||
(
|
||||
moduleWithFields as unknown as {
|
||||
fields: Array<{ name: string; display_name: string }>;
|
||||
}
|
||||
).fields ?? [];
|
||||
const fields = moduleWithFields.fields ?? [];
|
||||
|
||||
return (
|
||||
<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);
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const 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;
|
||||
const fields = moduleWithFields.fields;
|
||||
|
||||
return (
|
||||
<CmsPageShell
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { decodeFilters } from './_lib/filter-params';
|
||||
import { ModuleRecordsTable } from './module-records-table';
|
||||
import { ModuleSearchBar } from './module-search-bar';
|
||||
|
||||
interface ModuleDetailPageProps {
|
||||
@@ -33,34 +34,34 @@ export default async function ModuleDetailPage({
|
||||
const pageSize =
|
||||
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 result = await api.query.query({
|
||||
moduleId,
|
||||
page,
|
||||
pageSize,
|
||||
sortField:
|
||||
(search.sort as string) ??
|
||||
moduleWithFields.default_sort_field ??
|
||||
undefined,
|
||||
sortDirection:
|
||||
(search.dir as 'asc' | 'desc') ??
|
||||
(moduleWithFields.default_sort_direction as 'asc' | 'desc') ??
|
||||
'asc',
|
||||
sortField,
|
||||
sortDirection,
|
||||
search: (search.q as string) ?? undefined,
|
||||
filters,
|
||||
});
|
||||
|
||||
const fields = (
|
||||
moduleWithFields as unknown as {
|
||||
fields: Array<{
|
||||
name: string;
|
||||
display_name: string;
|
||||
show_in_filter: boolean;
|
||||
show_in_search: boolean;
|
||||
}>;
|
||||
}
|
||||
).fields;
|
||||
const allFields = moduleWithFields.fields;
|
||||
|
||||
const records = (result.data ?? []).map((row: Record<string, unknown>) => ({
|
||||
id: String(row.id ?? ''),
|
||||
data: (row.data ?? {}) as Record<string, unknown>,
|
||||
status: String(row.status ?? 'active'),
|
||||
created_at: String(row.created_at ?? ''),
|
||||
updated_at: String(row.updated_at ?? ''),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -83,18 +84,18 @@ export default async function ModuleDetailPage({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ModuleSearchBar fields={fields} />
|
||||
<ModuleSearchBar fields={allFields} />
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{result.pagination.total} Datensätze — Seite {result.pagination.page}{' '}
|
||||
von {result.pagination.totalPages}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<pre className="max-h-96 overflow-auto p-4 text-xs">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<ModuleRecordsTable
|
||||
fields={allFields}
|
||||
records={records}
|
||||
pagination={result.pagination}
|
||||
account={account}
|
||||
moduleId={moduleId}
|
||||
currentSort={
|
||||
sortField ? { field: sortField, direction: sortDirection } : undefined
|
||||
}
|
||||
/>
|
||||
</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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
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 { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { DeleteModuleButton } from './delete-module-button';
|
||||
import { ModuleSettingsForm } from './module-settings-form';
|
||||
|
||||
interface ModuleSettingsPageProps {
|
||||
params: Promise<{ account: string; moduleId: string }>;
|
||||
@@ -27,8 +25,24 @@ export default async function ModuleSettingsPage({
|
||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||
|
||||
const mod = moduleWithFields;
|
||||
const fields =
|
||||
(mod as unknown as { fields: Array<Record<string, unknown>> }).fields ?? [];
|
||||
const fields = mod.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 (
|
||||
<CmsPageShell
|
||||
@@ -36,71 +50,17 @@ export default async function ModuleSettingsPage({
|
||||
title={`${String(mod.display_name)} — Einstellungen`}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* General Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Allgemein
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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"
|
||||
<ModuleSettingsForm
|
||||
moduleId={moduleId}
|
||||
initialData={{
|
||||
name: String(mod.name),
|
||||
displayName: String(mod.display_name),
|
||||
description: String(mod.description ?? ''),
|
||||
icon: String(mod.icon ?? 'table'),
|
||||
defaultPageSize: Number(mod.default_page_size ?? 25),
|
||||
features,
|
||||
}}
|
||||
/>
|
||||
</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 */}
|
||||
<Card>
|
||||
@@ -109,7 +69,6 @@ export default async function ModuleSettingsPage({
|
||||
<List className="h-4 w-4" />
|
||||
Felder ({fields.length})
|
||||
</CardTitle>
|
||||
<Button size="sm">+ Feld hinzufügen</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -17,53 +16,100 @@ import {
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
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 { createCourse } from '../server/actions/course-actions';
|
||||
import {
|
||||
CreateCourseSchema,
|
||||
UpdateCourseSchema,
|
||||
} from '../schema/course.schema';
|
||||
import { createCourse, updateCourse } from '../server/actions/course-actions';
|
||||
|
||||
interface Props {
|
||||
accountId: 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) {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateCourseSchema),
|
||||
defaultValues: {
|
||||
export function CreateCourseForm({
|
||||
accountId,
|
||||
courseNumber: '',
|
||||
name: '',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
fee: 0,
|
||||
reducedFee: 0,
|
||||
capacity: 20,
|
||||
minParticipants: 5,
|
||||
status: 'planned' as const,
|
||||
registrationDeadline: '',
|
||||
notes: '',
|
||||
account,
|
||||
courseId,
|
||||
initialData,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const isEdit = Boolean(courseId);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(isEdit ? UpdateCourseSchema : CreateCourseSchema),
|
||||
defaultValues: {
|
||||
...(isEdit ? { courseId } : { accountId }),
|
||||
courseNumber: initialData?.courseNumber ?? '',
|
||||
name: initialData?.name ?? '',
|
||||
description: initialData?.description ?? '',
|
||||
startDate: initialData?.startDate ?? '',
|
||||
endDate: initialData?.endDate ?? '',
|
||||
fee: initialData?.fee ?? 0,
|
||||
reducedFee: initialData?.reducedFee ?? 0,
|
||||
capacity: initialData?.capacity ?? 20,
|
||||
minParticipants: initialData?.minParticipants ?? 5,
|
||||
status: (initialData?.status ?? 'planned') as
|
||||
| 'planned'
|
||||
| 'open'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'cancelled',
|
||||
registrationDeadline: initialData?.registrationDeadline ?? '',
|
||||
notes: initialData?.notes ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createCourse, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kurs erfolgreich erstellt');
|
||||
router.push(`/home/${account}/courses`);
|
||||
const { execute: execCreate, isPending: isCreating } = useActionWithToast(
|
||||
createCourse,
|
||||
{
|
||||
successMessage: 'Kurs erfolgreich erstellt',
|
||||
errorMessage: 'Fehler beim Erstellen des Kurses',
|
||||
onSuccess: () => router.push(`/home/${account}/courses`),
|
||||
},
|
||||
);
|
||||
|
||||
const { execute: execUpdate, isPending: isUpdating } = useActionWithToast(
|
||||
updateCourse,
|
||||
{
|
||||
successMessage: 'Kurs aktualisiert',
|
||||
errorMessage: 'Fehler beim Aktualisieren',
|
||||
onSuccess: () => router.push(`/home/${account}/courses/${courseId}`),
|
||||
},
|
||||
);
|
||||
|
||||
const isPending = isCreating || isUpdating;
|
||||
|
||||
const handleSubmit = (data: Record<string, unknown>) => {
|
||||
if (isEdit && courseId) {
|
||||
execUpdate({ ...data, courseId } as any);
|
||||
} else {
|
||||
execCreate({ ...data, accountId } as any);
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Erstellen des Kurses');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit(handleSubmit as any)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
@@ -167,7 +213,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kapazität</CardTitle>
|
||||
<CardTitle>Kapazität & Gebühren</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
@@ -211,7 +257,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
|
||||
name="fee"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gebühr (€)</FormLabel>
|
||||
<FormLabel>Gebühr (EUR)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -230,7 +276,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
|
||||
name="reducedFee"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ermäßigte Gebühr (€)</FormLabel>
|
||||
<FormLabel>Ermäßigte Gebühr (EUR)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -274,7 +320,6 @@ export function CreateCourseForm({ accountId, account }: Props) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="sm:col-span-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
@@ -291,25 +336,19 @@ export function CreateCourseForm({ accountId, account }: Props) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
data-test="course-cancel-btn"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
data-test="course-submit-btn"
|
||||
>
|
||||
{isPending ? 'Wird erstellt...' : 'Kurs erstellen'}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? 'Wird gespeichert...'
|
||||
: isEdit
|
||||
? 'Kurs aktualisieren'
|
||||
: 'Kurs erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -39,6 +39,7 @@ export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
|
||||
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
|
||||
courseId: z.string().uuid(),
|
||||
});
|
||||
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
|
||||
|
||||
export const EnrollParticipantSchema = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
CreateCourseSchema,
|
||||
UpdateCourseSchema,
|
||||
EnrollParticipantSchema,
|
||||
CreateSessionSchema,
|
||||
CreateCategorySchema,
|
||||
@@ -29,6 +30,32 @@ export const createCourse = authActionClient
|
||||
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
|
||||
.inputSchema(EnrollParticipantSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type {
|
||||
CreateCourseInput,
|
||||
UpdateCourseInput,
|
||||
EnrollParticipantInput,
|
||||
} from '../schema/course.schema';
|
||||
|
||||
@@ -78,6 +79,51 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
||||
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 ---
|
||||
async enrollParticipant(input: EnrollParticipantInput) {
|
||||
// Check capacity
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -17,58 +16,104 @@ import {
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
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 { createEvent } from '../server/actions/event-actions';
|
||||
import { CreateEventSchema, UpdateEventSchema } from '../schema/event.schema';
|
||||
import { createEvent, updateEvent } from '../server/actions/event-actions';
|
||||
|
||||
interface Props {
|
||||
accountId: 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) {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateEventSchema),
|
||||
defaultValues: {
|
||||
export function CreateEventForm({
|
||||
accountId,
|
||||
name: '',
|
||||
description: '',
|
||||
eventDate: '',
|
||||
eventTime: '',
|
||||
endDate: '',
|
||||
location: '',
|
||||
capacity: undefined as number | undefined,
|
||||
minAge: undefined as number | undefined,
|
||||
maxAge: undefined as number | undefined,
|
||||
fee: 0,
|
||||
status: 'planned' as const,
|
||||
registrationDeadline: '',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
account,
|
||||
eventId,
|
||||
initialData,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const isEdit = Boolean(eventId);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(isEdit ? UpdateEventSchema : CreateEventSchema),
|
||||
defaultValues: {
|
||||
...(isEdit ? { eventId } : { accountId }),
|
||||
name: initialData?.name ?? '',
|
||||
description: initialData?.description ?? '',
|
||||
eventDate: initialData?.eventDate ?? '',
|
||||
eventTime: initialData?.eventTime ?? '',
|
||||
endDate: initialData?.endDate ?? '',
|
||||
location: initialData?.location ?? '',
|
||||
capacity: initialData?.capacity ?? (undefined as number | undefined),
|
||||
minAge: initialData?.minAge ?? (undefined as number | undefined),
|
||||
maxAge: initialData?.maxAge ?? (undefined as number | undefined),
|
||||
fee: initialData?.fee ?? 0,
|
||||
status: (initialData?.status ?? 'planned') as
|
||||
| 'planned'
|
||||
| 'open'
|
||||
| 'full'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'cancelled',
|
||||
registrationDeadline: initialData?.registrationDeadline ?? '',
|
||||
contactName: initialData?.contactName ?? '',
|
||||
contactEmail: initialData?.contactEmail ?? '',
|
||||
contactPhone: initialData?.contactPhone ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createEvent, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Veranstaltung erfolgreich erstellt');
|
||||
router.push(`/home/${account}/events`);
|
||||
}
|
||||
const { execute: execCreate, isPending: isCreating } = useActionWithToast(
|
||||
createEvent,
|
||||
{
|
||||
successMessage: 'Veranstaltung erfolgreich erstellt',
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit(handleSubmit as any)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
@@ -241,7 +286,7 @@ export function CreateEventForm({ accountId, account }: Props) {
|
||||
name="fee"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gebühr (€)</FormLabel>
|
||||
<FormLabel>Gebühr (EUR)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -365,7 +410,11 @@ export function CreateEventForm({ accountId, account }: Props) {
|
||||
disabled={isPending}
|
||||
data-test="event-submit-btn"
|
||||
>
|
||||
{isPending ? 'Wird erstellt...' : 'Veranstaltung erstellen'}
|
||||
{isPending
|
||||
? 'Wird gespeichert...'
|
||||
: isEdit
|
||||
? 'Veranstaltung aktualisieren'
|
||||
: 'Veranstaltung erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -29,6 +29,11 @@ export const CreateEventSchema = z.object({
|
||||
});
|
||||
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({
|
||||
eventId: z.string().uuid(),
|
||||
firstName: z.string().min(1),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
CreateEventSchema,
|
||||
UpdateEventSchema,
|
||||
EventRegistrationSchema,
|
||||
CreateHolidayPassSchema,
|
||||
} from '../../schema/event.schema';
|
||||
@@ -26,6 +27,32 @@ export const createEvent = authActionClient
|
||||
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
|
||||
.inputSchema(EventRegistrationSchema)
|
||||
.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 { CreateEventInput } from '../schema/event.schema';
|
||||
import type {
|
||||
CreateEventInput,
|
||||
UpdateEventInput,
|
||||
} from '../schema/event.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
@@ -69,7 +72,7 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description || null,
|
||||
event_date: input.eventDate || null,
|
||||
event_date: input.eventDate,
|
||||
event_time: input.eventTime || null,
|
||||
end_date: input.endDate || null,
|
||||
location: input.location || null,
|
||||
@@ -89,6 +92,50 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
|
||||
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: {
|
||||
eventId: string;
|
||||
firstName: string;
|
||||
|
||||
@@ -20,7 +20,10 @@ import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { CreateFishSpeciesSchema } from '../schema/fischerei.schema';
|
||||
import { createSpecies } from '../server/actions/fischerei-actions';
|
||||
import {
|
||||
createSpecies,
|
||||
updateSpecies,
|
||||
} from '../server/actions/fischerei-actions';
|
||||
|
||||
interface CreateSpeciesFormProps {
|
||||
accountId: string;
|
||||
@@ -65,22 +68,50 @@ export function CreateSpeciesForm({
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createSpecies, {
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||
createSpecies,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success(isEdit ? 'Fischart aktualisiert' : 'Fischart erstellt');
|
||||
toast.success('Fischart erstellt');
|
||||
router.push(`/home/${account}/fischerei/species`);
|
||||
}
|
||||
},
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Card 1: Grunddaten */}
|
||||
|
||||
@@ -21,13 +21,17 @@ import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { CreateStockingSchema } from '../schema/fischerei.schema';
|
||||
import { createStocking } from '../server/actions/fischerei-actions';
|
||||
import {
|
||||
createStocking,
|
||||
updateStocking,
|
||||
} from '../server/actions/fischerei-actions';
|
||||
|
||||
interface CreateStockingFormProps {
|
||||
accountId: string;
|
||||
account: string;
|
||||
waters: Array<{ id: string; name: string }>;
|
||||
species: Array<{ id: string; name: string }>;
|
||||
stocking?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function CreateStockingForm({
|
||||
@@ -35,26 +39,45 @@ export function CreateStockingForm({
|
||||
account,
|
||||
waters,
|
||||
species,
|
||||
stocking,
|
||||
}: CreateStockingFormProps) {
|
||||
const router = useRouter();
|
||||
const isEdit = !!stocking;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateStockingSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
waterId: '',
|
||||
speciesId: '',
|
||||
stockingDate: todayISO(),
|
||||
quantity: 0,
|
||||
weightKg: undefined as number | undefined,
|
||||
ageClass: 'sonstige' as const,
|
||||
costEuros: undefined as number | undefined,
|
||||
supplierId: undefined as string | undefined,
|
||||
remarks: '',
|
||||
waterId: (stocking?.water_id as string) ?? '',
|
||||
speciesId: (stocking?.species_id as string) ?? '',
|
||||
stockingDate: (stocking?.stocking_date as string) ?? todayISO(),
|
||||
quantity: stocking?.quantity != null ? Number(stocking.quantity) : 0,
|
||||
weightKg:
|
||||
stocking?.weight_kg != null
|
||||
? Number(stocking.weight_kg)
|
||||
: (undefined as number | undefined),
|
||||
ageClass: ((stocking?.age_class as string) ?? 'sonstige') as
|
||||
| 'brut'
|
||||
| 'soemmerlinge'
|
||||
| 'einsoemmerig'
|
||||
| 'zweisoemmerig'
|
||||
| 'dreisoemmerig'
|
||||
| 'vorgestreckt'
|
||||
| 'setzlinge'
|
||||
| 'laichfische'
|
||||
| 'sonstige',
|
||||
costEuros:
|
||||
stocking?.cost_euros != null
|
||||
? Number(stocking.cost_euros)
|
||||
: (undefined as number | undefined),
|
||||
supplierId: (stocking?.supplier_id as string | undefined) ?? undefined,
|
||||
remarks: (stocking?.remarks as string) ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createStocking, {
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||
createStocking,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Besatz eingetragen');
|
||||
@@ -64,12 +87,38 @@ export function CreateStockingForm({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
@@ -260,7 +309,11 @@ export function CreateStockingForm({
|
||||
disabled={isPending}
|
||||
data-test="stocking-submit-btn"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert...' : 'Besatz eintragen'}
|
||||
{isPending
|
||||
? 'Wird gespeichert...'
|
||||
: isEdit
|
||||
? 'Besatz aktualisieren'
|
||||
: 'Besatz eintragen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { CreateWaterSchema } from '../schema/fischerei.schema';
|
||||
import { createWater } from '../server/actions/fischerei-actions';
|
||||
import { createWater, updateWater } from '../server/actions/fischerei-actions';
|
||||
|
||||
interface CreateWaterFormProps {
|
||||
accountId: string;
|
||||
@@ -65,22 +65,50 @@ export function CreateWaterForm({
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createWater, {
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||
createWater,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success(isEdit ? 'Gewässer aktualisiert' : 'Gewässer erstellt');
|
||||
toast.success('Gewässer erstellt');
|
||||
router.push(`/home/${account}/fischerei/waters`);
|
||||
}
|
||||
},
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 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 { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
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 { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||
|
||||
import { deleteSpecies } from '../server/actions/fischerei-actions';
|
||||
import { DeleteConfirmButton } from './delete-confirm-button';
|
||||
|
||||
interface SpeciesDataTableProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
@@ -19,6 +23,7 @@ interface SpeciesDataTableProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function SpeciesDataTable({
|
||||
@@ -27,10 +32,19 @@ export function SpeciesDataTable({
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
accountId,
|
||||
}: SpeciesDataTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
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 form = useForm({
|
||||
@@ -132,6 +146,7 @@ export function SpeciesDataTable({
|
||||
<th className="p-3 text-right font-medium">
|
||||
Max. Fang/Tag
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -162,6 +177,33 @@ export function SpeciesDataTable({
|
||||
? String(species.max_catch_per_day)
|
||||
: '—'}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
@@ -14,8 +14,11 @@ 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 { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||
|
||||
import { AGE_CLASS_LABELS } from '../lib/fischerei-constants';
|
||||
import { deleteStocking } from '../server/actions/fischerei-actions';
|
||||
import { DeleteConfirmButton } from './delete-confirm-button';
|
||||
|
||||
interface StockingDataTableProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
@@ -23,6 +26,7 @@ interface StockingDataTableProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
export function StockingDataTable({
|
||||
@@ -31,11 +35,20 @@ export function StockingDataTable({
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
accountId,
|
||||
}: StockingDataTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
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(
|
||||
(updates: Record<string, string>) => {
|
||||
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-left font-medium">Altersklasse</th>
|
||||
<th className="p-3 text-right font-medium">Kosten (€)</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -145,6 +159,33 @@ export function StockingDataTable({
|
||||
? formatCurrencyAmount(row.cost_euros as number)
|
||||
: '—'}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { formatNumber } from '@kit/shared/formatters';
|
||||
@@ -13,8 +13,11 @@ 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 { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||
|
||||
import { WATER_TYPE_LABELS } from '../lib/fischerei-constants';
|
||||
import { deleteWater } from '../server/actions/fischerei-actions';
|
||||
import { DeleteConfirmButton } from './delete-confirm-button';
|
||||
|
||||
interface WatersDataTableProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
@@ -22,6 +25,7 @@ interface WatersDataTableProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
const WATER_TYPE_OPTIONS = [
|
||||
@@ -43,10 +47,19 @@ export function WatersDataTable({
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
accountId,
|
||||
}: WatersDataTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { execute: executeDelete, isPending: isDeleting } = useActionWithToast(
|
||||
deleteWater,
|
||||
{
|
||||
successMessage: 'Gewässer gelöscht',
|
||||
onSuccess: () => router.refresh(),
|
||||
},
|
||||
);
|
||||
|
||||
const currentSearch = searchParams.get('q') ?? '';
|
||||
const currentType = searchParams.get('type') ?? '';
|
||||
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-right font-medium">Fläche (ha)</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -208,6 +222,34 @@ export function WatersDataTable({
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(water.location ?? '—')}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -473,6 +473,16 @@ export function createFischereiApi(client: SupabaseClient<Database>) {
|
||||
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) {
|
||||
const { data, error } = await client
|
||||
.from('fish_stocking')
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./services/*": "./src/server/services/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -33,19 +33,7 @@ export const createRecord = authActionClient
|
||||
}
|
||||
|
||||
// Validate data against field definitions
|
||||
const fields = (
|
||||
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 { fields } = moduleWithFields;
|
||||
const validation = validateRecordData(
|
||||
input.data as Record<string, unknown>,
|
||||
fields as Parameters<typeof validateRecordData>[1],
|
||||
@@ -98,19 +86,7 @@ export const updateRecord = authActionClient
|
||||
throw new Error('Module not found');
|
||||
}
|
||||
|
||||
const fields = (
|
||||
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 { fields } = moduleWithFields;
|
||||
const validation = validateRecordData(
|
||||
input.data as Record<string, unknown>,
|
||||
fields as Parameters<typeof validateRecordData>[1],
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CreateModuleInput,
|
||||
UpdateModuleInput,
|
||||
} from '../../schema/module.schema';
|
||||
import type { ModuleWithFields } from '../../types';
|
||||
|
||||
/**
|
||||
* Service for managing module definitions (CRUD).
|
||||
@@ -38,15 +39,20 @@ export function createModuleDefinitionService(
|
||||
return data;
|
||||
},
|
||||
|
||||
async getModuleWithFields(moduleId: string) {
|
||||
async getModuleWithFields(
|
||||
moduleId: string,
|
||||
): Promise<ModuleWithFields | null> {
|
||||
const { data, error } = await client
|
||||
.from('modules')
|
||||
.select('*, fields:module_fields(*)')
|
||||
.eq('id', moduleId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null;
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as ModuleWithFields;
|
||||
},
|
||||
|
||||
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