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