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

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

View File

@@ -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, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(isEdit ? 'Fischart aktualisiert' : 'Fischart erstellt');
router.push(`/home/${account}/fischerei/species`);
}
const { execute: executeCreate, isPending: isCreating } = useAction(
createSpecies,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Fischart erstellt');
router.push(`/home/${account}/fischerei/species`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateSpecies,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Fischart aktualisiert');
router.push(`/home/${account}/fischerei/species`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
});
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && species?.id) {
executeUpdate({ ...data, speciesId: String(species.id) } as any);
} else {
executeCreate(data as any);
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}

View File

@@ -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,41 +39,86 @@ 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, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Besatz eingetragen');
router.push(`/home/${account}/fischerei/stocking`);
}
const { execute: executeCreate, isPending: isCreating } = useAction(
createStocking,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Besatz eingetragen');
router.push(`/home/${account}/fischerei/stocking`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateStocking,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Besatz aktualisiert');
router.push(`/home/${account}/fischerei/stocking`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
});
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && stocking?.id) {
executeUpdate({ ...data, stockingId: String(stocking.id) } as any);
} else {
executeCreate(data as any);
}
};
return (
<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>

View File

@@ -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, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(isEdit ? 'Gewässer aktualisiert' : 'Gewässer erstellt');
router.push(`/home/${account}/fischerei/waters`);
}
const { execute: executeCreate, isPending: isCreating } = useAction(
createWater,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Gewässer erstellt');
router.push(`/home/${account}/fischerei/waters`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateWater,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Gewässer aktualisiert');
router.push(`/home/${account}/fischerei/waters`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
},
});
);
const isPending = isCreating || isUpdating;
const handleSubmit = (data: Record<string, unknown>) => {
if (isEdit && water?.id) {
executeUpdate({ ...data, waterId: String(water.id) } as any);
} else {
executeCreate(data as any);
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
onSubmit={form.handleSubmit((data) => handleSubmit(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}

View File

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

View File

@@ -5,13 +5,17 @@ import { useCallback } from 'react';
import Link from 'next/link';
import { 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>

View File

@@ -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>
);
})}

View File

@@ -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>

View File

@@ -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')