feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
445
packages/features/fischerei/src/components/create-water-form.tsx
Normal file
445
packages/features/fischerei/src/components/create-water-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
10
packages/features/fischerei/src/components/index.ts
Normal file
10
packages/features/fischerei/src/components/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
234
packages/features/fischerei/src/components/waters-data-table.tsx
Normal file
234
packages/features/fischerei/src/components/waters-data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
packages/features/fischerei/src/lib/fischerei-constants.ts
Normal file
102
packages/features/fischerei/src/lib/fischerei-constants.ts
Normal 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',
|
||||
};
|
||||
131
packages/features/fischerei/src/lib/fischerei-utils.ts
Normal file
131
packages/features/fischerei/src/lib/fischerei-utils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
430
packages/features/fischerei/src/schema/fischerei.schema.ts
Normal file
430
packages/features/fischerei/src/schema/fischerei.schema.ts
Normal 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>;
|
||||
@@ -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 };
|
||||
});
|
||||
1458
packages/features/fischerei/src/server/api.ts
Normal file
1458
packages/features/fischerei/src/server/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user