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,69 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import {
VerbandTabNavigation,
ClubContactsManager,
ClubFeeBillingTable,
ClubNotesList,
} from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props {
params: Promise<{ account: string; clubId: string }>;
}
export default async function ClubDetailPage({ params }: Props) {
const { account, clubId } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const detail = await api.getClubDetail(clubId);
return (
<CmsPageShell account={account} title={`Verein ${detail.club.name}`}>
<VerbandTabNavigation account={account} activeTab="clubs" />
<div className="space-y-6">
{/* Club Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold">{detail.club.name}</h1>
{detail.club.short_name && (
<p className="text-muted-foreground">{detail.club.short_name}</p>
)}
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
{detail.club.city && (
<span>{detail.club.zip} {detail.club.city}</span>
)}
{detail.club.member_count != null && (
<span>{detail.club.member_count} Mitglieder</span>
)}
{detail.club.founded_year && (
<span>Gegr. {detail.club.founded_year}</span>
)}
</div>
</div>
</div>
{/* Contacts */}
<ClubContactsManager clubId={clubId} contacts={detail.contacts} />
{/* Fee Billings */}
<ClubFeeBillingTable billings={detail.billings} clubId={clubId} />
{/* Notes */}
<ClubNotesList notes={detail.notes} clubId={clubId} />
</div>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,37 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation, CreateClubForm } from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props {
params: Promise<{ account: string }>;
}
export default async function NewClubPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const types = await api.listTypes(acct.id);
return (
<CmsPageShell account={account} title="Neuer Verein">
<VerbandTabNavigation account={account} activeTab="clubs" />
<CreateClubForm
accountId={acct.id}
account={account}
types={types.map((t) => ({ id: t.id, name: t.name }))}
/>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,54 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation, ClubsDataTable } from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface Props {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function ClubsPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const page = Number(search.page) || 1;
const showArchived = search.archived === '1';
const [result, types] = await Promise.all([
api.listClubs(acct.id, {
search: search.q as string,
typeId: search.type as string,
archived: showArchived ? undefined : false,
page,
pageSize: 25,
}),
api.listTypes(acct.id),
]);
return (
<CmsPageShell account={account} title="Verbandsverwaltung - Vereine">
<VerbandTabNavigation account={account} activeTab="clubs" />
<ClubsDataTable
data={result.data}
total={result.total}
page={page}
pageSize={25}
account={account}
types={types.map((t) => ({ id: t.id, name: t.name }))}
/>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export default function VerbandLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,33 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation, VerbandDashboard } from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function VerbandPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const stats = await api.getDashboardStats(acct.id);
return (
<CmsPageShell account={account} title="Verbandsverwaltung">
<VerbandTabNavigation account={account} activeTab="overview" />
<VerbandDashboard stats={stats} account={account} />
</CmsPageShell>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { Plus, Pencil, Trash2, Settings } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import {
createRole,
updateRole,
deleteRole,
createAssociationType,
updateAssociationType,
deleteAssociationType,
createFeeType,
updateFeeType,
deleteFeeType,
} from '@kit/verbandsverwaltung/actions/verband-actions';
interface SettingsContentProps {
accountId: string;
roles: Array<Record<string, unknown>>;
types: Array<Record<string, unknown>>;
feeTypes: Array<Record<string, unknown>>;
}
function SettingsSection({
title,
items,
onAdd,
onUpdate,
onDelete,
isAdding,
isUpdating,
}: {
title: string;
items: Array<Record<string, unknown>>;
onAdd: (name: string, description?: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
isAdding: boolean;
isUpdating: boolean;
}) {
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
{title}
</CardTitle>
{!showAdd && (
<Button size="sm" onClick={() => setShowAdd(true)}>
<Plus className="mr-2 h-4 w-4" />
Hinzufügen
</Button>
)}
</CardHeader>
<CardContent>
{showAdd && (
<div className="mb-4 flex gap-2 rounded-lg border p-3">
<Input
placeholder="Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1"
/>
<Input
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="flex-1"
/>
<Button
size="sm"
disabled={!newName.trim() || isAdding}
onClick={() => {
onAdd(newName.trim(), newDesc.trim() || undefined);
setNewName('');
setNewDesc('');
setShowAdd(false);
}}
>
Erstellen
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(false)}>
Abbrechen
</Button>
</div>
)}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Einträge vorhanden.</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={String(item.id)} className="flex items-center justify-between rounded-lg border p-3">
{editingId === String(item.id) ? (
<div className="flex flex-1 gap-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="flex-1"
/>
<Button
size="sm"
disabled={!editName.trim() || isUpdating}
onClick={() => {
onUpdate(String(item.id), editName.trim());
setEditingId(null);
}}
>
Speichern
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
Abbrechen
</Button>
</div>
) : (
<>
<div>
<span className="font-medium">{String(item.name)}</span>
{item.description && (
<p className="text-xs text-muted-foreground">{String(item.description)}</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(String(item.id));
setEditName(String(item.name));
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(String(item.id))}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
export default function SettingsContent({
accountId,
roles,
types,
feeTypes,
}: SettingsContentProps) {
// Roles
const { execute: execCreateRole, isPending: isCreatingRole } = useAction(createRole, {
onSuccess: () => toast.success('Funktion erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execUpdateRole, isPending: isUpdatingRole } = useAction(updateRole, {
onSuccess: () => toast.success('Funktion aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execDeleteRole } = useAction(deleteRole, {
onSuccess: () => toast.success('Funktion gelöscht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
// Types
const { execute: execCreateType, isPending: isCreatingType } = useAction(createAssociationType, {
onSuccess: () => toast.success('Vereinstyp erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execUpdateType, isPending: isUpdatingType } = useAction(updateAssociationType, {
onSuccess: () => toast.success('Vereinstyp aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execDeleteType } = useAction(deleteAssociationType, {
onSuccess: () => toast.success('Vereinstyp gelöscht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
// Fee Types
const { execute: execCreateFeeType, isPending: isCreatingFee } = useAction(createFeeType, {
onSuccess: () => toast.success('Beitragsart erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execUpdateFeeType, isPending: isUpdatingFee } = useAction(updateFeeType, {
onSuccess: () => toast.success('Beitragsart aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execDeleteFeeType } = useAction(deleteFeeType, {
onSuccess: () => toast.success('Beitragsart gelöscht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Einstellungen</h1>
<p className="text-muted-foreground">
Funktionen, Vereinstypen und Beitragsarten verwalten
</p>
</div>
<SettingsSection
title="Funktionen (Rollen)"
items={roles}
onAdd={(name, description) => execCreateRole({ accountId, name, description, sortOrder: 0 })}
onUpdate={(id, name) => execUpdateRole({ roleId: id, name })}
onDelete={(id) => execDeleteRole({ roleId: id })}
isAdding={isCreatingRole}
isUpdating={isUpdatingRole}
/>
<SettingsSection
title="Vereinstypen"
items={types}
onAdd={(name, description) => execCreateType({ accountId, name, description, sortOrder: 0 })}
onUpdate={(id, name) => execUpdateType({ typeId: id, name })}
onDelete={(id) => execDeleteType({ typeId: id })}
isAdding={isCreatingType}
isUpdating={isUpdatingType}
/>
<SettingsSection
title="Beitragsarten"
items={feeTypes}
onAdd={(name, description) => execCreateFeeType({ accountId, name, description, isActive: true })}
onUpdate={(id, name) => execUpdateFeeType({ feeTypeId: id, name })}
onDelete={(id) => execDeleteFeeType({ feeTypeId: id })}
isAdding={isCreatingFee}
isUpdating={isUpdatingFee}
/>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import SettingsContent from './_components/settings-content';
import { AccountNotFound } from '~/components/account-not-found';
interface Props {
params: Promise<{ account: string }>;
}
export default async function SettingsPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const [roles, types, feeTypes] = await Promise.all([
api.listRoles(acct.id),
api.listTypes(acct.id),
api.listFeeTypes(acct.id),
]);
return (
<CmsPageShell account={account} title="Verbandsverwaltung - Einstellungen">
<VerbandTabNavigation account={account} activeTab="settings" />
<SettingsContent
accountId={acct.id}
roles={roles}
types={types}
feeTypes={feeTypes}
/>
</CmsPageShell>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { BarChart3 } from 'lucide-react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
const PLACEHOLDER_DATA = [
{ year: '2020', vereine: 12, mitglieder: 850 },
{ year: '2021', vereine: 14, mitglieder: 920 },
{ year: '2022', vereine: 15, mitglieder: 980 },
{ year: '2023', vereine: 16, mitglieder: 1050 },
{ year: '2024', vereine: 18, mitglieder: 1120 },
{ year: '2025', vereine: 19, mitglieder: 1200 },
];
export default function StatisticsContent() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Statistik</h1>
<p className="text-muted-foreground">
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
Vereinsentwicklung
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={PLACEHOLDER_DATA}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="vereine"
stroke="hsl(var(--primary))"
fill="hsl(var(--primary))"
fillOpacity={0.1}
name="Vereine"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
Mitgliederentwicklung
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={PLACEHOLDER_DATA}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="mitglieder"
stroke="hsl(var(--chart-2))"
fill="hsl(var(--chart-2))"
fillOpacity={0.1}
name="Mitglieder"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">
Die Statistiken werden automatisch aus den Vereinsdaten und der Verbandshistorie berechnet.
Pflegen Sie die Mitgliederzahlen in den einzelnen Vereinsdetails, um aktuelle Auswertungen zu erhalten.
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import StatisticsContent from './_components/statistics-content';
interface Props {
params: Promise<{ account: string }>;
}
export default async function StatisticsPage({ params }: Props) {
const { account } = await params;
return (
<CmsPageShell account={account} title="Verbandsverwaltung - Statistik">
<VerbandTabNavigation account={account} activeTab="statistics" />
<StatisticsContent />
</CmsPageShell>
);
}