feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
Major changes: - Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI - Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions - Sitzungsprotokolle module: meeting protocols, agenda items, task tracking - Verbandsverwaltung module: federation management, member clubs, contacts, fees - Per-account module activation via Modules page toggle - Site Builder: live CMS data in Puck blocks (courses, events, membership registration) - Public registration APIs: course signup, event registration, membership application - Document generation: PDF member cards, Excel reports, HTML labels - Landing page: real Com.BISS content (no filler text) - UX audit fixes: AccountNotFound component, shared status badges, confirm dialog, pagination, duplicate heading removal, emoji→badge replacement, a11y fixes - QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user