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,242 @@
'use client';
import { useState, useTransition } from 'react';
import { FileDown, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import {
generateDocumentAction,
type GenerateDocumentInput,
type GenerateDocumentResult,
} from '../_lib/server/generate-document';
interface Props {
accountSlug: string;
initialType: string;
}
const DOCUMENT_LABELS: Record<string, string> = {
'member-card': 'Mitgliedsausweis',
invoice: 'Rechnung',
labels: 'Etiketten',
report: 'Bericht',
letter: 'Brief',
certificate: 'Zertifikat',
};
const COMING_SOON_TYPES = new Set(['invoice', 'letter', 'certificate']);
export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
const [isPending, startTransition] = useTransition();
const [result, setResult] = useState<GenerateDocumentResult | null>(null);
const [selectedType, setSelectedType] = useState(initialType);
const isComingSoon = COMING_SOON_TYPES.has(selectedType);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setResult(null);
const formData = new FormData(e.currentTarget);
const input: GenerateDocumentInput = {
accountSlug,
documentType: formData.get('documentType') as string,
title: formData.get('title') as string,
format: formData.get('format') as 'A4' | 'A5' | 'letter',
orientation: formData.get('orientation') as 'portrait' | 'landscape',
};
startTransition(async () => {
const res = await generateDocumentAction(input);
setResult(res);
if (res.success && res.data && res.mimeType && res.fileName) {
downloadFile(res.data, res.mimeType, res.fileName);
}
});
}
return (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
{/* Document Type */}
<div className="flex flex-col gap-2">
<Label htmlFor="documentType">Dokumenttyp</Label>
<select
id="documentType"
name="documentType"
value={selectedType}
onChange={(e) => {
setSelectedType(e.target.value);
setResult(null);
}}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option>
<option value="labels">Etiketten</option>
<option value="report">Bericht</option>
<option value="letter">Brief</option>
<option value="certificate">Zertifikat</option>
</select>
</div>
{/* Coming soon banner */}
{isComingSoon && (
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Demnächst verfügbar</p>
<p className="mt-1 text-amber-700 dark:text-amber-300">
Die Generierung von &ldquo;{DOCUMENT_LABELS[selectedType]}&rdquo;
befindet sich noch in Entwicklung und wird in Kürze verfügbar sein.
</p>
</div>
</div>
)}
{/* Title */}
<div className="flex flex-col gap-2">
<Label htmlFor="title">Titel / Bezeichnung</Label>
<Input
id="title"
name="title"
placeholder={`z.B. ${DOCUMENT_LABELS[selectedType] ?? 'Dokument'} ${new Date().getFullYear()}`}
required
disabled={isPending}
/>
</div>
{/* Format & Orientation */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="format">Format</Label>
<select
id="format"
name="format"
disabled={isPending}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="letter">Letter</option>
</select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="orientation">Ausrichtung</Label>
<select
id="orientation"
name="orientation"
disabled={isPending}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50"
>
<option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option>
</select>
</div>
</div>
{/* Hint */}
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
<p>
<strong>Hinweis:</strong>{' '}
{selectedType === 'member-card'
? 'Es werden Mitgliedsausweise für alle aktiven Mitglieder generiert (4 Karten pro A4-Seite).'
: selectedType === 'labels'
? 'Es werden Adressetiketten im Avery-L7163-Format für alle aktiven Mitglieder erzeugt.'
: selectedType === 'report'
? 'Es wird eine Excel-Datei mit allen Mitgliederdaten erstellt.'
: 'Wählen Sie den gewünschten Dokumenttyp, um die Generierung zu starten.'}
</p>
</div>
{/* Result feedback */}
{result && !result.success && (
<div className="flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Fehler bei der Generierung</p>
<p className="mt-1">{result.error}</p>
</div>
</div>
)}
{result && result.success && (
<div className="flex items-start gap-3 rounded-md border border-green-200 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Dokument erfolgreich erstellt!</p>
<p className="mt-1">
Die Datei &ldquo;{result.fileName}&rdquo; wurde heruntergeladen.
</p>
{result.data && result.mimeType && result.fileName && (
<button
type="button"
className="mt-2 text-green-700 underline hover:text-green-900 dark:text-green-300 dark:hover:text-green-100"
onClick={() =>
downloadFile(result.data!, result.mimeType!, result.fileName!)
}
>
Erneut herunterladen
</button>
)}
</div>
</div>
)}
{/* Submit button */}
<div className="flex justify-end">
<Button type="submit" disabled={isPending || isComingSoon}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird generiert
</>
) : (
<>
<FileDown className="mr-2 h-4 w-4" />
Generieren
</>
)}
</Button>
</div>
</form>
);
}
/**
* Trigger a browser download from a base64 string.
* Uses an anchor element with the download attribute set to the full filename.
*/
function downloadFile(
base64Data: string,
mimeType: string,
fileName: string,
) {
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
// Ensure the filename always has the right extension
a.download = fileName;
// Force the filename by also setting it via the Content-Disposition-like attribute
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
// Small delay before cleanup to ensure download starts
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
}