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

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

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
};

View File

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

View File

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

View 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 ?? [],
};
},
};
}