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:
@@ -24,6 +24,7 @@ export function PasswordInput(props: React.ComponentProps<'input'>) {
|
||||
data-test="password-input"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={'************'}
|
||||
required
|
||||
{...props}
|
||||
/>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CreateCourseForm({ accountId, account }: Props) {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kurs erfolgreich erstellt');
|
||||
router.push(`/home/${account}/courses-cms`);
|
||||
router.push(`/home/${account}/courses`);
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CreateEventForm({ accountId, account }: Props) {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Veranstaltung erfolgreich erstellt');
|
||||
router.push(`/home/${account}/events-cms`);
|
||||
router.push(`/home/${account}/events`);
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CreateEventInput } from '../schema/event.schema';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function createEventManagementApi(client: SupabaseClient<Database>) {
|
||||
const PAGE_SIZE = 25;
|
||||
const db = client;
|
||||
|
||||
return {
|
||||
@@ -14,10 +15,28 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
|
||||
.eq('account_id', accountId).order('event_date', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
const page = opts?.page ?? 1;
|
||||
query = query.range((page - 1) * 25, page * 25 - 1);
|
||||
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0 };
|
||||
const total = count ?? 0;
|
||||
return { data: data ?? [], total, page, pageSize: PAGE_SIZE, totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)) };
|
||||
},
|
||||
|
||||
async getRegistrationCounts(eventIds: string[]) {
|
||||
if (eventIds.length === 0) return {} as Record<string, number>;
|
||||
const { data, error } = await client
|
||||
.from('event_registrations')
|
||||
.select('event_id', { count: 'exact', head: false })
|
||||
.in('event_id', eventIds)
|
||||
.in('status', ['pending', 'confirmed']);
|
||||
if (error) throw error;
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of data ?? []) {
|
||||
const eid = (row as Record<string, unknown>).event_id as string;
|
||||
counts[eid] = (counts[eid] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
|
||||
async getEvent(eventId: string) {
|
||||
|
||||
40
packages/features/fischerei/package.json
Normal file
40
packages/features/fischerei/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@kit/fischerei",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"recharts": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -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
6
packages/features/fischerei/tsconfig.json
Normal file
6
packages/features/fischerei/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
@@ -9,6 +8,10 @@ import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { approveApplication, rejectApplication } from '../server/actions/member-actions';
|
||||
import {
|
||||
APPLICATION_STATUS_VARIANT,
|
||||
APPLICATION_STATUS_LABEL,
|
||||
} from '../lib/member-utils';
|
||||
|
||||
interface ApplicationWorkflowProps {
|
||||
applications: Array<Record<string, unknown>>;
|
||||
@@ -16,36 +19,12 @@ interface ApplicationWorkflowProps {
|
||||
account: string;
|
||||
}
|
||||
|
||||
const APPLICATION_STATUS_LABELS: Record<string, string> = {
|
||||
submitted: 'Eingereicht',
|
||||
review: 'In Prüfung',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
function getApplicationStatusColor(
|
||||
status: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'default';
|
||||
case 'submitted':
|
||||
case 'review':
|
||||
return 'outline';
|
||||
case 'rejected':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
export function ApplicationWorkflow({
|
||||
applications,
|
||||
accountId,
|
||||
account,
|
||||
}: ApplicationWorkflowProps) {
|
||||
const router = useRouter();
|
||||
const form = useForm();
|
||||
|
||||
const { execute: executeApprove, isPending: isApproving } = useAction(
|
||||
approveApplication,
|
||||
@@ -162,8 +141,8 @@ export function ApplicationWorkflow({
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={getApplicationStatusColor(appStatus)}>
|
||||
{APPLICATION_STATUS_LABELS[appStatus] ?? appStatus}
|
||||
<Badge variant={APPLICATION_STATUS_VARIANT[appStatus] ?? 'secondary'}>
|
||||
{APPLICATION_STATUS_LABEL[appStatus] ?? appStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
|
||||
@@ -67,3 +67,17 @@ export const STATUS_LABELS: Record<string, string> = {
|
||||
excluded: 'Ausgeschlossen',
|
||||
deceased: 'Verstorben',
|
||||
};
|
||||
|
||||
export const APPLICATION_STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
submitted: 'outline',
|
||||
review: 'secondary',
|
||||
approved: 'default',
|
||||
rejected: 'destructive',
|
||||
};
|
||||
|
||||
export const APPLICATION_STATUS_LABEL: Record<string, string> = {
|
||||
submitted: 'Eingereicht',
|
||||
review: 'In Prüfung',
|
||||
approved: 'Genehmigt',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CreateNewsletterForm({ accountId, account }: Props) {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Newsletter erfolgreich erstellt');
|
||||
router.push(`/home/${account}/newsletter-cms`);
|
||||
router.push(`/home/${account}/newsletter`);
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./context": "./src/context/site-data-context.tsx",
|
||||
"./config/*": "./src/config/*.tsx",
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import { Render } from '@measured/puck';
|
||||
import { clubPuckConfig } from '../config/puck-config';
|
||||
import { SiteDataProvider, type SiteData } from '../context/site-data-context';
|
||||
|
||||
interface Props {
|
||||
data: Record<string, unknown>;
|
||||
siteData?: SiteData;
|
||||
}
|
||||
|
||||
export function SiteRenderer({ data }: Props) {
|
||||
return <Render config={clubPuckConfig} data={data as any} />;
|
||||
export function SiteRenderer({ data, siteData }: Props) {
|
||||
const defaultData: SiteData = { accountId: '', events: [], courses: [], posts: [] };
|
||||
|
||||
return (
|
||||
<SiteDataProvider data={siteData ?? defaultData}>
|
||||
<Render config={clubPuckConfig} data={data as any} />
|
||||
</SiteDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Config } from '@measured/puck';
|
||||
import React from 'react';
|
||||
import { useSiteData } from '../context/site-data-context';
|
||||
|
||||
// Block components inline for simplicity
|
||||
|
||||
@@ -165,80 +166,385 @@ const MemberLoginBlock = ({ title, description }: { title: string; description:
|
||||
</section>
|
||||
);
|
||||
|
||||
const NewsFeedBlock = ({ count, showImage }: { count: number; showImage: boolean }) => (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Neuigkeiten</h2>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: count || 3 }, (_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex gap-4">
|
||||
{showImage && <div className="h-20 w-20 shrink-0 rounded bg-muted" />}
|
||||
<div>
|
||||
<h3 className="font-semibold">Beitragstitel {i + 1}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">Kurzbeschreibung des Beitrags...</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">01.01.2026</p>
|
||||
const NewsFeedBlock = ({ count, showImage }: { count: number; showImage: boolean }) => {
|
||||
const { posts } = useSiteData();
|
||||
const items = posts.slice(0, count || 5);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Neuigkeiten</h2>
|
||||
<p className="text-muted-foreground">Noch keine Beiträge vorhanden.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Neuigkeiten</h2>
|
||||
<div className="space-y-4">
|
||||
{items.map((post) => (
|
||||
<div key={post.id} className="rounded-lg border p-4 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex gap-4">
|
||||
{showImage && post.cover_image && <img src={post.cover_image} alt="" className="h-20 w-20 shrink-0 rounded object-cover" />}
|
||||
{showImage && !post.cover_image && <div className="h-20 w-20 shrink-0 rounded bg-muted" />}
|
||||
<div>
|
||||
<h3 className="font-semibold">{post.title}</h3>
|
||||
{post.excerpt && <p className="text-sm text-muted-foreground mt-1">{post.excerpt}</p>}
|
||||
{post.published_at && <p className="text-xs text-muted-foreground mt-2">{new Date(post.published_at).toLocaleDateString('de-DE')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const EventListBlock = ({ count, showPastEvents }: { count: number; showPastEvents: boolean }) => (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Veranstaltungen</h2>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count || 3 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-lg border p-4">
|
||||
<div className="flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<span className="text-lg font-bold">{15 + i}</span>
|
||||
<span className="text-xs">Apr</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Veranstaltung {i + 1}</h3>
|
||||
<p className="text-xs text-muted-foreground">10:00 — Vereinsheim</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const EventListBlock = ({ count, showPastEvents }: { count: number; showPastEvents: boolean }) => {
|
||||
const { events } = useSiteData();
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
const filtered = showPastEvents ? events : events.filter(e => e.event_date >= now);
|
||||
const items = filtered.slice(0, count || 5);
|
||||
|
||||
const CourseCatalogBlock = ({ count, showPrice }: { count: number; showPrice: boolean }) => (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Kursangebot</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: count || 4 }, (_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold">Kurs {i + 1}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">Mo, 18:00 — 20:00</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
{showPrice && <span className="text-sm font-semibold text-primary">49,00 €</span>}
|
||||
<span className="text-xs text-muted-foreground">5/15 Plätze</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
const [formData, setFormData] = React.useState({ firstName: '', lastName: '', email: '', phone: '', dateOfBirth: '' });
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [successId, setSuccessId] = React.useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = React.useState('');
|
||||
|
||||
const CardShopBlock = ({ title, description }: { title: string; description: string }) => (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-2">{title || 'Mitgliedschaft'}</h2>
|
||||
{description && <p className="text-muted-foreground mb-6">{description}</p>}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{['Basis', 'Standard', 'Familie'].map((name, i) => (
|
||||
<div key={name} className="rounded-lg border p-6 text-center hover:border-primary transition-colors">
|
||||
<h3 className="text-lg font-bold">{name}</h3>
|
||||
<p className="text-3xl font-bold text-primary mt-2">{[5, 10, 18][i]} €</p>
|
||||
<p className="text-xs text-muted-foreground">pro Monat</p>
|
||||
<button className="mt-4 w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground">Auswählen</button>
|
||||
const handleSubmit = async (eventId: string) => {
|
||||
if (!formData.firstName || !formData.lastName || !formData.email) {
|
||||
setErrorMsg('Bitte füllen Sie alle Pflichtfelder aus.');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
setErrorMsg('Ungültige E-Mail-Adresse.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/club/event-register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
eventId,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone || undefined,
|
||||
dateOfBirth: formData.dateOfBirth || undefined,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
setSuccessId(eventId);
|
||||
setExpandedId(null);
|
||||
setFormData({ firstName: '', lastName: '', email: '', phone: '', dateOfBirth: '' });
|
||||
} else {
|
||||
setErrorMsg(result.error || 'Anmeldung fehlgeschlagen.');
|
||||
}
|
||||
} catch {
|
||||
setErrorMsg('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Veranstaltungen</h2>
|
||||
<p className="text-muted-foreground">Keine anstehenden Veranstaltungen.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Veranstaltungen</h2>
|
||||
<div className="space-y-3">
|
||||
{items.map((event) => {
|
||||
const d = new Date(event.event_date);
|
||||
const isExpanded = expandedId === event.id;
|
||||
const isSuccess = successId === event.id;
|
||||
return (
|
||||
<div key={event.id} className="rounded-lg border overflow-hidden">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<span className="text-lg font-bold">{d.getDate()}</span>
|
||||
<span className="text-xs">{d.toLocaleDateString('de-DE', { month: 'short' })}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold">{event.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.event_time ? event.event_time.slice(0, 5) : ''}{event.location ? ` — ${event.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{event.fee > 0 && <span className="text-sm font-semibold text-primary shrink-0">{event.fee.toFixed(2)} €</span>}
|
||||
{isSuccess ? (
|
||||
<span className="shrink-0 flex items-center gap-1 text-sm font-semibold text-green-600">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
Angemeldet
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setExpandedId(isExpanded ? null : event.id); setErrorMsg(''); setSuccessId(null); }}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/30 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Vorname *" value={formData.firstName} onChange={e => setFormData(p => ({ ...p, firstName: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="Nachname *" value={formData.lastName} onChange={e => setFormData(p => ({ ...p, lastName: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="E-Mail *" type="email" value={formData.email} onChange={e => setFormData(p => ({ ...p, email: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="Telefon" type="tel" value={formData.phone} onChange={e => setFormData(p => ({ ...p, phone: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
<input placeholder="Geburtsdatum" type="date" value={formData.dateOfBirth} onChange={e => setFormData(p => ({ ...p, dateOfBirth: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSubmit(event.id)}
|
||||
disabled={submitting}
|
||||
className="rounded-md bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{submitting && <svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25" /><path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="4" strokeLinecap="round" className="opacity-75" /></svg>}
|
||||
Anmeldung absenden
|
||||
</button>
|
||||
<button onClick={() => setExpandedId(null)} className="rounded-md border px-4 py-2 text-sm hover:bg-muted">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseCatalogBlock = ({ count, showPrice }: { count: number; showPrice: boolean }) => {
|
||||
const { courses } = useSiteData();
|
||||
const items = courses.slice(0, count || 4);
|
||||
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
const [formData, setFormData] = React.useState({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [successId, setSuccessId] = React.useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = React.useState('');
|
||||
|
||||
const handleSubmit = async (courseId: string) => {
|
||||
if (!formData.firstName || !formData.lastName || !formData.email) {
|
||||
setErrorMsg('Bitte füllen Sie alle Pflichtfelder aus.');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
setErrorMsg('Ungültige E-Mail-Adresse.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/club/course-register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
courseId,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone || undefined,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
setSuccessId(courseId);
|
||||
setExpandedId(null);
|
||||
setFormData({ firstName: '', lastName: '', email: '', phone: '' });
|
||||
} else {
|
||||
setErrorMsg(result.error || 'Anmeldung fehlgeschlagen.');
|
||||
}
|
||||
} catch {
|
||||
setErrorMsg('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Kursangebot</h2>
|
||||
<p className="text-muted-foreground">Aktuell keine Kurse verfügbar.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">Kursangebot</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{items.map((course) => {
|
||||
const isExpanded = expandedId === course.id;
|
||||
const isSuccess = successId === course.id;
|
||||
return (
|
||||
<div key={course.id} className="rounded-lg border overflow-hidden">
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold">{course.name}</h3>
|
||||
{course.start_date && (
|
||||
<p className="text-sm text-muted-foreground mt-1">Ab {new Date(course.start_date).toLocaleDateString('de-DE')}</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
{showPrice && <span className="text-sm font-semibold text-primary">{course.fee.toFixed(2)} €</span>}
|
||||
{course.capacity && <span className="text-xs text-muted-foreground">{course.enrolled_count}/{course.capacity} Plätze</span>}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{isSuccess ? (
|
||||
<span className="flex items-center gap-1 text-sm font-semibold text-green-600">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
Anmeldung erfolgreich!
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setExpandedId(isExpanded ? null : course.id); setErrorMsg(''); setSuccessId(null); }}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/30 p-4">
|
||||
<div className="space-y-3">
|
||||
<input placeholder="Vorname *" value={formData.firstName} onChange={e => setFormData(p => ({ ...p, firstName: e.target.value }))} className="w-full rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="Nachname *" value={formData.lastName} onChange={e => setFormData(p => ({ ...p, lastName: e.target.value }))} className="w-full rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="E-Mail *" type="email" value={formData.email} onChange={e => setFormData(p => ({ ...p, email: e.target.value }))} className="w-full rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="Telefon" type="tel" value={formData.phone} onChange={e => setFormData(p => ({ ...p, phone: e.target.value }))} className="w-full rounded-md border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSubmit(course.id)}
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting && <svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25" /><path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="4" strokeLinecap="round" className="opacity-75" /></svg>}
|
||||
Absenden
|
||||
</button>
|
||||
<button onClick={() => setExpandedId(null)} className="rounded-md border px-4 py-2 text-sm hover:bg-muted">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const CardShopBlock = ({ title, description }: { title: string; description: string }) => {
|
||||
const { accountId } = useSiteData();
|
||||
const [formData, setFormData] = React.useState({
|
||||
firstName: '', lastName: '', email: '', phone: '',
|
||||
street: '', postalCode: '', city: '', dateOfBirth: '', message: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
const [errorMsg, setErrorMsg] = React.useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.firstName || !formData.lastName || !formData.email) {
|
||||
setErrorMsg('Bitte füllen Sie alle Pflichtfelder aus.');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
setErrorMsg('Ungültige E-Mail-Adresse.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/club/membership-apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accountId,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone || undefined,
|
||||
street: formData.street || undefined,
|
||||
postalCode: formData.postalCode || undefined,
|
||||
city: formData.city || undefined,
|
||||
dateOfBirth: formData.dateOfBirth || undefined,
|
||||
message: formData.message || undefined,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setFormData({ firstName: '', lastName: '', email: '', phone: '', street: '', postalCode: '', city: '', dateOfBirth: '', message: '' });
|
||||
} else {
|
||||
setErrorMsg(result.error || 'Bewerbung fehlgeschlagen.');
|
||||
}
|
||||
} catch {
|
||||
setErrorMsg('Verbindungsfehler. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-2xl mx-auto text-center">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-8">
|
||||
<svg className="mx-auto h-12 w-12 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
<h2 className="mt-4 text-2xl font-bold text-green-800">Bewerbung eingereicht!</h2>
|
||||
<p className="mt-2 text-green-700">Ihre Bewerbung wurde eingereicht! Wir melden uns bei Ihnen.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-6 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-2">{title || 'Mitglied werden'}</h2>
|
||||
{description && <p className="text-muted-foreground mb-6">{description}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<input placeholder="Vorname *" value={formData.firstName} onChange={e => setFormData(p => ({ ...p, firstName: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="Nachname *" value={formData.lastName} onChange={e => setFormData(p => ({ ...p, lastName: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="E-Mail *" type="email" value={formData.email} onChange={e => setFormData(p => ({ ...p, email: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" required />
|
||||
<input placeholder="Telefon" type="tel" value={formData.phone} onChange={e => setFormData(p => ({ ...p, phone: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
<input placeholder="Straße" value={formData.street} onChange={e => setFormData(p => ({ ...p, street: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input placeholder="PLZ" value={formData.postalCode} onChange={e => setFormData(p => ({ ...p, postalCode: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
<input placeholder="Ort" value={formData.city} onChange={e => setFormData(p => ({ ...p, city: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<input placeholder="Geburtsdatum" type="date" value={formData.dateOfBirth} onChange={e => setFormData(p => ({ ...p, dateOfBirth: e.target.value }))} className="rounded-md border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<textarea placeholder="Nachricht (optional)" rows={3} value={formData.message} onChange={e => setFormData(p => ({ ...p, message: e.target.value }))} className="w-full rounded-md border px-3 py-2 text-sm" />
|
||||
{errorMsg && <p className="text-sm text-red-600">{errorMsg}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-md bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting && <svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25" /><path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="4" strokeLinecap="round" className="opacity-75" /></svg>}
|
||||
Mitgliedschaft beantragen
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ColumnsBlock = ({ columns }: { columns: number }) => (
|
||||
<section className="py-8 px-6">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface SiteData {
|
||||
accountId: string;
|
||||
events: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
event_date: string;
|
||||
event_time?: string;
|
||||
location?: string;
|
||||
fee: number;
|
||||
status: string;
|
||||
}>;
|
||||
courses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
fee: number;
|
||||
capacity?: number;
|
||||
enrolled_count: number;
|
||||
status?: string;
|
||||
}>;
|
||||
posts: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
cover_image?: string;
|
||||
published_at?: string;
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const SiteDataContext = createContext<SiteData>({
|
||||
accountId: '',
|
||||
events: [],
|
||||
courses: [],
|
||||
posts: [],
|
||||
});
|
||||
|
||||
export function SiteDataProvider({
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
data: SiteData;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SiteDataContext.Provider value={data}>{children}</SiteDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSiteData() {
|
||||
return useContext(SiteDataContext);
|
||||
}
|
||||
39
packages/features/sitzungsprotokolle/package.json
Normal file
39
packages/features/sitzungsprotokolle/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@kit/sitzungsprotokolle",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'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,
|
||||
FormDescription,
|
||||
} from '@kit/ui/form';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
|
||||
import { CreateMeetingProtocolSchema } from '../schema/meetings.schema';
|
||||
import { createProtocol } from '../server/actions/meetings-actions';
|
||||
|
||||
interface CreateProtocolFormProps {
|
||||
accountId: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export function CreateProtocolForm({
|
||||
accountId,
|
||||
account,
|
||||
}: CreateProtocolFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateMeetingProtocolSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
title: '',
|
||||
meetingDate: new Date().toISOString().split('T')[0]!,
|
||||
meetingType: 'vorstand' as const,
|
||||
location: '',
|
||||
attendees: '',
|
||||
remarks: '',
|
||||
isPublished: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createProtocol, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Protokoll erfolgreich erstellt');
|
||||
router.push(`/home/${account}/meetings/protocols`);
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Speichern des Protokolls');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Card 1: Grunddaten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sitzungsdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Titel *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Vorstandssitzung März 2026" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meetingDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sitzungsdatum *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meetingType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sitzungsart *</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="vorstand">Vorstandssitzung</option>
|
||||
<option value="mitglieder">Mitgliederversammlung</option>
|
||||
<option value="ausschuss">Ausschusssitzung</option>
|
||||
<option value="sonstige">Sonstige</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ort</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Vereinsheim" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Teilnehmer & Anmerkungen */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Teilnehmer & Anmerkungen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attendees"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teilnehmer</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
placeholder="Namen der Teilnehmer (kommagetrennt oder zeilenweise)"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remarks"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Anmerkungen</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
placeholder="Optionale Anmerkungen zum Protokoll"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Veröffentlichung */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Veröffentlichung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isPublished"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Protokoll veröffentlichen</FormLabel>
|
||||
<FormDescription>
|
||||
Veröffentlichte Protokolle sind für alle Mitglieder sichtbar.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</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...' : 'Protokoll erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { MeetingsTabNavigation } from './meetings-tab-navigation';
|
||||
export { MeetingsDashboard } from './meetings-dashboard';
|
||||
export { ProtocolsDataTable } from './protocols-data-table';
|
||||
export { CreateProtocolForm } from './create-protocol-form';
|
||||
export { ProtocolItemsList } from './protocol-items-list';
|
||||
export { OpenTasksView } from './open-tasks-view';
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
ListChecks,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { MEETING_TYPE_LABELS } from '../lib/meetings-constants';
|
||||
|
||||
interface DashboardStats {
|
||||
totalProtocols: number;
|
||||
thisYearProtocols: number;
|
||||
openTasks: number;
|
||||
overdueTasks: number;
|
||||
}
|
||||
|
||||
interface RecentProtocol {
|
||||
id: string;
|
||||
title: string;
|
||||
meeting_date: string;
|
||||
meeting_type: string;
|
||||
is_published: boolean;
|
||||
}
|
||||
|
||||
interface OverdueTask {
|
||||
id: string;
|
||||
title: string;
|
||||
responsible_person: string | null;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
meeting_protocols: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MeetingsDashboardProps {
|
||||
stats: DashboardStats;
|
||||
recentProtocols: RecentProtocol[];
|
||||
overdueTasks: OverdueTask[];
|
||||
account: string;
|
||||
}
|
||||
|
||||
export function MeetingsDashboard({
|
||||
stats,
|
||||
recentProtocols,
|
||||
overdueTasks,
|
||||
account,
|
||||
}: MeetingsDashboardProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sitzungsprotokolle – Übersicht</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Protokolle, Tagesordnungspunkte und Aufgaben verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Link href={`/home/${account}/meetings/protocols`}>
|
||||
<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">Protokolle gesamt</p>
|
||||
<p className="text-2xl font-bold">{stats.totalProtocols}</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}/meetings/protocols`}>
|
||||
<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">Protokolle dieses Jahr</p>
|
||||
<p className="text-2xl font-bold">{stats.thisYearProtocols}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/meetings/tasks`}>
|
||||
<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 Aufgaben</p>
|
||||
<p className="text-2xl font-bold">{stats.openTasks}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<ListChecks className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href={`/home/${account}/meetings/tasks`}>
|
||||
<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">Überfällige Aufgaben</p>
|
||||
<p className="text-2xl font-bold">{stats.overdueTasks}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Recent + Overdue Sections */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Letzte Protokolle</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentProtocols.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch keine Protokolle vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentProtocols.map((protocol) => (
|
||||
<Link
|
||||
key={protocol.id}
|
||||
href={`/home/${account}/meetings/protocols/${protocol.id}`}
|
||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{protocol.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(protocol.meeting_date).toLocaleDateString('de-DE')}
|
||||
{' · '}
|
||||
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
|
||||
</p>
|
||||
</div>
|
||||
{protocol.is_published && (
|
||||
<Badge variant="default" className="ml-2 shrink-0">Veröffentlicht</Badge>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Überfällige Aufgaben</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overdueTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keine überfälligen Aufgaben.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{overdueTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="rounded-lg border border-destructive/20 bg-destructive/5 p-3"
|
||||
>
|
||||
<p className="text-sm font-medium">{task.title}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{task.responsible_person && (
|
||||
<span>Zuständig: {task.responsible_person}</span>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<span>
|
||||
Fällig: {new Date(task.due_date).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Protokoll: {task.meeting_protocols.title}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface MeetingsTabNavigationProps {
|
||||
account: string;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Übersicht', path: '' },
|
||||
{ id: 'protocols', label: 'Protokolle', path: '/protocols' },
|
||||
{ id: 'tasks', label: 'Offene Aufgaben', path: '/tasks' },
|
||||
] as const;
|
||||
|
||||
export function MeetingsTabNavigation({
|
||||
account,
|
||||
activeTab,
|
||||
}: MeetingsTabNavigationProps) {
|
||||
const basePath = `/home/${account}/meetings`;
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Sitzungsprotokolle 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '../lib/meetings-constants';
|
||||
|
||||
interface OpenTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
responsible_person: string | null;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
meeting_protocols: {
|
||||
id: string;
|
||||
title: string;
|
||||
meeting_date: string;
|
||||
meeting_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenTasksViewProps {
|
||||
data: OpenTask[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export function OpenTasksView({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
}: OpenTasksViewProps) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
router.push(`?${params.toString()}`);
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
updateParams({ page: String(newPage) });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Offene Aufgaben ({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 offenen Aufgaben</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Alle Tagesordnungspunkte sind erledigt oder vertagt.
|
||||
</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">Aufgabe</th>
|
||||
<th className="p-3 text-left font-medium">Protokoll</th>
|
||||
<th className="p-3 text-left font-medium">Zuständig</th>
|
||||
<th className="p-3 text-left font-medium">Fällig</th>
|
||||
<th className="p-3 text-center font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((task) => {
|
||||
const isOverdue =
|
||||
task.due_date && new Date(task.due_date) < today;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className="cursor-pointer border-b hover:bg-muted/30"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/home/${account}/meetings/protocols/${task.meeting_protocols.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<td className="p-3">
|
||||
<div>
|
||||
<p className="font-medium">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<div>
|
||||
<p className="text-sm">{task.meeting_protocols.title}</p>
|
||||
<p className="text-xs">
|
||||
{new Date(task.meeting_protocols.meeting_date).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{task.responsible_person ?? '—'}
|
||||
</td>
|
||||
<td className={`p-3 ${isOverdue ? 'font-medium text-destructive' : 'text-muted-foreground'}`}>
|
||||
{task.due_date
|
||||
? new Date(task.due_date).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
{isOverdue && (
|
||||
<span className="ml-1 text-xs">(überfällig)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
(ITEM_STATUS_COLORS[task.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
|
||||
'outline'
|
||||
}
|
||||
>
|
||||
{ITEM_STATUS_LABELS[task.status] ?? task.status}
|
||||
</Badge>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '../lib/meetings-constants';
|
||||
import { updateItemStatus, deleteProtocolItem } from '../server/actions/meetings-actions';
|
||||
|
||||
import type { MeetingItemStatus } from '../schema/meetings.schema';
|
||||
|
||||
interface ProtocolItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
responsible_person: string | null;
|
||||
due_date: string | null;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ProtocolItemsListProps {
|
||||
items: ProtocolItem[];
|
||||
protocolId: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, MeetingItemStatus> = {
|
||||
offen: 'in_bearbeitung',
|
||||
in_bearbeitung: 'erledigt',
|
||||
erledigt: 'offen',
|
||||
vertagt: 'offen',
|
||||
};
|
||||
|
||||
export function ProtocolItemsList({
|
||||
items,
|
||||
protocolId,
|
||||
account,
|
||||
}: ProtocolItemsListProps) {
|
||||
const { execute: executeStatusUpdate, isPending: isUpdating } = useAction(updateItemStatus, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Status aktualisiert');
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteProtocolItem, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Tagesordnungspunkt gelöscht');
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleStatus = (item: ProtocolItem) => {
|
||||
const nextStatus = STATUS_TRANSITIONS[item.status] ?? 'offen';
|
||||
executeStatusUpdate({ itemId: item.id, status: nextStatus });
|
||||
};
|
||||
|
||||
const handleDelete = (itemId: string) => {
|
||||
if (confirm('Tagesordnungspunkt wirklich löschen?')) {
|
||||
executeDelete({ itemId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tagesordnungspunkte ({items.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
|
||||
<h3 className="text-lg font-semibold">Keine Tagesordnungspunkte</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Fügen Sie Tagesordnungspunkte zu diesem Protokoll hinzu.
|
||||
</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">#</th>
|
||||
<th className="p-3 text-left font-medium">Titel</th>
|
||||
<th className="p-3 text-left font-medium">Zuständig</th>
|
||||
<th className="p-3 text-left font-medium">Fällig</th>
|
||||
<th className="p-3 text-center font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
const isOverdue =
|
||||
item.due_date &&
|
||||
['offen', 'in_bearbeitung'].includes(item.status) &&
|
||||
new Date(item.due_date) < new Date();
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="border-b hover:bg-muted/30">
|
||||
<td className="p-3 text-muted-foreground">{index + 1}</td>
|
||||
<td className="p-3">
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{item.responsible_person ?? '—'}
|
||||
</td>
|
||||
<td className={`p-3 ${isOverdue ? 'font-medium text-destructive' : 'text-muted-foreground'}`}>
|
||||
{item.due_date
|
||||
? new Date(item.due_date).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
(ITEM_STATUS_COLORS[item.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
|
||||
'outline'
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleToggleStatus(item)}
|
||||
>
|
||||
{ITEM_STATUS_LABELS[item.status] ?? item.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'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 { MEETING_TYPE_LABELS } from '../lib/meetings-constants';
|
||||
|
||||
interface ProtocolsDataTableProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
}
|
||||
|
||||
const MEETING_TYPE_OPTIONS = [
|
||||
{ value: '', label: 'Alle Sitzungsarten' },
|
||||
{ value: 'vorstand', label: 'Vorstandssitzung' },
|
||||
{ value: 'mitglieder', label: 'Mitgliederversammlung' },
|
||||
{ value: 'ausschuss', label: 'Ausschusssitzung' },
|
||||
{ value: 'sonstige', label: 'Sonstige' },
|
||||
] as const;
|
||||
|
||||
export function ProtocolsDataTable({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
}: ProtocolsDataTableProps) {
|
||||
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="Protokoll 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"
|
||||
>
|
||||
{MEETING_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Link href={`/home/${account}/meetings/protocols/new`}>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neues Protokoll
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Protokolle ({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 Protokolle vorhanden</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Erstellen Sie Ihr erstes Sitzungsprotokoll, um loszulegen.
|
||||
</p>
|
||||
<Link href={`/home/${account}/meetings/protocols/new`} className="mt-4">
|
||||
<Button>Neues Protokoll</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">Titel</th>
|
||||
<th className="p-3 text-left font-medium">Sitzungsart</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-center font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((protocol) => (
|
||||
<tr
|
||||
key={String(protocol.id)}
|
||||
className="cursor-pointer border-b hover:bg-muted/30"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/home/${account}/meetings/protocols/${String(protocol.id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{protocol.meeting_date
|
||||
? new Date(String(protocol.meeting_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/meetings/protocols/${String(protocol.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(protocol.title)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="secondary">
|
||||
{MEETING_TYPE_LABELS[String(protocol.meeting_type)] ??
|
||||
String(protocol.meeting_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{String(protocol.location ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
{protocol.is_published ? (
|
||||
<Badge variant="default">Veröffentlicht</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Entwurf</Badge>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* German label mappings for Sitzungsprotokolle enums and status colors.
|
||||
*/
|
||||
|
||||
// =====================================================
|
||||
// Meeting Type Labels
|
||||
// =====================================================
|
||||
|
||||
export const MEETING_TYPE_LABELS: Record<string, string> = {
|
||||
vorstand: 'Vorstandssitzung',
|
||||
mitglieder: 'Mitgliederversammlung',
|
||||
ausschuss: 'Ausschusssitzung',
|
||||
sonstige: 'Sonstige',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Item Status Labels
|
||||
// =====================================================
|
||||
|
||||
export const ITEM_STATUS_LABELS: Record<string, string> = {
|
||||
offen: 'Offen',
|
||||
in_bearbeitung: 'In Bearbeitung',
|
||||
erledigt: 'Erledigt',
|
||||
vertagt: 'Vertagt',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Item Status Colors (Badge variants)
|
||||
// =====================================================
|
||||
|
||||
export const ITEM_STATUS_COLORS: Record<string, string> = {
|
||||
offen: 'outline',
|
||||
in_bearbeitung: 'secondary',
|
||||
erledigt: 'default',
|
||||
vertagt: 'destructive',
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// =====================================================
|
||||
// Enums
|
||||
// =====================================================
|
||||
|
||||
export const meetingItemStatusEnum = z.enum([
|
||||
'offen',
|
||||
'in_bearbeitung',
|
||||
'erledigt',
|
||||
'vertagt',
|
||||
]);
|
||||
|
||||
export type MeetingItemStatus = z.infer<typeof meetingItemStatusEnum>;
|
||||
|
||||
export const meetingTypeEnum = z.enum([
|
||||
'vorstand',
|
||||
'mitglieder',
|
||||
'ausschuss',
|
||||
'sonstige',
|
||||
]);
|
||||
|
||||
export type MeetingType = z.infer<typeof meetingTypeEnum>;
|
||||
|
||||
// =====================================================
|
||||
// Protocol Schemas
|
||||
// =====================================================
|
||||
|
||||
export const CreateMeetingProtocolSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
title: z.string().min(1, 'Titel ist erforderlich'),
|
||||
meetingDate: z.string().min(1, 'Datum ist erforderlich'),
|
||||
meetingType: meetingTypeEnum,
|
||||
location: z.string().optional(),
|
||||
attendees: z.string().optional(),
|
||||
remarks: z.string().optional(),
|
||||
isPublished: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateMeetingProtocolInput = z.infer<typeof CreateMeetingProtocolSchema>;
|
||||
|
||||
export const UpdateMeetingProtocolSchema = z.object({
|
||||
protocolId: z.string().uuid(),
|
||||
title: z.string().min(1).optional(),
|
||||
meetingDate: z.string().optional(),
|
||||
meetingType: meetingTypeEnum.optional(),
|
||||
location: z.string().optional(),
|
||||
attendees: z.string().optional(),
|
||||
remarks: z.string().optional(),
|
||||
isPublished: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateMeetingProtocolInput = z.infer<typeof UpdateMeetingProtocolSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Protocol Item Schemas
|
||||
// =====================================================
|
||||
|
||||
export const CreateProtocolItemSchema = z.object({
|
||||
protocolId: z.string().uuid(),
|
||||
title: z.string().min(1, 'Titel ist erforderlich'),
|
||||
description: z.string().optional(),
|
||||
responsiblePerson: z.string().optional(),
|
||||
dueDate: z.string().optional(),
|
||||
status: meetingItemStatusEnum.default('offen'),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type CreateProtocolItemInput = z.infer<typeof CreateProtocolItemSchema>;
|
||||
|
||||
export const UpdateProtocolItemSchema = z.object({
|
||||
itemId: z.string().uuid(),
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
responsiblePerson: z.string().optional(),
|
||||
dueDate: z.string().optional(),
|
||||
status: meetingItemStatusEnum.optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type UpdateProtocolItemInput = z.infer<typeof UpdateProtocolItemSchema>;
|
||||
|
||||
export const UpdateItemStatusSchema = z.object({
|
||||
itemId: z.string().uuid(),
|
||||
status: meetingItemStatusEnum,
|
||||
});
|
||||
|
||||
export type UpdateItemStatusInput = z.infer<typeof UpdateItemStatusSchema>;
|
||||
|
||||
export const ReorderItemsSchema = z.object({
|
||||
protocolId: z.string().uuid(),
|
||||
itemIds: z.array(z.string().uuid()),
|
||||
});
|
||||
|
||||
export type ReorderItemsInput = z.infer<typeof ReorderItemsSchema>;
|
||||
@@ -0,0 +1,206 @@
|
||||
'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 {
|
||||
CreateMeetingProtocolSchema,
|
||||
UpdateMeetingProtocolSchema,
|
||||
CreateProtocolItemSchema,
|
||||
UpdateProtocolItemSchema,
|
||||
UpdateItemStatusSchema,
|
||||
ReorderItemsSchema,
|
||||
} from '../../schema/meetings.schema';
|
||||
|
||||
import { createMeetingsApi } from '../api';
|
||||
|
||||
const REVALIDATION_PATH = '/home/[account]/meetings';
|
||||
|
||||
// =====================================================
|
||||
// Protocols
|
||||
// =====================================================
|
||||
|
||||
export const createProtocol = authActionClient
|
||||
.inputSchema(CreateMeetingProtocolSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll wird erstellt...');
|
||||
const result = await api.createProtocol(input, userId);
|
||||
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll erstellt');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateProtocol = authActionClient
|
||||
.inputSchema(UpdateMeetingProtocolSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll wird aktualisiert...');
|
||||
const result = await api.updateProtocol(input, userId);
|
||||
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll aktualisiert');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteProtocol = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
protocolId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll wird gelöscht...');
|
||||
await api.deleteProtocol(input.protocolId);
|
||||
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll gelöscht');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Protocol Items
|
||||
// =====================================================
|
||||
|
||||
export const createProtocolItem = authActionClient
|
||||
.inputSchema(CreateProtocolItemSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt wird erstellt...');
|
||||
const result = await api.createItem(input, userId);
|
||||
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt erstellt');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateProtocolItem = authActionClient
|
||||
.inputSchema(UpdateProtocolItemSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt wird aktualisiert...');
|
||||
const result = await api.updateItem(input, userId);
|
||||
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt aktualisiert');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateItemStatus = authActionClient
|
||||
.inputSchema(UpdateItemStatusSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.item.status' }, 'Status wird geändert...');
|
||||
const result = await api.updateItemStatus(input, userId);
|
||||
logger.info({ name: 'meetings.item.status' }, 'Status geändert');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteProtocolItem = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
itemId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt wird gelöscht...');
|
||||
await api.deleteItem(input.itemId);
|
||||
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt gelöscht');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const reorderProtocolItems = authActionClient
|
||||
.inputSchema(ReorderItemsSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge wird aktualisiert...');
|
||||
await api.reorderItems(input);
|
||||
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge aktualisiert');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Attachments
|
||||
// =====================================================
|
||||
|
||||
export const addProtocolAttachment = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
protocolId: z.string().uuid(),
|
||||
fileName: z.string().min(1),
|
||||
filePath: z.string().min(1),
|
||||
fileSize: z.number().int().positive(),
|
||||
mimeType: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.attachment.add' }, 'Anhang wird hinzugefügt...');
|
||||
const result = await api.addAttachment(
|
||||
input.protocolId,
|
||||
input.fileName,
|
||||
input.filePath,
|
||||
input.fileSize,
|
||||
input.mimeType,
|
||||
userId,
|
||||
);
|
||||
logger.info({ name: 'meetings.attachment.add' }, 'Anhang hinzugefügt');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteProtocolAttachment = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
attachmentId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang wird gelöscht...');
|
||||
await api.deleteAttachment(input.attachmentId);
|
||||
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang gelöscht');
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
394
packages/features/sitzungsprotokolle/src/server/api.ts
Normal file
394
packages/features/sitzungsprotokolle/src/server/api.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type {
|
||||
CreateMeetingProtocolInput,
|
||||
UpdateMeetingProtocolInput,
|
||||
CreateProtocolItemInput,
|
||||
UpdateProtocolItemInput,
|
||||
UpdateItemStatusInput,
|
||||
ReorderItemsInput,
|
||||
} from '../schema/meetings.schema';
|
||||
|
||||
/**
|
||||
* Factory for the Sitzungsprotokolle (Meeting Protocols) API.
|
||||
*/
|
||||
export function createMeetingsApi(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
// =====================================================
|
||||
// Protocols
|
||||
// =====================================================
|
||||
|
||||
async listProtocols(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
search?: string;
|
||||
meetingType?: string;
|
||||
year?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('meeting_protocols')
|
||||
.select(
|
||||
'id, title, meeting_date, meeting_type, location, attendees, remarks, is_published, created_at, updated_at',
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.order('meeting_date', { ascending: false });
|
||||
|
||||
if (opts?.search) {
|
||||
query = query.or(
|
||||
`title.ilike.%${opts.search}%,location.ilike.%${opts.search}%,attendees.ilike.%${opts.search}%`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts?.meetingType) {
|
||||
query = query.eq('meeting_type', opts.meetingType);
|
||||
}
|
||||
|
||||
if (opts?.year) {
|
||||
query = query
|
||||
.gte('meeting_date', `${opts.year}-01-01`)
|
||||
.lte('meeting_date', `${opts.year}-12-31`);
|
||||
}
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async getProtocol(protocolId: string) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocols')
|
||||
.select('*')
|
||||
.eq('id', protocolId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createProtocol(input: CreateMeetingProtocolInput, userId: string) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocols')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
title: input.title,
|
||||
meeting_date: input.meetingDate,
|
||||
meeting_type: input.meetingType,
|
||||
location: input.location,
|
||||
attendees: input.attendees,
|
||||
remarks: input.remarks,
|
||||
is_published: input.isPublished,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateProtocol(input: UpdateMeetingProtocolInput, userId: string) {
|
||||
const updateData: Record<string, unknown> = { updated_by: userId };
|
||||
|
||||
if (input.title !== undefined) updateData.title = input.title;
|
||||
if (input.meetingDate !== undefined) updateData.meeting_date = input.meetingDate;
|
||||
if (input.meetingType !== undefined) updateData.meeting_type = input.meetingType;
|
||||
if (input.location !== undefined) updateData.location = input.location;
|
||||
if (input.attendees !== undefined) updateData.attendees = input.attendees;
|
||||
if (input.remarks !== undefined) updateData.remarks = input.remarks;
|
||||
if (input.isPublished !== undefined) updateData.is_published = input.isPublished;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocols')
|
||||
.update(updateData)
|
||||
.eq('id', input.protocolId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteProtocol(protocolId: string) {
|
||||
const { error } = await client
|
||||
.from('meeting_protocols')
|
||||
.delete()
|
||||
.eq('id', protocolId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Protocol Items (Tagesordnungspunkte)
|
||||
// =====================================================
|
||||
|
||||
async listItems(
|
||||
protocolId: string,
|
||||
opts?: { status?: string },
|
||||
) {
|
||||
let query = client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
'id, protocol_id, title, description, responsible_person, due_date, status, sort_order, created_at, updated_at',
|
||||
)
|
||||
.eq('protocol_id', protocolId)
|
||||
.order('sort_order')
|
||||
.order('created_at');
|
||||
|
||||
if (opts?.status) {
|
||||
query = query.eq('status', opts.status);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createItem(input: CreateProtocolItemInput, userId: string) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_items')
|
||||
.insert({
|
||||
protocol_id: input.protocolId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
responsible_person: input.responsiblePerson,
|
||||
due_date: input.dueDate,
|
||||
status: input.status,
|
||||
sort_order: input.sortOrder,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateItem(input: UpdateProtocolItemInput, userId: string) {
|
||||
const updateData: Record<string, unknown> = { updated_by: userId };
|
||||
|
||||
if (input.title !== undefined) updateData.title = input.title;
|
||||
if (input.description !== undefined) updateData.description = input.description;
|
||||
if (input.responsiblePerson !== undefined) updateData.responsible_person = input.responsiblePerson;
|
||||
if (input.dueDate !== undefined) updateData.due_date = input.dueDate;
|
||||
if (input.status !== undefined) updateData.status = input.status;
|
||||
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_items')
|
||||
.update(updateData)
|
||||
.eq('id', input.itemId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateItemStatus(input: UpdateItemStatusInput, userId: string) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_items')
|
||||
.update({
|
||||
status: input.status,
|
||||
updated_by: userId,
|
||||
})
|
||||
.eq('id', input.itemId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteItem(itemId: string) {
|
||||
const { error } = await client
|
||||
.from('meeting_protocol_items')
|
||||
.delete()
|
||||
.eq('id', itemId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async reorderItems(input: ReorderItemsInput) {
|
||||
// Update sort_order for each item based on position in the array
|
||||
const updates = input.itemIds.map((itemId, index) =>
|
||||
client
|
||||
.from('meeting_protocol_items')
|
||||
.update({ sort_order: index })
|
||||
.eq('id', itemId)
|
||||
.eq('protocol_id', input.protocolId),
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Open Tasks (cross-protocol)
|
||||
// =====================================================
|
||||
|
||||
async listOpenTasks(
|
||||
accountId: string,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 50;
|
||||
|
||||
const { data, error, count } = await client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
'id, protocol_id, title, description, responsible_person, due_date, status, sort_order, created_at, updated_at, meeting_protocols!inner ( id, title, meeting_date, meeting_type, account_id )',
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.eq('meeting_protocols.account_id', accountId)
|
||||
.in('status', ['offen', 'in_bearbeitung'])
|
||||
.order('due_date', { ascending: true, nullsFirst: false })
|
||||
.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Attachments
|
||||
// =====================================================
|
||||
|
||||
async listAttachments(protocolId: string) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_attachments')
|
||||
.select(
|
||||
'id, protocol_id, file_name, file_path, file_size, mime_type, created_at',
|
||||
)
|
||||
.eq('protocol_id', protocolId)
|
||||
.order('created_at');
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async addAttachment(
|
||||
protocolId: string,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
mimeType: string,
|
||||
userId: string,
|
||||
) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_attachments')
|
||||
.insert({
|
||||
protocol_id: protocolId,
|
||||
file_name: fileName,
|
||||
file_path: filePath,
|
||||
file_size: fileSize,
|
||||
mime_type: mimeType,
|
||||
created_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteAttachment(attachmentId: string) {
|
||||
const { error } = await client
|
||||
.from('meeting_protocol_attachments')
|
||||
.delete()
|
||||
.eq('id', attachmentId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Dashboard Stats
|
||||
// =====================================================
|
||||
|
||||
async getDashboardStats(accountId: string) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const [
|
||||
totalProtocolsResult,
|
||||
thisYearProtocolsResult,
|
||||
openTasksResult,
|
||||
overdueTasksResult,
|
||||
] = await Promise.all([
|
||||
// Total protocols
|
||||
client
|
||||
.from('meeting_protocols')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId),
|
||||
// Protocols this year
|
||||
client
|
||||
.from('meeting_protocols')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId)
|
||||
.gte('meeting_date', `${currentYear}-01-01`)
|
||||
.lte('meeting_date', `${currentYear}-12-31`),
|
||||
// Open tasks (offen + in_bearbeitung)
|
||||
client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
'id, meeting_protocols!inner ( account_id )',
|
||||
{ count: 'exact', head: true },
|
||||
)
|
||||
.eq('meeting_protocols.account_id', accountId)
|
||||
.in('status', ['offen', 'in_bearbeitung']),
|
||||
// Overdue tasks
|
||||
client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
'id, meeting_protocols!inner ( account_id )',
|
||||
{ count: 'exact', head: true },
|
||||
)
|
||||
.eq('meeting_protocols.account_id', accountId)
|
||||
.in('status', ['offen', 'in_bearbeitung'])
|
||||
.lt('due_date', new Date().toISOString().split('T')[0]!),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalProtocols: totalProtocolsResult.count ?? 0,
|
||||
thisYearProtocols: thisYearProtocolsResult.count ?? 0,
|
||||
openTasks: openTasksResult.count ?? 0,
|
||||
overdueTasks: overdueTasksResult.count ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Recent Protocols (for dashboard)
|
||||
// =====================================================
|
||||
|
||||
async getRecentProtocols(accountId: string, limit = 5) {
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocols')
|
||||
.select('id, title, meeting_date, meeting_type, is_published')
|
||||
.eq('account_id', accountId)
|
||||
.order('meeting_date', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Overdue Tasks (for dashboard)
|
||||
// =====================================================
|
||||
|
||||
async getOverdueTasks(accountId: string, limit = 5) {
|
||||
const today = new Date().toISOString().split('T')[0]!;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
'id, title, responsible_person, due_date, status, meeting_protocols!inner ( id, title, account_id )',
|
||||
)
|
||||
.eq('meeting_protocols.account_id', accountId)
|
||||
.in('status', ['offen', 'in_bearbeitung'])
|
||||
.lt('due_date', today)
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/sitzungsprotokolle/tsconfig.json
Normal file
6
packages/features/sitzungsprotokolle/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
40
packages/features/verbandsverwaltung/package.json
Normal file
40
packages/features/verbandsverwaltung/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@kit/verbandsverwaltung",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"recharts": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { Plus, Pencil, Trash2, Star } 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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { CreateClubContactSchema } from '../schema/verband.schema';
|
||||
import { CONTACT_ROLE_LABELS } from '../lib/verband-constants';
|
||||
import {
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
} from '../server/actions/verband-actions';
|
||||
|
||||
interface ClubContactsManagerProps {
|
||||
clubId: string;
|
||||
contacts: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateClubContactSchema),
|
||||
defaultValues: {
|
||||
clubId,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'sonstige' as const,
|
||||
phone: '',
|
||||
email: '',
|
||||
isPrimary: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(createContact, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kontakt erstellt');
|
||||
setShowForm(false);
|
||||
form.reset({ clubId, firstName: '', lastName: '', role: 'sonstige', phone: '', email: '', isPrimary: false });
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeUpdate, isPending: isUpdating } = useAction(updateContact, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kontakt aktualisiert');
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
form.reset({ clubId, firstName: '', lastName: '', role: 'sonstige', phone: '', email: '', isPrimary: false });
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeDelete } = useAction(deleteContact, {
|
||||
onSuccess: () => {
|
||||
toast.success('Kontakt gelöscht');
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (contact: Record<string, unknown>) => {
|
||||
setEditingId(String(contact.id));
|
||||
form.reset({
|
||||
clubId,
|
||||
firstName: String(contact.first_name ?? ''),
|
||||
lastName: String(contact.last_name ?? ''),
|
||||
role: (contact.role as 'sonstige') ?? 'sonstige',
|
||||
phone: String(contact.phone ?? ''),
|
||||
email: String(contact.email ?? ''),
|
||||
isPrimary: Boolean(contact.is_primary),
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (data: Record<string, unknown>) => {
|
||||
if (editingId) {
|
||||
executeUpdate({ contactId: editingId, ...data } as Parameters<typeof executeUpdate>[0]);
|
||||
} else {
|
||||
executeCreate(data as Parameters<typeof executeCreate>[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Ansprechpartner</CardTitle>
|
||||
{!showForm && (
|
||||
<Button size="sm" onClick={() => { setEditingId(null); setShowForm(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Kontakt hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showForm && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="mb-4 space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vorname *</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nachname *</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Funktion</FormLabel>
|
||||
<FormControl>
|
||||
<select
|
||||
{...field}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(CONTACT_ROLE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<FormControl><Input type="tel" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl><Input type="email" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(false); setEditingId(null); }}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || isUpdating}>
|
||||
{isCreating || isUpdating
|
||||
? 'Wird gespeichert...'
|
||||
: editingId
|
||||
? 'Aktualisieren'
|
||||
: 'Erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{contacts.length === 0 && !showForm ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Ansprechpartner vorhanden.</p>
|
||||
) : (
|
||||
<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">Funktion</th>
|
||||
<th className="p-3 text-left font-medium">Telefon</th>
|
||||
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contacts.map((contact) => (
|
||||
<tr key={String(contact.id)} className="border-b">
|
||||
<td className="p-3 font-medium">
|
||||
{String(contact.first_name)} {String(contact.last_name)}
|
||||
{contact.is_primary && (
|
||||
<Star className="ml-1 inline h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="secondary">
|
||||
{CONTACT_ROLE_LABELS[String(contact.role)] ?? String(contact.role)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{String(contact.phone ?? '—')}</td>
|
||||
<td className="p-3 text-muted-foreground">{String(contact.email ?? '—')}</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(contact)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ contactId: String(contact.id) })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { CheckCircle2, Euro, XCircle } 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 { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { BILLING_STATUS_LABELS, BILLING_STATUS_COLORS, PAYMENT_METHOD_LABELS } from '../lib/verband-constants';
|
||||
import { markBillingPaid, deleteFeeBilling } from '../server/actions/verband-actions';
|
||||
|
||||
interface ClubFeeBillingTableProps {
|
||||
billings: Array<Record<string, unknown>>;
|
||||
clubId: string;
|
||||
}
|
||||
|
||||
export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTableProps) {
|
||||
const [showPaid, setShowPaid] = useState(false);
|
||||
|
||||
const { execute: executeMarkPaid } = useAction(markBillingPaid, {
|
||||
onSuccess: () => {
|
||||
toast.success('Beitrag als bezahlt markiert');
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeDelete } = useAction(deleteFeeBilling, {
|
||||
onSuccess: () => {
|
||||
toast.success('Beitragsabrechnung gelöscht');
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
|
||||
const filteredBillings = showPaid
|
||||
? billings
|
||||
: billings.filter((b) => b.status !== 'bezahlt');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Euro className="h-4 w-4" />
|
||||
Beitragsabrechnungen
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant={showPaid ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowPaid(!showPaid)}
|
||||
>
|
||||
{showPaid ? 'Nur offene' : 'Alle anzeigen'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredBillings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Beitragsabrechnungen vorhanden.</p>
|
||||
) : (
|
||||
<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">Beitragsart</th>
|
||||
<th className="p-3 text-center font-medium">Jahr</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
<th className="p-3 text-left font-medium">Fällig</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-left font-medium">Zahlung</th>
|
||||
<th className="p-3 text-right font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBillings.map((billing) => {
|
||||
const feeTypeName = (billing.club_fee_types as Record<string, unknown> | null)?.name;
|
||||
const status = String(billing.status ?? 'offen');
|
||||
|
||||
return (
|
||||
<tr key={String(billing.id)} className="border-b">
|
||||
<td className="p-3 font-medium">
|
||||
{feeTypeName ? String(feeTypeName) : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">{String(billing.year)}</td>
|
||||
<td className="p-3 text-right">
|
||||
{Number(billing.amount).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{billing.due_date
|
||||
? new Date(String(billing.due_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={BILLING_STATUS_COLORS[status] as 'default' | 'secondary' | 'destructive' | 'outline' ?? 'outline'}>
|
||||
{BILLING_STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{billing.payment_method
|
||||
? PAYMENT_METHOD_LABELS[String(billing.payment_method)] ?? String(billing.payment_method)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{status !== 'bezahlt' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeMarkPaid({ billingId: String(billing.id) })}
|
||||
title="Als bezahlt markieren"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ billingId: String(billing.id) })}
|
||||
title="Löschen"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { CheckCircle2, Circle, Trash2, StickyNote, ListTodo, Bell } 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 { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { NOTE_TYPE_LABELS } from '../lib/verband-constants';
|
||||
import { completeClubNote, deleteClubNote } from '../server/actions/verband-actions';
|
||||
|
||||
interface ClubNotesListProps {
|
||||
notes: Array<Record<string, unknown>>;
|
||||
clubId: string;
|
||||
}
|
||||
|
||||
const NOTE_ICONS: Record<string, React.ReactNode> = {
|
||||
notiz: <StickyNote className="h-4 w-4" />,
|
||||
aufgabe: <ListTodo className="h-4 w-4" />,
|
||||
erinnerung: <Bell className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
const { execute: executeComplete } = useAction(completeClubNote, {
|
||||
onSuccess: () => {
|
||||
toast.success('Aufgabe erledigt');
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeDelete } = useAction(deleteClubNote, {
|
||||
onSuccess: () => {
|
||||
toast.success('Notiz gelöscht');
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
|
||||
const openNotes = notes.filter((n) => !n.is_completed);
|
||||
const completedNotes = notes.filter((n) => n.is_completed);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
Notizen & Aufgaben ({openNotes.length} offen)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Notizen vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{openNotes.map((note) => {
|
||||
const noteType = String(note.note_type ?? 'notiz');
|
||||
return (
|
||||
<div
|
||||
key={String(note.id)}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-0.5 h-auto p-0"
|
||||
onClick={() => executeComplete({ noteId: String(note.id) })}
|
||||
title="Als erledigt markieren"
|
||||
>
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{String(note.title)}</span>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
{NOTE_ICONS[noteType]}
|
||||
{NOTE_TYPE_LABELS[noteType] ?? noteType}
|
||||
</Badge>
|
||||
{note.due_date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Fällig: {new Date(String(note.due_date)).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{note.content && (
|
||||
<p className="text-sm text-muted-foreground">{String(note.content)}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ noteId: String(note.id) })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{completedNotes.length > 0 && (
|
||||
<>
|
||||
<div className="pt-2 text-sm font-medium text-muted-foreground">
|
||||
Erledigt ({completedNotes.length})
|
||||
</div>
|
||||
{completedNotes.map((note) => (
|
||||
<div
|
||||
key={String(note.id)}
|
||||
className="flex items-start gap-3 rounded-lg border p-3 opacity-60"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" />
|
||||
<div className="flex-1">
|
||||
<span className="font-medium line-through">{String(note.title)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ noteId: String(note.id) })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'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 ClubsDataTableProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
types: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export function ClubsDataTable({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
types,
|
||||
}: ClubsDataTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentSearch = searchParams.get('q') ?? '';
|
||||
const currentType = searchParams.get('type') ?? '';
|
||||
const showArchived = searchParams.get('archived') === '1';
|
||||
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 handleArchivedToggle = useCallback(() => {
|
||||
updateParams({ archived: showArchived ? '' : '1' });
|
||||
}, [showArchived, 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="Verein 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"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant={showArchived ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleArchivedToggle}
|
||||
>
|
||||
{showArchived ? 'Archivierte ausblenden' : 'Archivierte anzeigen'}
|
||||
</Button>
|
||||
|
||||
<Link href={`/home/${account}/verband/clubs/new`}>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neuer Verein
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vereine ({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 Vereine vorhanden</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Erstellen Sie Ihren ersten Verein, um loszulegen.
|
||||
</p>
|
||||
<Link href={`/home/${account}/verband/clubs/new`} className="mt-4">
|
||||
<Button>Neuer Verein</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">Typ</th>
|
||||
<th className="p-3 text-right font-medium">Mitglieder</th>
|
||||
<th className="p-3 text-left font-medium">Ort</th>
|
||||
<th className="p-3 text-left font-medium">Kontakt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((club) => {
|
||||
const typeName = (club.association_types as Record<string, unknown> | null)?.name;
|
||||
return (
|
||||
<tr
|
||||
key={String(club.id)}
|
||||
className="cursor-pointer border-b hover:bg-muted/30"
|
||||
onClick={() =>
|
||||
router.push(`/home/${account}/verband/clubs/${String(club.id)}`)
|
||||
}
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
href={`/home/${account}/verband/clubs/${String(club.id)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(club.name)}
|
||||
</Link>
|
||||
{club.is_archived && (
|
||||
<Badge variant="secondary" className="ml-2">Archiviert</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{typeName ? (
|
||||
<Badge variant="secondary">{String(typeName)}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{club.member_count != null ? Number(club.member_count).toLocaleString('de-DE') : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{club.city ? `${String(club.zip ?? '')} ${String(club.city)}`.trim() : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{String(club.email ?? club.phone ?? '—')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
'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 { CreateMemberClubSchema } from '../schema/verband.schema';
|
||||
import { createClub } from '../server/actions/verband-actions';
|
||||
|
||||
interface CreateClubFormProps {
|
||||
accountId: string;
|
||||
account: string;
|
||||
types: Array<{ id: string; name: string }>;
|
||||
club?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function CreateClubForm({
|
||||
accountId,
|
||||
account,
|
||||
types,
|
||||
club,
|
||||
}: CreateClubFormProps) {
|
||||
const router = useRouter();
|
||||
const isEdit = !!club;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CreateMemberClubSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
name: (club?.name as string) ?? '',
|
||||
shortName: (club?.short_name as string) ?? '',
|
||||
associationTypeId: (club?.association_type_id as string) ?? undefined,
|
||||
memberCount: club?.member_count != null ? Number(club.member_count) : undefined,
|
||||
foundedYear: club?.founded_year != null ? Number(club.founded_year) : undefined,
|
||||
street: (club?.street as string) ?? '',
|
||||
zip: (club?.zip as string) ?? '',
|
||||
city: (club?.city as string) ?? '',
|
||||
phone: (club?.phone as string) ?? '',
|
||||
email: (club?.email as string) ?? '',
|
||||
website: (club?.website as string) ?? '',
|
||||
iban: (club?.iban as string) ?? '',
|
||||
bic: (club?.bic as string) ?? '',
|
||||
accountHolder: (club?.account_holder as string) ?? '',
|
||||
isArchived: (club?.is_archived as boolean) ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(createClub, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success(isEdit ? 'Verein aktualisiert' : 'Verein erstellt');
|
||||
router.push(`/home/${account}/verband/clubs`);
|
||||
}
|
||||
},
|
||||
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="associationTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vereinstyp</FormLabel>
|
||||
<FormControl>
|
||||
<select
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value || undefined)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Kein Typ —</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="foundedYear"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gründungsjahr</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1800}
|
||||
max={2100}
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mitgliederanzahl</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>
|
||||
|
||||
{/* Card 2: Adresse */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Adresse</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="street"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Straße</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PLZ</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ort</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="tel" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="url" placeholder="https://" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Bankdaten */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bankdaten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accountHolder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Kontoinhaber</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="iban"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IBAN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>BIC</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</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
|
||||
? 'Verein aktualisieren'
|
||||
: 'Verein erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { VerbandTabNavigation } from './verband-tab-navigation';
|
||||
export { VerbandDashboard } from './verband-dashboard';
|
||||
export { ClubsDataTable } from './clubs-data-table';
|
||||
export { CreateClubForm } from './create-club-form';
|
||||
export { ClubContactsManager } from './club-contacts-manager';
|
||||
export { ClubFeeBillingTable } from './club-fee-billing-table';
|
||||
export { ClubNotesList } from './club-notes-list';
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Euro,
|
||||
StickyNote,
|
||||
Archive,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface DashboardStats {
|
||||
activeClubsCount: number;
|
||||
archivedClubsCount: number;
|
||||
typesCount: number;
|
||||
unpaidBillingsCount: number;
|
||||
unpaidAmount: number;
|
||||
openNotesCount: number;
|
||||
totalMembers: number;
|
||||
clubsWithoutContact: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface VerbandDashboardProps {
|
||||
stats: DashboardStats;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Verbandsverwaltung – Übersicht</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vereine, Beiträge, Kontakte und Aufgaben 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}/verband/clubs`}>
|
||||
<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 Vereine</p>
|
||||
<p className="text-2xl font-bold">{stats.activeClubsCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<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">Gesamtmitglieder</p>
|
||||
<p className="text-2xl font-bold">{stats.totalMembers.toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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 Beiträge</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{stats.unpaidAmount.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.unpaidBillingsCount} Rechnungen</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<Euro className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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 Aufgaben</p>
|
||||
<p className="text-2xl font-bold">{stats.openNotesCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<StickyNote className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link href={`/home/${account}/verband/settings`}>
|
||||
<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">Vereinstypen</p>
|
||||
<p className="text-2xl font-bold">{stats.typesCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<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">Archivierte Vereine</p>
|
||||
<p className="text-2xl font-bold">{stats.archivedClubsCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<Archive className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Clubs without contact */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
Vereine ohne Ansprechpartner
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.clubsWithoutContact.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Alle Vereine haben mindestens einen Ansprechpartner.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stats.clubsWithoutContact.map((club) => (
|
||||
<div key={club.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm font-medium">{club.name}</span>
|
||||
<Link
|
||||
href={`/home/${account}/verband/clubs/${club.id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Kontakt hinzufügen
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface VerbandTabNavigationProps {
|
||||
account: string;
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Übersicht', path: '' },
|
||||
{ id: 'clubs', label: 'Vereine', path: '/clubs' },
|
||||
{ id: 'statistics', label: 'Statistik', path: '/statistics' },
|
||||
{ id: 'settings', label: 'Einstellungen', path: '/settings' },
|
||||
] as const;
|
||||
|
||||
export function VerbandTabNavigation({
|
||||
account,
|
||||
activeTab,
|
||||
}: VerbandTabNavigationProps) {
|
||||
const basePath = `/home/${account}/verband`;
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Verbandsverwaltung 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* German label mappings for all Verbandsverwaltung enums and status colors.
|
||||
*/
|
||||
|
||||
// =====================================================
|
||||
// Association Type Labels
|
||||
// =====================================================
|
||||
|
||||
export const ASSOCIATION_TYPE_LABELS: Record<string, string> = {
|
||||
sportverein: 'Sportverein',
|
||||
fischereiverein: 'Fischereiverein',
|
||||
schuetzenverein: 'Schützenverein',
|
||||
musikverein: 'Musikverein',
|
||||
kulturverein: 'Kulturverein',
|
||||
foerderverein: 'Förderverein',
|
||||
jugendverein: 'Jugendverein',
|
||||
sonstige: 'Sonstige',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Payment Method Labels
|
||||
// =====================================================
|
||||
|
||||
export const PAYMENT_METHOD_LABELS: Record<string, string> = {
|
||||
bar: 'Bar',
|
||||
lastschrift: 'Lastschrift',
|
||||
ueberweisung: 'Überweisung',
|
||||
paypal: 'PayPal',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Billing Status Labels
|
||||
// =====================================================
|
||||
|
||||
export const BILLING_STATUS_LABELS: Record<string, string> = {
|
||||
offen: 'Offen',
|
||||
bezahlt: 'Bezahlt',
|
||||
ueberfaellig: 'Überfällig',
|
||||
storniert: 'Storniert',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Billing Status Colors (Badge variants)
|
||||
// =====================================================
|
||||
|
||||
export const BILLING_STATUS_COLORS: Record<string, string> = {
|
||||
offen: 'outline',
|
||||
bezahlt: 'default',
|
||||
ueberfaellig: 'destructive',
|
||||
storniert: 'secondary',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Note Type Labels
|
||||
// =====================================================
|
||||
|
||||
export const NOTE_TYPE_LABELS: Record<string, string> = {
|
||||
notiz: 'Notiz',
|
||||
aufgabe: 'Aufgabe',
|
||||
erinnerung: 'Erinnerung',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// Contact Role Labels
|
||||
// =====================================================
|
||||
|
||||
export const CONTACT_ROLE_LABELS: Record<string, string> = {
|
||||
vorsitzender: 'Vorsitzender',
|
||||
stellvertreter: 'Stellvertreter',
|
||||
kassier: 'Kassier',
|
||||
schriftfuehrer: 'Schriftführer',
|
||||
jugendwart: 'Jugendwart',
|
||||
sportwart: 'Sportwart',
|
||||
beisitzer: 'Beisitzer',
|
||||
sonstige: 'Sonstige',
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// =====================================================
|
||||
// Enum Schemas
|
||||
// =====================================================
|
||||
|
||||
export const associationTypeSchema = z.enum([
|
||||
'sportverein',
|
||||
'fischereiverein',
|
||||
'schuetzenverein',
|
||||
'musikverein',
|
||||
'kulturverein',
|
||||
'foerderverein',
|
||||
'jugendverein',
|
||||
'sonstige',
|
||||
]);
|
||||
|
||||
export const paymentMethodSchema = z.enum([
|
||||
'bar',
|
||||
'lastschrift',
|
||||
'ueberweisung',
|
||||
'paypal',
|
||||
]);
|
||||
|
||||
export const billingStatusSchema = z.enum([
|
||||
'offen',
|
||||
'bezahlt',
|
||||
'ueberfaellig',
|
||||
'storniert',
|
||||
]);
|
||||
|
||||
export const noteTypeSchema = z.enum([
|
||||
'notiz',
|
||||
'aufgabe',
|
||||
'erinnerung',
|
||||
]);
|
||||
|
||||
export const contactRoleSchema = z.enum([
|
||||
'vorsitzender',
|
||||
'stellvertreter',
|
||||
'kassier',
|
||||
'schriftfuehrer',
|
||||
'jugendwart',
|
||||
'sportwart',
|
||||
'beisitzer',
|
||||
'sonstige',
|
||||
]);
|
||||
|
||||
// =====================================================
|
||||
// Type Exports
|
||||
// =====================================================
|
||||
|
||||
export type AssociationType = z.infer<typeof associationTypeSchema>;
|
||||
export type PaymentMethod = z.infer<typeof paymentMethodSchema>;
|
||||
export type BillingStatus = z.infer<typeof billingStatusSchema>;
|
||||
export type NoteType = z.infer<typeof noteTypeSchema>;
|
||||
export type ContactRole = z.infer<typeof contactRoleSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Member Clubs (Vereine)
|
||||
// =====================================================
|
||||
|
||||
export const CreateMemberClubSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1, 'Name ist erforderlich').max(256),
|
||||
shortName: z.string().max(32).optional(),
|
||||
associationTypeId: z.string().uuid().optional(),
|
||||
memberCount: z.number().int().min(0).optional(),
|
||||
foundedYear: z.number().int().min(1800).max(2100).optional(),
|
||||
street: z.string().max(256).optional(),
|
||||
zip: z.string().max(10).optional(),
|
||||
city: z.string().max(128).optional(),
|
||||
phone: z.string().max(64).optional(),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
website: z.string().url().optional().or(z.literal('')),
|
||||
iban: z.string().max(34).optional(),
|
||||
bic: z.string().max(11).optional(),
|
||||
accountHolder: z.string().max(256).optional(),
|
||||
isArchived: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateMemberClubInput = z.infer<typeof CreateMemberClubSchema>;
|
||||
|
||||
export const UpdateMemberClubSchema = CreateMemberClubSchema.partial().extend({
|
||||
clubId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type UpdateMemberClubInput = z.infer<typeof UpdateMemberClubSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Club Contacts (Ansprechpartner)
|
||||
// =====================================================
|
||||
|
||||
export const CreateClubContactSchema = z.object({
|
||||
clubId: z.string().uuid(),
|
||||
firstName: z.string().min(1, 'Vorname ist erforderlich').max(128),
|
||||
lastName: z.string().min(1, 'Nachname ist erforderlich').max(128),
|
||||
role: contactRoleSchema.default('sonstige'),
|
||||
phone: z.string().max(64).optional(),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
isPrimary: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateClubContactInput = z.infer<typeof CreateClubContactSchema>;
|
||||
|
||||
export const UpdateClubContactSchema = CreateClubContactSchema.partial().extend({
|
||||
contactId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type UpdateClubContactInput = z.infer<typeof UpdateClubContactSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Club Roles (Funktionen im Verband)
|
||||
// =====================================================
|
||||
|
||||
export const CreateClubRoleSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1, 'Name ist erforderlich').max(128),
|
||||
description: z.string().max(512).optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type CreateClubRoleInput = z.infer<typeof CreateClubRoleSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Association Types (Vereinstypen)
|
||||
// =====================================================
|
||||
|
||||
export const CreateAssociationTypeSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1, 'Name ist erforderlich').max(128),
|
||||
description: z.string().max(512).optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type CreateAssociationTypeInput = z.infer<typeof CreateAssociationTypeSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Club Fee Types (Beitragsarten)
|
||||
// =====================================================
|
||||
|
||||
export const CreateClubFeeTypeSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1, 'Name ist erforderlich').max(128),
|
||||
description: z.string().max(512).optional(),
|
||||
defaultAmount: z.number().min(0).optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type CreateClubFeeTypeInput = z.infer<typeof CreateClubFeeTypeSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Club Fee Billings (Beitragsabrechnungen)
|
||||
// =====================================================
|
||||
|
||||
export const CreateClubFeeBillingSchema = z.object({
|
||||
clubId: z.string().uuid(),
|
||||
feeTypeId: z.string().uuid(),
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
amount: z.number().min(0),
|
||||
dueDate: z.string().optional(),
|
||||
paidDate: z.string().optional(),
|
||||
paymentMethod: paymentMethodSchema.optional(),
|
||||
status: billingStatusSchema.default('offen'),
|
||||
notes: z.string().max(1024).optional(),
|
||||
});
|
||||
|
||||
export type CreateClubFeeBillingInput = z.infer<typeof CreateClubFeeBillingSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Club Notes (Notizen / Aufgaben)
|
||||
// =====================================================
|
||||
|
||||
export const CreateClubNoteSchema = z.object({
|
||||
clubId: z.string().uuid(),
|
||||
title: z.string().min(1, 'Titel ist erforderlich').max(256),
|
||||
content: z.string().optional(),
|
||||
noteType: noteTypeSchema.default('notiz'),
|
||||
dueDate: z.string().optional(),
|
||||
isCompleted: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateClubNoteInput = z.infer<typeof CreateClubNoteSchema>;
|
||||
|
||||
// =====================================================
|
||||
// Association History (Verbandshistorie)
|
||||
// =====================================================
|
||||
|
||||
export const CreateAssociationHistorySchema = z.object({
|
||||
clubId: z.string().uuid(),
|
||||
year: z.number().int().min(1800).max(2100),
|
||||
memberCount: z.number().int().min(0).optional(),
|
||||
notes: z.string().max(2048).optional(),
|
||||
});
|
||||
|
||||
export type CreateAssociationHistoryInput = z.infer<typeof CreateAssociationHistorySchema>;
|
||||
@@ -0,0 +1,472 @@
|
||||
'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 {
|
||||
CreateMemberClubSchema,
|
||||
UpdateMemberClubSchema,
|
||||
CreateClubContactSchema,
|
||||
UpdateClubContactSchema,
|
||||
CreateClubRoleSchema,
|
||||
CreateAssociationTypeSchema,
|
||||
CreateClubFeeTypeSchema,
|
||||
CreateClubFeeBillingSchema,
|
||||
CreateClubNoteSchema,
|
||||
CreateAssociationHistorySchema,
|
||||
} from '../../schema/verband.schema';
|
||||
|
||||
import { createVerbandApi } from '../api';
|
||||
|
||||
const REVALIDATE_PATH = '/home/[account]/verband';
|
||||
|
||||
// =====================================================
|
||||
// Clubs (Vereine)
|
||||
// =====================================================
|
||||
|
||||
export const createClub = authActionClient
|
||||
.inputSchema(CreateMemberClubSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'verband.club.create' }, 'Creating club...');
|
||||
const result = await api.createClub(input, userId);
|
||||
logger.info({ name: 'verband.club.create' }, 'Club created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateClub = authActionClient
|
||||
.inputSchema(UpdateMemberClubSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'verband.club.update' }, 'Updating club...');
|
||||
const result = await api.updateClub(input, userId);
|
||||
logger.info({ name: 'verband.club.update' }, 'Club updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const archiveClub = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
clubId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.club.archive' }, 'Archiving club...');
|
||||
await api.archiveClub(input.clubId);
|
||||
logger.info({ name: 'verband.club.archive' }, 'Club archived');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Contacts (Ansprechpartner)
|
||||
// =====================================================
|
||||
|
||||
export const createContact = authActionClient
|
||||
.inputSchema(CreateClubContactSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.contact.create' }, 'Creating contact...');
|
||||
const result = await api.createContact(input);
|
||||
logger.info({ name: 'verband.contact.create' }, 'Contact created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateContact = authActionClient
|
||||
.inputSchema(UpdateClubContactSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.contact.update' }, 'Updating contact...');
|
||||
const result = await api.updateContact(input);
|
||||
logger.info({ name: 'verband.contact.update' }, 'Contact updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteContact = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
contactId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.contact.delete' }, 'Deleting contact...');
|
||||
await api.deleteContact(input.contactId);
|
||||
logger.info({ name: 'verband.contact.delete' }, 'Contact deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Roles (Funktionen)
|
||||
// =====================================================
|
||||
|
||||
export const createRole = authActionClient
|
||||
.inputSchema(CreateClubRoleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.role.create' }, 'Creating role...');
|
||||
const result = await api.createRole(input);
|
||||
logger.info({ name: 'verband.role.create' }, 'Role created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateRole = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
roleId: z.string().uuid(),
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
description: z.string().max(512).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
const { roleId, ...updates } = input;
|
||||
logger.info({ name: 'verband.role.update' }, 'Updating role...');
|
||||
const result = await api.updateRole(roleId, updates);
|
||||
logger.info({ name: 'verband.role.update' }, 'Role updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteRole = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
roleId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.role.delete' }, 'Deleting role...');
|
||||
await api.deleteRole(input.roleId);
|
||||
logger.info({ name: 'verband.role.delete' }, 'Role deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Association Types (Vereinstypen)
|
||||
// =====================================================
|
||||
|
||||
export const createAssociationType = authActionClient
|
||||
.inputSchema(CreateAssociationTypeSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.type.create' }, 'Creating association type...');
|
||||
const result = await api.createType(input);
|
||||
logger.info({ name: 'verband.type.create' }, 'Association type created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateAssociationType = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
typeId: z.string().uuid(),
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
description: z.string().max(512).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
const { typeId, ...updates } = input;
|
||||
logger.info({ name: 'verband.type.update' }, 'Updating association type...');
|
||||
const result = await api.updateType(typeId, updates);
|
||||
logger.info({ name: 'verband.type.update' }, 'Association type updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteAssociationType = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
typeId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.type.delete' }, 'Deleting association type...');
|
||||
await api.deleteType(input.typeId);
|
||||
logger.info({ name: 'verband.type.delete' }, 'Association type deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Fee Types (Beitragsarten)
|
||||
// =====================================================
|
||||
|
||||
export const createFeeType = authActionClient
|
||||
.inputSchema(CreateClubFeeTypeSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.feeType.create' }, 'Creating fee type...');
|
||||
const result = await api.createFeeType(input);
|
||||
logger.info({ name: 'verband.feeType.create' }, 'Fee type created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateFeeType = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
feeTypeId: z.string().uuid(),
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
description: z.string().max(512).optional(),
|
||||
defaultAmount: z.number().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
const { feeTypeId, ...updates } = input;
|
||||
logger.info({ name: 'verband.feeType.update' }, 'Updating fee type...');
|
||||
const result = await api.updateFeeType(feeTypeId, updates);
|
||||
logger.info({ name: 'verband.feeType.update' }, 'Fee type updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteFeeType = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
feeTypeId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.feeType.delete' }, 'Deleting fee type...');
|
||||
await api.deleteFeeType(input.feeTypeId);
|
||||
logger.info({ name: 'verband.feeType.delete' }, 'Fee type deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Fee Billings (Beitragsabrechnungen)
|
||||
// =====================================================
|
||||
|
||||
export const createFeeBilling = authActionClient
|
||||
.inputSchema(CreateClubFeeBillingSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.billing.create' }, 'Creating fee billing...');
|
||||
const result = await api.createFeeBilling(input);
|
||||
logger.info({ name: 'verband.billing.create' }, 'Fee billing created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateFeeBilling = authActionClient
|
||||
.inputSchema(
|
||||
CreateClubFeeBillingSchema.partial().extend({
|
||||
billingId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
const { billingId, ...updates } = input;
|
||||
logger.info({ name: 'verband.billing.update' }, 'Updating fee billing...');
|
||||
const result = await api.updateFeeBilling(billingId, updates);
|
||||
logger.info({ name: 'verband.billing.update' }, 'Fee billing updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteFeeBilling = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
billingId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.billing.delete' }, 'Deleting fee billing...');
|
||||
await api.deleteFeeBilling(input.billingId);
|
||||
logger.info({ name: 'verband.billing.delete' }, 'Fee billing deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const markBillingPaid = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
billingId: z.string().uuid(),
|
||||
paidDate: z.string().optional(),
|
||||
paymentMethod: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.billing.markPaid' }, 'Marking billing as paid...');
|
||||
const result = await api.updateFeeBilling(input.billingId, {
|
||||
status: 'bezahlt',
|
||||
paidDate: input.paidDate ?? new Date().toISOString().split('T')[0],
|
||||
paymentMethod: input.paymentMethod as 'bar' | 'lastschrift' | 'ueberweisung' | 'paypal' | undefined,
|
||||
});
|
||||
logger.info({ name: 'verband.billing.markPaid' }, 'Billing marked as paid');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Notes (Notizen / Aufgaben)
|
||||
// =====================================================
|
||||
|
||||
export const createClubNote = authActionClient
|
||||
.inputSchema(CreateClubNoteSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.note.create' }, 'Creating note...');
|
||||
const result = await api.createNote(input);
|
||||
logger.info({ name: 'verband.note.create' }, 'Note created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateClubNote = authActionClient
|
||||
.inputSchema(
|
||||
CreateClubNoteSchema.partial().extend({
|
||||
noteId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
const { noteId, ...updates } = input;
|
||||
logger.info({ name: 'verband.note.update' }, 'Updating note...');
|
||||
const result = await api.updateNote(noteId, updates);
|
||||
logger.info({ name: 'verband.note.update' }, 'Note updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const completeClubNote = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
noteId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.note.complete' }, 'Completing note...');
|
||||
const result = await api.completeNote(input.noteId);
|
||||
logger.info({ name: 'verband.note.complete' }, 'Note completed');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteClubNote = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
noteId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.note.delete' }, 'Deleting note...');
|
||||
await api.deleteNote(input.noteId);
|
||||
logger.info({ name: 'verband.note.delete' }, 'Note deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// Association History (Verbandshistorie)
|
||||
// =====================================================
|
||||
|
||||
export const upsertAssociationHistory = authActionClient
|
||||
.inputSchema(CreateAssociationHistorySchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.history.upsert' }, 'Upserting association history...');
|
||||
const result = await api.upsertHistory(input);
|
||||
logger.info({ name: 'verband.history.upsert' }, 'Association history upserted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
682
packages/features/verbandsverwaltung/src/server/api.ts
Normal file
682
packages/features/verbandsverwaltung/src/server/api.ts
Normal file
@@ -0,0 +1,682 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type {
|
||||
CreateMemberClubInput,
|
||||
UpdateMemberClubInput,
|
||||
CreateClubContactInput,
|
||||
UpdateClubContactInput,
|
||||
CreateClubRoleInput,
|
||||
CreateAssociationTypeInput,
|
||||
CreateClubFeeTypeInput,
|
||||
CreateClubFeeBillingInput,
|
||||
CreateClubNoteInput,
|
||||
CreateAssociationHistoryInput,
|
||||
} from '../schema/verband.schema';
|
||||
|
||||
/**
|
||||
* Factory for the Verbandsverwaltung (Association Management) API.
|
||||
*/
|
||||
export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
// =====================================================
|
||||
// Clubs (Vereine)
|
||||
// =====================================================
|
||||
|
||||
async listClubs(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
search?: string;
|
||||
typeId?: string;
|
||||
archived?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('member_clubs')
|
||||
.select(
|
||||
'id, name, short_name, association_type_id, member_count, founded_year, street, zip, city, phone, email, website, iban, bic, account_holder, is_archived, created_at, updated_at, association_types ( id, name )',
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.order('name');
|
||||
|
||||
if (opts?.search) {
|
||||
query = query.or(
|
||||
`name.ilike.%${opts.search}%,short_name.ilike.%${opts.search}%,city.ilike.%${opts.search}%`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts?.typeId) {
|
||||
query = query.eq('association_type_id', opts.typeId);
|
||||
}
|
||||
|
||||
if (opts?.archived !== undefined) {
|
||||
query = query.eq('is_archived', opts.archived);
|
||||
} else {
|
||||
query = query.eq('is_archived', false);
|
||||
}
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async getClub(clubId: string) {
|
||||
const { data, error } = await client
|
||||
.from('member_clubs')
|
||||
.select('*, association_types ( id, name )')
|
||||
.eq('id', clubId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createClub(input: CreateMemberClubInput, userId: string) {
|
||||
const { data, error } = await client
|
||||
.from('member_clubs')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
short_name: input.shortName,
|
||||
association_type_id: input.associationTypeId,
|
||||
member_count: input.memberCount,
|
||||
founded_year: input.foundedYear,
|
||||
street: input.street,
|
||||
zip: input.zip,
|
||||
city: input.city,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
website: input.website,
|
||||
iban: input.iban,
|
||||
bic: input.bic,
|
||||
account_holder: input.accountHolder,
|
||||
is_archived: input.isArchived,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateClub(input: UpdateMemberClubInput, userId: string) {
|
||||
const updateData: Record<string, unknown> = { updated_by: userId };
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.shortName !== undefined) updateData.short_name = input.shortName;
|
||||
if (input.associationTypeId !== undefined) updateData.association_type_id = input.associationTypeId;
|
||||
if (input.memberCount !== undefined) updateData.member_count = input.memberCount;
|
||||
if (input.foundedYear !== undefined) updateData.founded_year = input.foundedYear;
|
||||
if (input.street !== undefined) updateData.street = input.street;
|
||||
if (input.zip !== undefined) updateData.zip = input.zip;
|
||||
if (input.city !== undefined) updateData.city = input.city;
|
||||
if (input.phone !== undefined) updateData.phone = input.phone;
|
||||
if (input.email !== undefined) updateData.email = input.email;
|
||||
if (input.website !== undefined) updateData.website = input.website;
|
||||
if (input.iban !== undefined) updateData.iban = input.iban;
|
||||
if (input.bic !== undefined) updateData.bic = input.bic;
|
||||
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
|
||||
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('member_clubs')
|
||||
.update(updateData)
|
||||
.eq('id', input.clubId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async archiveClub(clubId: string) {
|
||||
const { error } = await client
|
||||
.from('member_clubs')
|
||||
.update({ is_archived: true })
|
||||
.eq('id', clubId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async getClubDetail(clubId: string) {
|
||||
const [clubResult, contactsResult, billingsResult, notesResult, historyResult] =
|
||||
await Promise.all([
|
||||
client.from('member_clubs').select('*, association_types ( id, name )').eq('id', clubId).single(),
|
||||
client
|
||||
.from('club_contacts')
|
||||
.select('id, club_id, first_name, last_name, role, phone, email, is_primary, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('last_name'),
|
||||
client
|
||||
.from('club_fee_billings')
|
||||
.select('id, club_id, fee_type_id, year, amount, due_date, paid_date, payment_method, status, notes, created_at, club_fee_types ( id, name )')
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false })
|
||||
.limit(50),
|
||||
client
|
||||
.from('club_notes')
|
||||
.select('id, club_id, title, content, note_type, due_date, is_completed, created_at, updated_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50),
|
||||
client
|
||||
.from('association_history')
|
||||
.select('id, club_id, year, member_count, notes, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false }),
|
||||
]);
|
||||
|
||||
if (clubResult.error) throw clubResult.error;
|
||||
|
||||
return {
|
||||
club: clubResult.data,
|
||||
contacts: contactsResult.data ?? [],
|
||||
billings: billingsResult.data ?? [],
|
||||
notes: notesResult.data ?? [],
|
||||
history: historyResult.data ?? [],
|
||||
};
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Contacts (Ansprechpartner)
|
||||
// =====================================================
|
||||
|
||||
async listContacts(clubId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_contacts')
|
||||
.select('id, club_id, first_name, last_name, role, phone, email, is_primary, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('last_name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createContact(input: CreateClubContactInput) {
|
||||
const { data, error } = await client
|
||||
.from('club_contacts')
|
||||
.insert({
|
||||
club_id: input.clubId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
role: input.role,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
is_primary: input.isPrimary,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateContact(input: UpdateClubContactInput) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (input.firstName !== undefined) updateData.first_name = input.firstName;
|
||||
if (input.lastName !== undefined) updateData.last_name = input.lastName;
|
||||
if (input.role !== undefined) updateData.role = input.role;
|
||||
if (input.phone !== undefined) updateData.phone = input.phone;
|
||||
if (input.email !== undefined) updateData.email = input.email;
|
||||
if (input.isPrimary !== undefined) updateData.is_primary = input.isPrimary;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_contacts')
|
||||
.update(updateData)
|
||||
.eq('id', input.contactId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteContact(contactId: string) {
|
||||
const { error } = await client
|
||||
.from('club_contacts')
|
||||
.delete()
|
||||
.eq('id', contactId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Roles (Funktionen)
|
||||
// =====================================================
|
||||
|
||||
async listRoles(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_roles')
|
||||
.select('id, name, description, sort_order, created_at')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order')
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createRole(input: CreateClubRoleInput) {
|
||||
const { data, error } = await client
|
||||
.from('club_roles')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
sort_order: input.sortOrder,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRole(roleId: string, updates: { name?: string; description?: string; sortOrder?: number }) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
if (updates.sortOrder !== undefined) updateData.sort_order = updates.sortOrder;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_roles')
|
||||
.update(updateData)
|
||||
.eq('id', roleId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteRole(roleId: string) {
|
||||
const { error } = await client
|
||||
.from('club_roles')
|
||||
.delete()
|
||||
.eq('id', roleId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Association Types (Vereinstypen)
|
||||
// =====================================================
|
||||
|
||||
async listTypes(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('association_types')
|
||||
.select('id, name, description, sort_order, created_at')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order')
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createType(input: CreateAssociationTypeInput) {
|
||||
const { data, error } = await client
|
||||
.from('association_types')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
sort_order: input.sortOrder,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateType(typeId: string, updates: { name?: string; description?: string; sortOrder?: number }) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
if (updates.sortOrder !== undefined) updateData.sort_order = updates.sortOrder;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('association_types')
|
||||
.update(updateData)
|
||||
.eq('id', typeId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteType(typeId: string) {
|
||||
const { error } = await client
|
||||
.from('association_types')
|
||||
.delete()
|
||||
.eq('id', typeId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Fee Types (Beitragsarten)
|
||||
// =====================================================
|
||||
|
||||
async listFeeTypes(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_fee_types')
|
||||
.select('id, name, description, default_amount, is_active, created_at')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_active', true)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createFeeType(input: CreateClubFeeTypeInput) {
|
||||
const { data, error } = await client
|
||||
.from('club_fee_types')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
default_amount: input.defaultAmount,
|
||||
is_active: input.isActive,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateFeeType(feeTypeId: string, updates: { name?: string; description?: string; defaultAmount?: number; isActive?: boolean }) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
if (updates.defaultAmount !== undefined) updateData.default_amount = updates.defaultAmount;
|
||||
if (updates.isActive !== undefined) updateData.is_active = updates.isActive;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_fee_types')
|
||||
.update(updateData)
|
||||
.eq('id', feeTypeId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteFeeType(feeTypeId: string) {
|
||||
const { error } = await client
|
||||
.from('club_fee_types')
|
||||
.delete()
|
||||
.eq('id', feeTypeId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Fee Billings (Beitragsabrechnungen)
|
||||
// =====================================================
|
||||
|
||||
async listFeeBillings(
|
||||
clubId: string,
|
||||
opts?: { year?: number; status?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
let query = client
|
||||
.from('club_fee_billings')
|
||||
.select(
|
||||
'id, club_id, fee_type_id, year, amount, due_date, paid_date, payment_method, status, notes, created_at, club_fee_types ( id, name )',
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false });
|
||||
|
||||
if (opts?.year) {
|
||||
query = query.eq('year', opts.year);
|
||||
}
|
||||
if (opts?.status) {
|
||||
query = query.eq('status', opts.status);
|
||||
}
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async listUnpaidBillings(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_fee_billings')
|
||||
.select(
|
||||
'id, club_id, fee_type_id, year, amount, due_date, status, member_clubs ( id, name ), club_fee_types ( id, name )',
|
||||
)
|
||||
.eq('member_clubs.account_id', accountId)
|
||||
.in('status', ['offen', 'ueberfaellig'])
|
||||
.order('due_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createFeeBilling(input: CreateClubFeeBillingInput) {
|
||||
const { data, error } = await client
|
||||
.from('club_fee_billings')
|
||||
.insert({
|
||||
club_id: input.clubId,
|
||||
fee_type_id: input.feeTypeId,
|
||||
year: input.year,
|
||||
amount: input.amount,
|
||||
due_date: input.dueDate,
|
||||
paid_date: input.paidDate,
|
||||
payment_method: input.paymentMethod,
|
||||
status: input.status,
|
||||
notes: input.notes,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateFeeBilling(billingId: string, updates: Partial<CreateClubFeeBillingInput>) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (updates.feeTypeId !== undefined) updateData.fee_type_id = updates.feeTypeId;
|
||||
if (updates.year !== undefined) updateData.year = updates.year;
|
||||
if (updates.amount !== undefined) updateData.amount = updates.amount;
|
||||
if (updates.dueDate !== undefined) updateData.due_date = updates.dueDate;
|
||||
if (updates.paidDate !== undefined) updateData.paid_date = updates.paidDate;
|
||||
if (updates.paymentMethod !== undefined) updateData.payment_method = updates.paymentMethod;
|
||||
if (updates.status !== undefined) updateData.status = updates.status;
|
||||
if (updates.notes !== undefined) updateData.notes = updates.notes;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_fee_billings')
|
||||
.update(updateData)
|
||||
.eq('id', billingId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteFeeBilling(billingId: string) {
|
||||
const { error } = await client
|
||||
.from('club_fee_billings')
|
||||
.delete()
|
||||
.eq('id', billingId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Notes (Notizen / Aufgaben)
|
||||
// =====================================================
|
||||
|
||||
async listNotes(clubId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_notes')
|
||||
.select('id, club_id, title, content, note_type, due_date, is_completed, created_at, updated_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('is_completed')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createNote(input: CreateClubNoteInput) {
|
||||
const { data, error } = await client
|
||||
.from('club_notes')
|
||||
.insert({
|
||||
club_id: input.clubId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
note_type: input.noteType,
|
||||
due_date: input.dueDate,
|
||||
is_completed: input.isCompleted,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateNote(noteId: string, updates: Partial<CreateClubNoteInput>) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (updates.title !== undefined) updateData.title = updates.title;
|
||||
if (updates.content !== undefined) updateData.content = updates.content;
|
||||
if (updates.noteType !== undefined) updateData.note_type = updates.noteType;
|
||||
if (updates.dueDate !== undefined) updateData.due_date = updates.dueDate;
|
||||
if (updates.isCompleted !== undefined) updateData.is_completed = updates.isCompleted;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_notes')
|
||||
.update(updateData)
|
||||
.eq('id', noteId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async completeNote(noteId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_notes')
|
||||
.update({ is_completed: true })
|
||||
.eq('id', noteId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteNote(noteId: string) {
|
||||
const { error } = await client
|
||||
.from('club_notes')
|
||||
.delete()
|
||||
.eq('id', noteId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// History (Verbandshistorie)
|
||||
// =====================================================
|
||||
|
||||
async listHistory(clubId: string) {
|
||||
const { data, error } = await client
|
||||
.from('association_history')
|
||||
.select('id, club_id, year, member_count, notes, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async upsertHistory(input: CreateAssociationHistoryInput) {
|
||||
const { data, error } = await client
|
||||
.from('association_history')
|
||||
.upsert(
|
||||
{
|
||||
club_id: input.clubId,
|
||||
year: input.year,
|
||||
member_count: input.memberCount,
|
||||
notes: input.notes,
|
||||
},
|
||||
{ onConflict: 'club_id,year' },
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Dashboard
|
||||
// =====================================================
|
||||
|
||||
async getDashboardStats(accountId: string) {
|
||||
const [
|
||||
clubsResult,
|
||||
archivedResult,
|
||||
typesResult,
|
||||
unpaidResult,
|
||||
notesResult,
|
||||
totalMembersResult,
|
||||
] = await Promise.all([
|
||||
client
|
||||
.from('member_clubs')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', false),
|
||||
client
|
||||
.from('member_clubs')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', true),
|
||||
client
|
||||
.from('association_types')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId),
|
||||
client
|
||||
.from('club_fee_billings')
|
||||
.select('id, amount', { count: 'exact' })
|
||||
.in('status', ['offen', 'ueberfaellig']),
|
||||
client
|
||||
.from('club_notes')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('is_completed', false),
|
||||
client
|
||||
.from('member_clubs')
|
||||
.select('member_count')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', false),
|
||||
]);
|
||||
|
||||
const totalMembers = (totalMembersResult.data ?? []).reduce(
|
||||
(sum, c) => sum + (c.member_count ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const unpaidAmount = (unpaidResult.data ?? []).reduce(
|
||||
(sum, b) => sum + ((b as Record<string, unknown>).amount as number ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Get clubs without contacts
|
||||
const { data: clubsWithoutContact } = await client
|
||||
.from('member_clubs')
|
||||
.select('id, name')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', false)
|
||||
.not('id', 'in', `(select club_id from club_contacts)`)
|
||||
.limit(10);
|
||||
|
||||
return {
|
||||
activeClubsCount: clubsResult.count ?? 0,
|
||||
archivedClubsCount: archivedResult.count ?? 0,
|
||||
typesCount: typesResult.count ?? 0,
|
||||
unpaidBillingsCount: unpaidResult.count ?? 0,
|
||||
unpaidAmount,
|
||||
openNotesCount: notesResult.count ?? 0,
|
||||
totalMembers,
|
||||
clubsWithoutContact: clubsWithoutContact ?? [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
6
packages/features/verbandsverwaltung/tsconfig.json
Normal file
6
packages/features/verbandsverwaltung/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user