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,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 “{DOCUMENT_LABELS[selectedType]}”
|
||||
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 “{result.fileName}” 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);
|
||||
}
|
||||
Reference in New Issue
Block a user