feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m26s
Workflow / ⚫️ Test (push) Has been skipped

Major changes:
- Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI
- Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions
- Sitzungsprotokolle module: meeting protocols, agenda items, task tracking
- Verbandsverwaltung module: federation management, member clubs, contacts, fees
- Per-account module activation via Modules page toggle
- Site Builder: live CMS data in Puck blocks (courses, events, membership registration)
- Public registration APIs: course signup, event registration, membership application
- Document generation: PDF member cards, Excel reports, HTML labels
- Landing page: real Com.BISS content (no filler text)
- UX audit fixes: AccountNotFound component, shared status badges, confirm dialog,
  pagination, duplicate heading removal, emoji→badge replacement, a11y fixes
- QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute
This commit is contained in:
Zaid Marzguioui
2026-03-31 16:35:46 +02:00
parent 16648c92eb
commit ebd0fd4638
176 changed files with 17133 additions and 981 deletions

View File

@@ -0,0 +1,40 @@
{
"name": "@kit/fischerei",
"version": "0.1.0",
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"recharts": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,212 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Check } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
CATCH_BOOK_STATUS_LABELS,
CATCH_BOOK_STATUS_COLORS,
} from '../lib/fischerei-constants';
interface CatchBooksDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
}
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'offen', label: 'Offen' },
{ value: 'eingereicht', label: 'Eingereicht' },
{ value: 'geprueft', label: 'Geprüft' },
{ value: 'akzeptiert', label: 'Akzeptiert' },
{ value: 'abgelehnt', label: 'Abgelehnt' },
] as const;
export function CatchBooksDataTable({
data,
total,
page,
pageSize,
account,
}: CatchBooksDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentYear = searchParams.get('year') ?? '';
const currentStatus = searchParams.get('status') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
if (!('page' in updates)) params.delete('page');
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleYearChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ year: e.target.value });
},
[updateParams],
);
const handleStatusChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ status: e.target.value });
},
[updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
// Build year options from current year going back 5 years
const thisYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 6 }, (_, i) => thisYear - i);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<select
value={currentYear}
onChange={handleYearChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value="">Alle Jahre</option>
{yearOptions.map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
<select
value={currentStatus}
onChange={handleStatusChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Table */}
<Card>
<CardHeader>
<CardTitle>Fangbücher ({total})</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Fangbücher vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Es wurden noch keine Fangbücher angelegt.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Mitglied</th>
<th className="p-3 text-right font-medium">Jahr</th>
<th className="p-3 text-right font-medium">Angeltage</th>
<th className="p-3 text-right font-medium">Fänge</th>
<th className="p-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{data.map((cb) => {
const members = cb.members as Record<string, unknown> | null;
const memberName = members
? `${String(members.first_name ?? '')} ${String(members.last_name ?? '')}`.trim()
: String(cb.member_name ?? '—');
const status = String(cb.status ?? 'offen');
return (
<tr
key={String(cb.id)}
className="cursor-pointer border-b hover:bg-muted/30"
onClick={() =>
router.push(
`/home/${account}/fischerei/catch-books/${String(cb.id)}`,
)
}
>
<td className="p-3 font-medium">{memberName}</td>
<td className="p-3 text-right">{String(cb.year)}</td>
<td className="p-3 text-right">
{String(cb.fishing_days_count ?? 0)}
</td>
<td className="p-3 text-right">
{String(cb.total_fish_caught ?? 0)}
</td>
<td className="p-3">
<Badge
variant={
(CATCH_BOOK_STATUS_COLORS[status] as
| 'secondary'
| 'default'
| 'outline'
| 'destructive') ?? 'secondary'
}
>
{CATCH_BOOK_STATUS_LABELS[status] ?? status}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>
Zurück
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)}>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface CompetitionsDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
}
export function CompetitionsDataTable({
data,
total,
page,
pageSize,
account,
}: CompetitionsDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
if (!('page' in updates)) params.delete('page');
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Wettbewerbe ({total})</h2>
<Link href={`/home/${account}/fischerei/competitions/new`}>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Neuer Wettbewerb
</Button>
</Link>
</div>
{/* Table */}
<Card>
<CardContent className="pt-6">
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Wettbewerbe vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihren ersten Wettbewerb.
</p>
<Link href={`/home/${account}/fischerei/competitions/new`} className="mt-4">
<Button>Neuer Wettbewerb</Button>
</Link>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-right font-medium">Max. Teilnehmer</th>
</tr>
</thead>
<tbody>
{data.map((comp) => {
const waters = comp.waters as Record<string, unknown> | null;
return (
<tr
key={String(comp.id)}
className="cursor-pointer border-b hover:bg-muted/30"
onClick={() =>
router.push(
`/home/${account}/fischerei/competitions/${String(comp.id)}`,
)
}
>
<td className="p-3 font-medium">{String(comp.name)}</td>
<td className="p-3">
{comp.competition_date
? new Date(String(comp.competition_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">
{waters ? String(waters.name) : '—'}
</td>
<td className="p-3 text-right">
{comp.max_participants != null
? String(comp.max_participants)
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>
Zurück
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)}>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateFishSpeciesSchema } from '../schema/fischerei.schema';
import { createSpecies } from '../server/actions/fischerei-actions';
interface CreateSpeciesFormProps {
accountId: string;
account: string;
species?: Record<string, unknown>;
}
export function CreateSpeciesForm({
accountId,
account,
species,
}: CreateSpeciesFormProps) {
const router = useRouter();
const isEdit = !!species;
const form = useForm({
resolver: zodResolver(CreateFishSpeciesSchema),
defaultValues: {
accountId,
name: (species?.name as string) ?? '',
nameLatin: (species?.name_latin as string) ?? '',
nameLocal: (species?.name_local as string) ?? '',
isActive: species?.is_active != null ? Boolean(species.is_active) : true,
protectedMinSizeCm: species?.protected_min_size_cm != null ? Number(species.protected_min_size_cm) : undefined,
protectionPeriodStart: (species?.protection_period_start as string) ?? '',
protectionPeriodEnd: (species?.protection_period_end as string) ?? '',
maxCatchPerDay: species?.max_catch_per_day != null ? Number(species.max_catch_per_day) : undefined,
maxCatchPerYear: species?.max_catch_per_year != null ? Number(species.max_catch_per_year) : undefined,
individualRecording: species?.individual_recording != null ? Boolean(species.individual_recording) : false,
},
});
const { execute, isPending } = useAction(createSpecies, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(isEdit ? 'Fischart aktualisiert' : 'Fischart erstellt');
router.push(`/home/${account}/fischerei/species`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}
<Card>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameLatin"
render={({ field }) => (
<FormItem>
<FormLabel>Lateinischer Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameLocal"
render={({ field }) => (
<FormItem>
<FormLabel>Lokaler Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Schutzbestimmungen */}
<Card>
<CardHeader>
<CardTitle>Schutzbestimmungen</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="protectedMinSizeCm"
render={({ field }) => (
<FormItem>
<FormLabel>Schonmaß (cm)</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protectionPeriodStart"
render={({ field }) => (
<FormItem>
<FormLabel>Schonzeit Beginn (MM.TT)</FormLabel>
<FormControl>
<Input placeholder="z.B. 03.01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protectionPeriodEnd"
render={({ field }) => (
<FormItem>
<FormLabel>Schonzeit Ende (MM.TT)</FormLabel>
<FormControl>
<Input placeholder="z.B. 06.30" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 3: Fangbegrenzungen */}
<Card>
<CardHeader>
<CardTitle>Fangbegrenzungen</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="maxCatchPerDay"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Fang/Tag</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxCatchPerYear"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Fang/Jahr</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending
? 'Wird gespeichert...'
: isEdit
? 'Fischart aktualisieren'
: 'Fischart erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,257 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateStockingSchema } from '../schema/fischerei.schema';
import { createStocking } from '../server/actions/fischerei-actions';
interface CreateStockingFormProps {
accountId: string;
account: string;
waters: Array<{ id: string; name: string }>;
species: Array<{ id: string; name: string }>;
}
export function CreateStockingForm({
accountId,
account,
waters,
species,
}: CreateStockingFormProps) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateStockingSchema),
defaultValues: {
accountId,
waterId: '',
speciesId: '',
stockingDate: new Date().toISOString().split('T')[0],
quantity: 0,
weightKg: undefined as number | undefined,
ageClass: 'sonstige' as const,
costEuros: undefined as number | undefined,
supplierId: undefined as string | undefined,
remarks: '',
},
});
const { execute, isPending } = 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');
},
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader>
<CardTitle>Besatzdaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="waterId"
render={({ field }) => (
<FormItem>
<FormLabel>Gewässer *</FormLabel>
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value=""> Gewässer wählen </option>
{waters.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="speciesId"
render={({ field }) => (
<FormItem>
<FormLabel>Fischart *</FormLabel>
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value=""> Fischart wählen </option>
{species.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stockingDate"
render={({ field }) => (
<FormItem>
<FormLabel>Besatzdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Anzahl (Stück) *</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="weightKg"
render={({ field }) => (
<FormItem>
<FormLabel>Gewicht (kg)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ageClass"
render={({ field }) => (
<FormItem>
<FormLabel>Altersklasse</FormLabel>
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="brut">Brut</option>
<option value="soemmerlinge">Sömmerlinge</option>
<option value="einsoemmerig">1-sömmrig</option>
<option value="zweisoemmerig">2-sömmrig</option>
<option value="dreisoemmerig">3-sömmrig</option>
<option value="vorgestreckt">Vorgestreckt</option>
<option value="setzlinge">Setzlinge</option>
<option value="laichfische">Laichfische</option>
<option value="sonstige">Sonstige</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="costEuros"
render={({ field }) => (
<FormItem>
<FormLabel>Kosten (EUR)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value ? Number(e.target.value) : undefined)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-2">
<FormField
control={form.control}
name="remarks"
render={({ field }) => (
<FormItem>
<FormLabel>Bemerkungen</FormLabel>
<FormControl>
<textarea
{...field}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Besatz eintragen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,445 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateWaterSchema } from '../schema/fischerei.schema';
import { createWater } from '../server/actions/fischerei-actions';
interface CreateWaterFormProps {
accountId: string;
account: string;
water?: Record<string, unknown>;
}
export function CreateWaterForm({
accountId,
account,
water,
}: CreateWaterFormProps) {
const router = useRouter();
const isEdit = !!water;
const form = useForm({
resolver: zodResolver(CreateWaterSchema),
defaultValues: {
accountId,
name: (water?.name as string) ?? '',
shortName: (water?.short_name as string) ?? '',
waterType: (water?.water_type as string) ?? 'sonstige',
description: (water?.description as string) ?? '',
surfaceAreaHa: water?.surface_area_ha != null ? Number(water.surface_area_ha) : undefined,
lengthM: water?.length_m != null ? Number(water.length_m) : undefined,
widthM: water?.width_m != null ? Number(water.width_m) : undefined,
avgDepthM: water?.avg_depth_m != null ? Number(water.avg_depth_m) : undefined,
maxDepthM: water?.max_depth_m != null ? Number(water.max_depth_m) : undefined,
outflow: (water?.outflow as string) ?? '',
location: (water?.location as string) ?? '',
county: (water?.county as string) ?? '',
geoLat: water?.geo_lat != null ? Number(water.geo_lat) : undefined,
geoLng: water?.geo_lng != null ? Number(water.geo_lng) : undefined,
lfvNumber: (water?.lfv_number as string) ?? '',
costShareDs: water?.cost_share_ds != null ? Number(water.cost_share_ds) : undefined,
},
});
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`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern');
},
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}
<Card>
<CardHeader>
<CardTitle>Grunddaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="shortName"
render={({ field }) => (
<FormItem>
<FormLabel>Kurzname</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="waterType"
render={({ field }) => (
<FormItem>
<FormLabel>Gewässertyp</FormLabel>
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="fluss">Fluss</option>
<option value="bach">Bach</option>
<option value="see">See</option>
<option value="teich">Teich</option>
<option value="weiher">Weiher</option>
<option value="kanal">Kanal</option>
<option value="stausee">Stausee</option>
<option value="baggersee">Baggersee</option>
<option value="sonstige">Sonstige</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-2">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormControl>
<textarea
{...field}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* Card 2: Abmessungen */}
<Card>
<CardHeader>
<CardTitle>Abmessungen</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="surfaceAreaHa"
render={({ field }) => (
<FormItem>
<FormLabel>Fläche (ha)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lengthM"
render={({ field }) => (
<FormItem>
<FormLabel>Länge (m)</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="widthM"
render={({ field }) => (
<FormItem>
<FormLabel>Breite (m)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avgDepthM"
render={({ field }) => (
<FormItem>
<FormLabel>Durchschnittstiefe (m)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxDepthM"
render={({ field }) => (
<FormItem>
<FormLabel>Maximaltiefe (m)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 3: Geografie */}
<Card>
<CardHeader>
<CardTitle>Geografie</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="outflow"
render={({ field }) => (
<FormItem>
<FormLabel>Abfluss</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Lage/Standort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="county"
render={({ field }) => (
<FormItem>
<FormLabel>Landkreis</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="geoLat"
render={({ field }) => (
<FormItem>
<FormLabel>Breitengrad</FormLabel>
<FormControl>
<Input
type="number"
step="0.0000001"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="geoLng"
render={({ field }) => (
<FormItem>
<FormLabel>Längengrad</FormLabel>
<FormControl>
<Input
type="number"
step="0.0000001"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* Card 4: Verwaltung */}
<Card>
<CardHeader>
<CardTitle>Verwaltung</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="lfvNumber"
render={({ field }) => (
<FormItem>
<FormLabel>LFV-Nummer</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="costShareDs"
render={({ field }) => (
<FormItem>
<FormLabel>Kostenanteil DS (%)</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={100}
step="0.01"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending
? 'Wird gespeichert...'
: isEdit
? 'Gewässer aktualisieren'
: 'Gewässer erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import Link from 'next/link';
import {
Droplets,
Fish,
FileText,
BookOpen,
Trophy,
Euro,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
interface DashboardStats {
watersCount: number;
speciesCount: number;
activeLeasesCount: number;
pendingCatchBooksCount: number;
upcomingCompetitionsCount: number;
stockingCostYtd: number;
}
interface FischereiDashboardProps {
stats: DashboardStats;
account: string;
}
export function FischereiDashboard({ stats, account }: FischereiDashboardProps) {
return (
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Fischerei Übersicht</h1>
<p className="text-muted-foreground">
Gewässer, Fischarten, Besatz, Fangbücher und mehr verwalten
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link href={`/home/${account}/fischerei/waters`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Gewässer</p>
<p className="text-2xl font-bold">{stats.watersCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<Droplets className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/fischerei/species`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Fischarten</p>
<p className="text-2xl font-bold">{stats.speciesCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<Fish className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/fischerei/leases`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Aktive Pachten</p>
<p className="text-2xl font-bold">{stats.activeLeasesCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<FileText className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/fischerei/catch-books`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Offene Fangbücher</p>
<p className="text-2xl font-bold">{stats.pendingCatchBooksCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<BookOpen className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/fischerei/competitions`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Kommende Wettbewerbe</p>
<p className="text-2xl font-bold">{stats.upcomingCompetitionsCount}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<Trophy className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/fischerei/stocking`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Besatzkosten (lfd. Jahr)</p>
<p className="text-2xl font-bold">
{stats.stockingCostYtd.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})}
</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<Euro className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
</div>
{/* Quick Access Sections */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Letzte Besatzaktionen</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Noch keine Besatzaktionen vorhanden.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Offene Fangbücher</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Keine Fangbücher zur Prüfung ausstehend.
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@kit/ui/utils';
interface FischereiTabNavigationProps {
account: string;
activeTab: string;
}
const TABS = [
{ id: 'overview', label: 'Übersicht', path: '' },
{ id: 'waters', label: 'Gewässer', path: '/waters' },
{ id: 'species', label: 'Fischarten', path: '/species' },
{ id: 'stocking', label: 'Besatz', path: '/stocking' },
{ id: 'leases', label: 'Pachten', path: '/leases' },
{ id: 'catch-books', label: 'Fangbücher', path: '/catch-books' },
{ id: 'permits', label: 'Erlaubnisscheine', path: '/permits' },
{ id: 'competitions', label: 'Wettbewerbe', path: '/competitions' },
{ id: 'statistics', label: 'Statistiken', path: '/statistics' },
] as const;
export function FischereiTabNavigation({
account,
activeTab,
}: FischereiTabNavigationProps) {
const basePath = `/home/${account}/fischerei`;
return (
<div className="mb-6 border-b">
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Fischerei Navigation">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<Link
key={tab.id}
href={`${basePath}${tab.path}`}
className={cn(
'whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground',
)}
>
{tab.label}
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export { FischereiTabNavigation } from './fischerei-tab-navigation';
export { FischereiDashboard } from './fischerei-dashboard';
export { WatersDataTable } from './waters-data-table';
export { CreateWaterForm } from './create-water-form';
export { SpeciesDataTable } from './species-data-table';
export { CreateSpeciesForm } from './create-species-form';
export { StockingDataTable } from './stocking-data-table';
export { CreateStockingForm } from './create-stocking-form';
export { CatchBooksDataTable } from './catch-books-data-table';
export { CompetitionsDataTable } from './competitions-data-table';

View File

@@ -0,0 +1,179 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Plus } from 'lucide-react';
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';
interface SpeciesDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
}
export function SpeciesDataTable({
data,
total,
page,
pageSize,
account,
}: SpeciesDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentSearch = searchParams.get('q') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({
defaultValues: { search: currentSearch },
});
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
if (!('page' in updates)) params.delete('page');
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
updateParams({ q: form.getValues('search') });
},
[form, updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Fischart suchen..."
className="w-64"
{...form.register('search')}
/>
<Button type="submit" variant="outline" size="sm">
Suchen
</Button>
</form>
<Link href={`/home/${account}/fischerei/species/new`}>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Neue Fischart
</Button>
</Link>
</div>
{/* Table */}
<Card>
<CardHeader>
<CardTitle>Fischarten ({total})</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Fischarten vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihre erste Fischart.
</p>
<Link href={`/home/${account}/fischerei/species/new`} className="mt-4">
<Button>Neue Fischart</Button>
</Link>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Lat. Name</th>
<th className="p-3 text-right font-medium">Schonmaß (cm)</th>
<th className="p-3 text-left font-medium">Schonzeit</th>
<th className="p-3 text-right font-medium">Max. Fang/Tag</th>
</tr>
</thead>
<tbody>
{data.map((species) => (
<tr key={String(species.id)} className="border-b hover:bg-muted/30">
<td className="p-3 font-medium">{String(species.name)}</td>
<td className="p-3 italic text-muted-foreground">
{String(species.name_latin ?? '—')}
</td>
<td className="p-3 text-right">
{species.protected_min_size_cm != null
? `${Number(species.protected_min_size_cm)} cm`
: '—'}
</td>
<td className="p-3">
{species.protection_period_start && species.protection_period_end
? `${String(species.protection_period_start)} ${String(species.protection_period_end)}`
: '—'}
</td>
<td className="p-3 text-right">
{species.max_catch_per_day != null
? String(species.max_catch_per_day)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Plus } from 'lucide-react';
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 { AGE_CLASS_LABELS } from '../lib/fischerei-constants';
interface StockingDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
}
export function StockingDataTable({
data,
total,
page,
pageSize,
account,
}: StockingDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
if (!('page' in updates)) params.delete('page');
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Besatz ({total})</h2>
<Link href={`/home/${account}/fischerei/stocking/new`}>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Besatz eintragen
</Button>
</Link>
</div>
{/* Table */}
<Card>
<CardContent className="pt-6">
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Besatzeinträge vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Tragen Sie den ersten Besatz ein.
</p>
<Link href={`/home/${account}/fischerei/stocking/new`} className="mt-4">
<Button>Besatz eintragen</Button>
</Link>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Gewässer</th>
<th className="p-3 text-left font-medium">Fischart</th>
<th className="p-3 text-right font-medium">Anzahl</th>
<th className="p-3 text-right font-medium">Gewicht (kg)</th>
<th className="p-3 text-left font-medium">Altersklasse</th>
<th className="p-3 text-right font-medium">Kosten ()</th>
</tr>
</thead>
<tbody>
{data.map((row) => {
const waters = row.waters as Record<string, unknown> | null;
const species = row.fish_species as Record<string, unknown> | null;
return (
<tr key={String(row.id)} className="border-b hover:bg-muted/30">
<td className="p-3">
{row.stocking_date
? new Date(String(row.stocking_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3">{waters ? String(waters.name) : '—'}</td>
<td className="p-3">{species ? String(species.name) : '—'}</td>
<td className="p-3 text-right">
{Number(row.quantity).toLocaleString('de-DE')}
</td>
<td className="p-3 text-right">
{row.weight_kg != null
? `${Number(row.weight_kg).toLocaleString('de-DE')} kg`
: '—'}
</td>
<td className="p-3">
<Badge variant="secondary">
{AGE_CLASS_LABELS[String(row.age_class)] ?? String(row.age_class)}
</Badge>
</td>
<td className="p-3 text-right">
{row.cost_euros != null
? `${Number(row.cost_euros).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>
Zurück
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => handlePageChange(page + 1)}>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,234 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Plus } from 'lucide-react';
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 { WATER_TYPE_LABELS } from '../lib/fischerei-constants';
interface WatersDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
}
const WATER_TYPE_OPTIONS = [
{ value: '', label: 'Alle Typen' },
{ value: 'fluss', label: 'Fluss' },
{ value: 'bach', label: 'Bach' },
{ value: 'see', label: 'See' },
{ value: 'teich', label: 'Teich' },
{ value: 'weiher', label: 'Weiher' },
{ value: 'kanal', label: 'Kanal' },
{ value: 'stausee', label: 'Stausee' },
{ value: 'baggersee', label: 'Baggersee' },
{ value: 'sonstige', label: 'Sonstige' },
] as const;
export function WatersDataTable({
data,
total,
page,
pageSize,
account,
}: WatersDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentSearch = searchParams.get('q') ?? '';
const currentType = searchParams.get('type') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({
defaultValues: { search: currentSearch },
});
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
if (!('page' in updates)) {
params.delete('page');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
updateParams({ q: form.getValues('search') });
},
[form, updateParams],
);
const handleTypeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ type: e.target.value });
},
[updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Gewässer suchen..."
className="w-64"
{...form.register('search')}
/>
<Button type="submit" variant="outline" size="sm">
Suchen
</Button>
</form>
<div className="flex items-center gap-3">
<select
value={currentType}
onChange={handleTypeChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{WATER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Link href={`/home/${account}/fischerei/waters/new`}>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Neues Gewässer
</Button>
</Link>
</div>
</div>
{/* Table */}
<Card>
<CardHeader>
<CardTitle>Gewässer ({total})</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Gewässer vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihr erstes Gewässer, um loszulegen.
</p>
<Link href={`/home/${account}/fischerei/waters/new`} className="mt-4">
<Button>Neues Gewässer</Button>
</Link>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Kurzname</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-right font-medium">Fläche (ha)</th>
<th className="p-3 text-left font-medium">Ort</th>
</tr>
</thead>
<tbody>
{data.map((water) => (
<tr
key={String(water.id)}
className="cursor-pointer border-b hover:bg-muted/30"
onClick={() =>
router.push(
`/home/${account}/fischerei/waters/${String(water.id)}`,
)
}
>
<td className="p-3 font-medium">
<Link
href={`/home/${account}/fischerei/waters/${String(water.id)}`}
className="hover:underline"
>
{String(water.name)}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{String(water.short_name ?? '—')}
</td>
<td className="p-3">
<Badge variant="secondary">
{WATER_TYPE_LABELS[String(water.water_type)] ??
String(water.water_type)}
</Badge>
</td>
<td className="p-3 text-right">
{water.surface_area_ha != null
? `${Number(water.surface_area_ha).toLocaleString('de-DE')} ha`
: '—'}
</td>
<td className="p-3 text-muted-foreground">
{String(water.location ?? '—')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,102 @@
/**
* German label mappings for all Fischerei enums and status colors.
*/
// =====================================================
// Water Type Labels
// =====================================================
export const WATER_TYPE_LABELS: Record<string, string> = {
fluss: 'Fluss',
bach: 'Bach',
see: 'See',
teich: 'Teich',
weiher: 'Weiher',
kanal: 'Kanal',
stausee: 'Stausee',
baggersee: 'Baggersee',
sonstige: 'Sonstige',
};
// =====================================================
// Fish Age Class Labels
// =====================================================
export const AGE_CLASS_LABELS: Record<string, string> = {
brut: 'Brut',
soemmerlinge: 'Sömmerlinge',
einsoemmerig: '1-sömmrig',
zweisoemmerig: '2-sömmrig',
dreisoemmerig: '3-sömmrig',
vorgestreckt: 'Vorgestreckt',
setzlinge: 'Setzlinge',
laichfische: 'Laichfische',
sonstige: 'Sonstige',
};
// =====================================================
// Catch Book Status Labels
// =====================================================
export const CATCH_BOOK_STATUS_LABELS: Record<string, string> = {
offen: 'Offen',
eingereicht: 'Eingereicht',
geprueft: 'Geprüft',
akzeptiert: 'Akzeptiert',
abgelehnt: 'Abgelehnt',
};
// =====================================================
// Catch Book Status Colors (Badge variants)
// =====================================================
export const CATCH_BOOK_STATUS_COLORS: Record<string, string> = {
offen: 'outline',
eingereicht: 'secondary',
geprueft: 'info',
akzeptiert: 'default',
abgelehnt: 'destructive',
};
// =====================================================
// Verification Labels
// =====================================================
export const VERIFICATION_LABELS: Record<string, string> = {
sehrgut: 'Sehr gut',
gut: 'Gut',
ok: 'OK',
schlecht: 'Schlecht',
falsch: 'Falsch',
leer: 'Leer',
};
// =====================================================
// Lease Payment Method Labels
// =====================================================
export const LEASE_PAYMENT_LABELS: Record<string, string> = {
bar: 'Bar',
lastschrift: 'Lastschrift',
ueberweisung: 'Überweisung',
};
// =====================================================
// Fish Gender Labels
// =====================================================
export const FISH_GENDER_LABELS: Record<string, string> = {
maennlich: 'Männlich',
weiblich: 'Weiblich',
unbekannt: 'Unbekannt',
};
// =====================================================
// Fish Size Category Labels
// =====================================================
export const SIZE_CATEGORY_LABELS: Record<string, string> = {
gross: 'Groß',
mittel: 'Mittel',
klein: 'Klein',
};

View File

@@ -0,0 +1,131 @@
/**
* Utility functions for the Fischerei module.
*/
/**
* Compute the Fulton condition factor (K-factor) for a fish.
*
* Formula: K = (weight_g / length_cm^3) * 100000
*
* Returns null if either value is missing or zero.
*/
export function computeKFactor(
weightG: number | null | undefined,
lengthCm: number | null | undefined,
): number | null {
if (!weightG || !lengthCm || lengthCm === 0) {
return null;
}
return Math.round((weightG / Math.pow(lengthCm, 3)) * 100000 * 1000) / 1000;
}
/**
* Check whether a given date falls within a protection period.
*
* @param startMMDD - Start date in MM.DD format (e.g. "03.01" for March 1st)
* @param endMMDD - End date in MM.DD format (e.g. "06.30" for June 30th)
* @param date - The date to check (defaults to today)
* @returns true if the date is within the protection period
*/
export function isInProtectionPeriod(
startMMDD: string | null | undefined,
endMMDD: string | null | undefined,
date?: Date,
): boolean {
if (!startMMDD || !endMMDD) {
return false;
}
const now = date ?? new Date();
const currentMonth = now.getMonth() + 1;
const currentDay = now.getDate();
const [startMonth, startDay] = startMMDD.split('.').map(Number) as [
number,
number,
];
const [endMonth, endDay] = endMMDD.split('.').map(Number) as [
number,
number,
];
const currentValue = currentMonth * 100 + currentDay;
const startValue = startMonth * 100 + startDay;
const endValue = endMonth * 100 + endDay;
// Handle wrapping around year boundary (e.g. 10.01 - 02.28)
if (startValue <= endValue) {
return currentValue >= startValue && currentValue <= endValue;
} else {
return currentValue >= startValue || currentValue <= endValue;
}
}
/**
* Format GPS coordinates for display.
*
* @returns Formatted string like "48.1234, 11.5678" or "--" if no coords
*/
export function formatGpsCoords(
lat: number | null | undefined,
lng: number | null | undefined,
): string {
if (lat == null || lng == null) {
return '--';
}
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
}
/**
* Compute the lease amount for a given target year, accounting for
* either fixed annual increases or percentage-based increases.
*
* Mirrors the SQL function `public.compute_lease_amount`.
*/
export function computeLeaseAmountForYear(
initialAmount: number,
fixedIncrease: number,
percentageIncrease: number,
startYear: number,
targetYear: number,
): number {
const yearOffset = targetYear - startYear;
if (yearOffset <= 0) {
return initialAmount;
}
let amount: number;
if (percentageIncrease > 0) {
amount =
initialAmount * Math.pow(1 + percentageIncrease / 100, yearOffset);
} else {
amount = initialAmount + fixedIncrease * yearOffset;
}
return Math.round(amount * 100) / 100;
}
/**
* Get the lucide icon name for a given water type.
*/
export function getWaterTypeIcon(type: string): string {
switch (type) {
case 'fluss':
case 'bach':
case 'kanal':
return 'Waves';
case 'see':
case 'stausee':
case 'baggersee':
return 'Droplets';
case 'teich':
case 'weiher':
return 'Droplet';
default:
return 'MapPin';
}
}

View File

@@ -0,0 +1,430 @@
import { z } from 'zod';
// =====================================================
// Enum Schemas
// =====================================================
export const waterTypeSchema = z.enum([
'fluss',
'bach',
'see',
'teich',
'weiher',
'kanal',
'stausee',
'baggersee',
'sonstige',
]);
export const fishAgeClassSchema = z.enum([
'brut',
'soemmerlinge',
'einsoemmerig',
'zweisoemmerig',
'dreisoemmerig',
'vorgestreckt',
'setzlinge',
'laichfische',
'sonstige',
]);
export const catchBookStatusSchema = z.enum([
'offen',
'eingereicht',
'geprueft',
'akzeptiert',
'abgelehnt',
]);
export const catchBookVerificationSchema = z.enum([
'sehrgut',
'gut',
'ok',
'schlecht',
'falsch',
'leer',
]);
export const leasePaymentMethodSchema = z.enum([
'bar',
'lastschrift',
'ueberweisung',
]);
export const fishGenderSchema = z.enum([
'maennlich',
'weiblich',
'unbekannt',
]);
export const fishSizeCategorySchema = z.enum([
'gross',
'mittel',
'klein',
]);
// =====================================================
// Type exports from enums
// =====================================================
export type WaterType = z.infer<typeof waterTypeSchema>;
export type FishAgeClass = z.infer<typeof fishAgeClassSchema>;
export type CatchBookStatus = z.infer<typeof catchBookStatusSchema>;
export type CatchBookVerification = z.infer<typeof catchBookVerificationSchema>;
export type LeasePaymentMethod = z.infer<typeof leasePaymentMethodSchema>;
export type FishGender = z.infer<typeof fishGenderSchema>;
export type FishSizeCategory = z.infer<typeof fishSizeCategorySchema>;
// =====================================================
// Waters
// =====================================================
export const CreateWaterSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(256),
shortName: z.string().max(32).optional(),
waterType: waterTypeSchema.default('sonstige'),
description: z.string().optional(),
surfaceAreaHa: z.number().optional(),
lengthM: z.number().optional(),
widthM: z.number().optional(),
avgDepthM: z.number().optional(),
maxDepthM: z.number().optional(),
outflow: z.string().optional(),
location: z.string().optional(),
classificationOrder: z.number().int().optional(),
county: z.string().optional(),
geoLat: z.number().min(-90).max(90).optional(),
geoLng: z.number().min(-180).max(180).optional(),
lfvNumber: z.string().optional(),
lfvName: z.string().optional(),
costShareDs: z.number().min(0).max(100).optional(),
costShareKalk: z.number().min(0).max(100).optional(),
electrofishingPermitRequested: z.boolean().default(false),
hejfishId: z.string().optional(),
costCenterId: z.string().uuid().optional(),
isArchived: z.boolean().default(false),
});
export const UpdateWaterSchema = CreateWaterSchema.partial().extend({
waterId: z.string().uuid(),
});
export type CreateWaterInput = z.infer<typeof CreateWaterSchema>;
export type UpdateWaterInput = z.infer<typeof UpdateWaterSchema>;
// =====================================================
// Fish Species
// =====================================================
export const CreateFishSpeciesSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(256),
nameLatin: z.string().optional(),
nameLocal: z.string().optional(),
isActive: z.boolean().default(true),
maxAgeYears: z.number().int().optional(),
maxWeightKg: z.number().optional(),
maxLengthCm: z.number().optional(),
protectedMinSizeCm: z.number().optional(),
protectionPeriodStart: z
.string()
.regex(/^\d{2}\.\d{2}$/)
.optional(),
protectionPeriodEnd: z
.string()
.regex(/^\d{2}\.\d{2}$/)
.optional(),
spawningSeasonStart: z
.string()
.regex(/^\d{2}\.\d{2}$/)
.optional(),
spawningSeasonEnd: z
.string()
.regex(/^\d{2}\.\d{2}$/)
.optional(),
hasSpecialSpawningSeason: z.boolean().default(false),
kFactorAvg: z.number().optional(),
kFactorMin: z.number().optional(),
kFactorMax: z.number().optional(),
pricePerUnit: z.number().optional(),
maxCatchPerDay: z.number().int().optional(),
maxCatchPerYear: z.number().int().optional(),
individualRecording: z.boolean().default(false),
});
export const UpdateFishSpeciesSchema = CreateFishSpeciesSchema.partial().extend({
speciesId: z.string().uuid(),
});
export type CreateFishSpeciesInput = z.infer<typeof CreateFishSpeciesSchema>;
export type UpdateFishSpeciesInput = z.infer<typeof UpdateFishSpeciesSchema>;
// =====================================================
// Water-Species Rules
// =====================================================
export const CreateWaterSpeciesRuleSchema = z.object({
waterId: z.string().uuid(),
speciesId: z.string().uuid(),
minSizeCm: z.number().optional(),
protectionPeriodStart: z.string().optional(),
protectionPeriodEnd: z.string().optional(),
maxCatchPerDay: z.number().int().optional(),
maxCatchPerYear: z.number().int().optional(),
});
export type CreateWaterSpeciesRuleInput = z.infer<
typeof CreateWaterSpeciesRuleSchema
>;
// =====================================================
// Stocking
// =====================================================
export const CreateStockingSchema = z.object({
accountId: z.string().uuid(),
waterId: z.string().uuid(),
speciesId: z.string().uuid(),
stockingDate: z.string(),
quantity: z.number().int().min(0),
weightKg: z.number().optional(),
ageClass: fishAgeClassSchema.default('sonstige'),
costEuros: z.number().optional(),
supplierId: z.string().uuid().optional(),
remarks: z.string().optional(),
});
export const UpdateStockingSchema = CreateStockingSchema.partial().extend({
stockingId: z.string().uuid(),
});
export type CreateStockingInput = z.infer<typeof CreateStockingSchema>;
export type UpdateStockingInput = z.infer<typeof UpdateStockingSchema>;
// =====================================================
// Leases
// =====================================================
export const CreateLeaseSchema = z.object({
accountId: z.string().uuid(),
waterId: z.string().uuid(),
lessorName: z.string().min(1),
lessorAddress: z.string().optional(),
lessorPhone: z.string().optional(),
lessorEmail: z.string().optional(),
startDate: z.string(),
endDate: z.string().optional(),
durationYears: z.number().int().optional(),
initialAmount: z.number().min(0),
fixedAnnualIncrease: z.number().default(0),
percentageAnnualIncrease: z.number().default(0),
paymentMethod: leasePaymentMethodSchema.default('ueberweisung'),
accountHolder: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
locationDetails: z.string().optional(),
specialAgreements: z.string().optional(),
isArchived: z.boolean().default(false),
});
export const UpdateLeaseSchema = CreateLeaseSchema.partial().extend({
leaseId: z.string().uuid(),
});
export type CreateLeaseInput = z.infer<typeof CreateLeaseSchema>;
export type UpdateLeaseInput = z.infer<typeof UpdateLeaseSchema>;
// =====================================================
// Catch Books
// =====================================================
export const CreateCatchBookSchema = z.object({
accountId: z.string().uuid(),
memberId: z.string().uuid(),
year: z.number().int().min(1900).max(2100),
memberName: z.string().optional(),
memberBirthDate: z.string().optional(),
fishingDaysCount: z.number().int().default(0),
cardNumbers: z.string().optional(),
isFlyFisher: z.boolean().default(false),
isHejfish: z.boolean().default(false),
isEmpty: z.boolean().default(false),
notFished: z.boolean().default(false),
remarks: z.string().optional(),
});
export const UpdateCatchBookSchema = CreateCatchBookSchema.partial().extend({
catchBookId: z.string().uuid(),
status: catchBookStatusSchema.optional(),
verification: catchBookVerificationSchema.optional(),
isChecked: z.boolean().optional(),
isSubmitted: z.boolean().optional(),
});
export type CreateCatchBookInput = z.infer<typeof CreateCatchBookSchema>;
export type UpdateCatchBookInput = z.infer<typeof UpdateCatchBookSchema>;
// =====================================================
// Catches
// =====================================================
export const CreateCatchSchema = z.object({
catchBookId: z.string().uuid(),
speciesId: z.string().uuid(),
waterId: z.string().uuid().optional(),
memberId: z.string().uuid().optional(),
catchDate: z.string(),
quantity: z.number().int().default(1),
lengthCm: z.number().optional(),
weightG: z.number().optional(),
sizeCategory: fishSizeCategorySchema.optional(),
gender: fishGenderSchema.optional(),
isEmptyEntry: z.boolean().default(false),
hasError: z.boolean().default(false),
hejfishId: z.string().optional(),
competitionId: z.string().uuid().optional(),
competitionParticipantId: z.string().uuid().optional(),
permitId: z.string().uuid().optional(),
remarks: z.string().optional(),
});
export type CreateCatchInput = z.infer<typeof CreateCatchSchema>;
// =====================================================
// Permits
// =====================================================
export const CreatePermitSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1),
shortCode: z.string().optional(),
primaryWaterId: z.string().uuid().optional(),
totalQuantity: z.number().int().optional(),
costCenterId: z.string().uuid().optional(),
hejfishId: z.string().optional(),
isForSale: z.boolean().default(true),
isArchived: z.boolean().default(false),
});
export const UpdatePermitSchema = CreatePermitSchema.partial().extend({
permitId: z.string().uuid(),
});
export type CreatePermitInput = z.infer<typeof CreatePermitSchema>;
export type UpdatePermitInput = z.infer<typeof UpdatePermitSchema>;
// =====================================================
// Inspector Assignments
// =====================================================
export const CreateInspectorAssignmentSchema = z.object({
accountId: z.string().uuid(),
waterId: z.string().uuid(),
memberId: z.string().uuid(),
assignmentStart: z.string().default(() => new Date().toISOString().split('T')[0]!),
assignmentEnd: z.string().optional(),
});
export type CreateInspectorAssignmentInput = z.infer<
typeof CreateInspectorAssignmentSchema
>;
// =====================================================
// Competitions
// =====================================================
export const CreateCompetitionSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1),
competitionDate: z.string(),
eventId: z.string().uuid().optional(),
permitId: z.string().uuid().optional(),
waterId: z.string().uuid().optional(),
maxParticipants: z.number().int().optional(),
scoreByCount: z.boolean().default(false),
scoreByHeaviest: z.boolean().default(false),
scoreByTotalWeight: z.boolean().default(true),
scoreByLongest: z.boolean().default(false),
scoreByTotalLength: z.boolean().default(false),
separateMemberGuestScoring: z.boolean().default(false),
resultCountWeight: z.number().int().default(3),
resultCountLength: z.number().int().default(3),
resultCountCount: z.number().int().default(3),
});
export const UpdateCompetitionSchema = CreateCompetitionSchema.partial().extend({
competitionId: z.string().uuid(),
});
export type CreateCompetitionInput = z.infer<typeof CreateCompetitionSchema>;
export type UpdateCompetitionInput = z.infer<typeof UpdateCompetitionSchema>;
// =====================================================
// Competition Participants
// =====================================================
export const CreateCompetitionParticipantSchema = z.object({
competitionId: z.string().uuid(),
memberId: z.string().uuid().optional(),
categoryId: z.string().uuid().optional(),
participantName: z.string().min(1),
birthDate: z.string().optional(),
address: z.string().optional(),
phone: z.string().optional(),
email: z.string().optional(),
});
export type CreateCompetitionParticipantInput = z.infer<
typeof CreateCompetitionParticipantSchema
>;
// =====================================================
// Suppliers
// =====================================================
export const CreateSupplierSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1),
contactPerson: z.string().optional(),
phone: z.string().optional(),
email: z.string().optional(),
address: z.string().optional(),
notes: z.string().optional(),
isActive: z.boolean().default(true),
});
export const UpdateSupplierSchema = CreateSupplierSchema.partial().extend({
supplierId: z.string().uuid(),
});
export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>;
export type UpdateSupplierInput = z.infer<typeof UpdateSupplierSchema>;
// =====================================================
// Export & Statistics Filter
// =====================================================
export const FischereiExportSchema = z.object({
accountId: z.string().uuid(),
entityType: z.enum([
'stocking',
'catches',
'waters',
'species',
'leases',
'permits',
'competitions',
]),
year: z.number().int().optional(),
format: z.enum(['csv', 'excel']),
});
export const CatchStatisticsFilterSchema = z.object({
accountId: z.string().uuid(),
year: z.number().int().optional(),
waterId: z.string().uuid().optional(),
});
export type FischereiExportInput = z.infer<typeof FischereiExportSchema>;
export type CatchStatisticsFilterInput = z.infer<typeof CatchStatisticsFilterSchema>;

View File

@@ -0,0 +1,673 @@
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateWaterSchema,
UpdateWaterSchema,
CreateFishSpeciesSchema,
UpdateFishSpeciesSchema,
CreateWaterSpeciesRuleSchema,
CreateStockingSchema,
UpdateStockingSchema,
CreateLeaseSchema,
UpdateLeaseSchema,
CreateCatchBookSchema,
UpdateCatchBookSchema,
CreateCatchSchema,
CreatePermitSchema,
UpdatePermitSchema,
CreateInspectorAssignmentSchema,
CreateCompetitionSchema,
UpdateCompetitionSchema,
CreateCompetitionParticipantSchema,
CreateSupplierSchema,
UpdateSupplierSchema,
catchBookStatusSchema,
catchBookVerificationSchema,
} from '../../schema/fischerei.schema';
import { createFischereiApi } from '../api';
// =====================================================
// Waters
// =====================================================
export const createWater = authActionClient
.inputSchema(CreateWaterSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.water.create' }, 'Creating water...');
const result = await api.createWater(input, userId);
logger.info({ name: 'fischerei.water.create' }, 'Water created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateWater = authActionClient
.inputSchema(UpdateWaterSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.water.update' }, 'Updating water...');
const result = await api.updateWater(input, userId);
logger.info({ name: 'fischerei.water.update' }, 'Water updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteWater = authActionClient
.inputSchema(
z.object({
waterId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.water.delete' }, 'Archiving water...');
await api.deleteWater(input.waterId);
logger.info({ name: 'fischerei.water.delete' }, 'Water archived');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Species
// =====================================================
export const createSpecies = authActionClient
.inputSchema(CreateFishSpeciesSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.species.create' }, 'Creating species...');
const result = await api.createSpecies(input);
logger.info({ name: 'fischerei.species.create' }, 'Species created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateSpecies = authActionClient
.inputSchema(UpdateFishSpeciesSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.species.update' }, 'Updating species...');
const result = await api.updateSpecies(input);
logger.info({ name: 'fischerei.species.update' }, 'Species updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteSpecies = authActionClient
.inputSchema(
z.object({
speciesId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.species.delete' }, 'Deleting species...');
await api.deleteSpecies(input.speciesId);
logger.info({ name: 'fischerei.species.delete' }, 'Species deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Water-Species Rules
// =====================================================
export const upsertWaterSpeciesRule = authActionClient
.inputSchema(CreateWaterSpeciesRuleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.waterSpeciesRule.upsert' },
'Upserting water species rule...',
);
const result = await api.upsertWaterSpeciesRule(input);
logger.info(
{ name: 'fischerei.waterSpeciesRule.upsert' },
'Water species rule upserted',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteWaterSpeciesRule = authActionClient
.inputSchema(
z.object({
ruleId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.waterSpeciesRule.delete' },
'Deleting water species rule...',
);
await api.deleteWaterSpeciesRule(input.ruleId);
logger.info(
{ name: 'fischerei.waterSpeciesRule.delete' },
'Water species rule deleted',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Stocking
// =====================================================
export const createStocking = authActionClient
.inputSchema(CreateStockingSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.stocking.create' }, 'Creating stocking entry...');
const result = await api.createStocking(input, userId);
logger.info({ name: 'fischerei.stocking.create' }, 'Stocking entry created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateStocking = authActionClient
.inputSchema(UpdateStockingSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.stocking.update' }, 'Updating stocking entry...');
const result = await api.updateStocking(input, userId);
logger.info({ name: 'fischerei.stocking.update' }, 'Stocking entry updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteStocking = authActionClient
.inputSchema(
z.object({
stockingId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.stocking.delete' }, 'Deleting stocking entry...');
await api.deleteStocking(input.stockingId);
logger.info({ name: 'fischerei.stocking.delete' }, 'Stocking entry deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Leases
// =====================================================
export const createLease = authActionClient
.inputSchema(CreateLeaseSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.lease.create' }, 'Creating lease...');
const result = await api.createLease(input, userId);
logger.info({ name: 'fischerei.lease.create' }, 'Lease created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateLease = authActionClient
.inputSchema(UpdateLeaseSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.lease.update' }, 'Updating lease...');
const result = await api.updateLease(input, userId);
logger.info({ name: 'fischerei.lease.update' }, 'Lease updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
// =====================================================
// Catch Books
// =====================================================
export const createCatchBook = authActionClient
.inputSchema(CreateCatchBookSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.catchBook.create' }, 'Creating catch book...');
const result = await api.createCatchBook(input, userId);
logger.info({ name: 'fischerei.catchBook.create' }, 'Catch book created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateCatchBook = authActionClient
.inputSchema(UpdateCatchBookSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.catchBook.update' }, 'Updating catch book...');
const result = await api.updateCatchBook(input, userId);
logger.info({ name: 'fischerei.catchBook.update' }, 'Catch book updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const submitCatchBook = authActionClient
.inputSchema(
z.object({
catchBookId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info({ name: 'fischerei.catchBook.submit' }, 'Submitting catch book...');
const result = await api.submitCatchBook(input.catchBookId, userId);
logger.info({ name: 'fischerei.catchBook.submit' }, 'Catch book submitted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const reviewCatchBook = authActionClient
.inputSchema(
z.object({
catchBookId: z.string().uuid(),
accountId: z.string().uuid(),
status: z.enum(['akzeptiert', 'abgelehnt']),
verification: catchBookVerificationSchema.optional(),
remarks: z.string().optional(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info(
{ name: 'fischerei.catchBook.review' },
`Reviewing catch book (${input.status})...`,
);
const result = await api.reviewCatchBook(
input.catchBookId,
userId,
input.status,
input.verification,
input.remarks,
);
logger.info({ name: 'fischerei.catchBook.review' }, 'Catch book reviewed');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
// =====================================================
// Catches
// =====================================================
export const createCatch = authActionClient
.inputSchema(CreateCatchSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.catch.create' }, 'Creating catch entry...');
const result = await api.createCatch(input);
logger.info({ name: 'fischerei.catch.create' }, 'Catch entry created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateCatch = authActionClient
.inputSchema(
CreateCatchSchema.partial().extend({
catchId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const { catchId, ...updates } = input;
logger.info({ name: 'fischerei.catch.update' }, 'Updating catch entry...');
const result = await api.updateCatch(catchId, updates);
logger.info({ name: 'fischerei.catch.update' }, 'Catch entry updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteCatch = authActionClient
.inputSchema(
z.object({
catchId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.catch.delete' }, 'Deleting catch entry...');
await api.deleteCatch(input.catchId);
logger.info({ name: 'fischerei.catch.delete' }, 'Catch entry deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Permits
// =====================================================
export const createPermit = authActionClient
.inputSchema(CreatePermitSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.permit.create' }, 'Creating permit...');
const result = await api.createPermit(input);
logger.info({ name: 'fischerei.permit.create' }, 'Permit created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updatePermit = authActionClient
.inputSchema(UpdatePermitSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.permit.update' }, 'Updating permit...');
const result = await api.updatePermit(input);
logger.info({ name: 'fischerei.permit.update' }, 'Permit updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
// =====================================================
// Inspectors
// =====================================================
export const assignInspector = authActionClient
.inputSchema(CreateInspectorAssignmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.inspector.assign' }, 'Assigning inspector...');
const result = await api.assignInspector(input);
logger.info({ name: 'fischerei.inspector.assign' }, 'Inspector assigned');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const removeInspector = authActionClient
.inputSchema(
z.object({
inspectorId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.inspector.remove' }, 'Removing inspector...');
await api.removeInspector(input.inspectorId);
logger.info({ name: 'fischerei.inspector.remove' }, 'Inspector removed');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
// =====================================================
// Competitions
// =====================================================
export const createCompetition = authActionClient
.inputSchema(CreateCompetitionSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info(
{ name: 'fischerei.competition.create' },
'Creating competition...',
);
const result = await api.createCompetition(input, userId);
logger.info(
{ name: 'fischerei.competition.create' },
'Competition created',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateCompetition = authActionClient
.inputSchema(UpdateCompetitionSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
const userId = ctx.user.id;
logger.info(
{ name: 'fischerei.competition.update' },
'Updating competition...',
);
const result = await api.updateCompetition(input, userId);
logger.info(
{ name: 'fischerei.competition.update' },
'Competition updated',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteCompetition = authActionClient
.inputSchema(
z.object({
competitionId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.competition.delete' },
'Deleting competition...',
);
await api.deleteCompetition(input.competitionId);
logger.info(
{ name: 'fischerei.competition.delete' },
'Competition deleted',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
export const addCompetitionParticipant = authActionClient
.inputSchema(CreateCompetitionParticipantSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.competition.addParticipant' },
'Adding participant...',
);
const result = await api.addParticipant(input);
logger.info(
{ name: 'fischerei.competition.addParticipant' },
'Participant added',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const removeCompetitionParticipant = authActionClient
.inputSchema(
z.object({
participantId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.competition.removeParticipant' },
'Removing participant...',
);
await api.removeParticipant(input.participantId);
logger.info(
{ name: 'fischerei.competition.removeParticipant' },
'Participant removed',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});
export const computeCompetitionResults = authActionClient
.inputSchema(
z.object({
competitionId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info(
{ name: 'fischerei.competition.computeResults' },
'Computing competition results...',
);
const results = await api.computeCompetitionResults(input.competitionId);
logger.info(
{ name: 'fischerei.competition.computeResults' },
'Competition results computed',
);
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: results };
});
// =====================================================
// Suppliers
// =====================================================
export const createSupplier = authActionClient
.inputSchema(CreateSupplierSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.supplier.create' }, 'Creating supplier...');
const result = await api.createSupplier(input);
logger.info({ name: 'fischerei.supplier.create' }, 'Supplier created');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const updateSupplier = authActionClient
.inputSchema(UpdateSupplierSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.supplier.update' }, 'Updating supplier...');
const result = await api.updateSupplier(input);
logger.info({ name: 'fischerei.supplier.update' }, 'Supplier updated');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true, data: result };
});
export const deleteSupplier = authActionClient
.inputSchema(
z.object({
supplierId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createFischereiApi(client);
logger.info({ name: 'fischerei.supplier.delete' }, 'Deleting supplier...');
await api.deleteSupplier(input.supplierId);
logger.info({ name: 'fischerei.supplier.delete' }, 'Supplier deleted');
revalidatePath('/home/[account]/fischerei', 'page');
return { success: true };
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}