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);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'use server';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
|
||||
|
||||
export type GenerateDocumentInput = {
|
||||
accountSlug: string;
|
||||
documentType: string;
|
||||
title: string;
|
||||
format: 'A4' | 'A5' | 'letter';
|
||||
orientation: 'portrait' | 'landscape';
|
||||
};
|
||||
|
||||
export type GenerateDocumentResult = {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
fileName?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function generateDocumentAction(
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
try {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct, error: acctError } = await client
|
||||
.from('accounts')
|
||||
.select('id, name')
|
||||
.eq('slug', input.accountSlug)
|
||||
.single();
|
||||
|
||||
if (acctError || !acct) {
|
||||
return { success: false, error: 'Konto nicht gefunden.' };
|
||||
}
|
||||
|
||||
switch (input.documentType) {
|
||||
case 'member-card':
|
||||
return await generateMemberCards(client, acct.id, acct.name, input);
|
||||
case 'labels':
|
||||
return await generateLabels(client, acct.id, input);
|
||||
case 'report':
|
||||
return await generateMemberReport(client, acct.id, input);
|
||||
case 'invoice':
|
||||
case 'letter':
|
||||
case 'certificate':
|
||||
return {
|
||||
success: false,
|
||||
error: `"${LABELS[input.documentType] ?? input.documentType}" ist noch in Entwicklung.`,
|
||||
};
|
||||
default:
|
||||
return { success: false, error: 'Unbekannter Dokumenttyp.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Document generation error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
'member-card': 'Mitgliedsausweis',
|
||||
invoice: 'Rechnung',
|
||||
labels: 'Etiketten',
|
||||
report: 'Bericht',
|
||||
letter: 'Brief',
|
||||
certificate: 'Zertifikat',
|
||||
};
|
||||
|
||||
function fmtDate(d: string | null): string {
|
||||
if (!d) return '–';
|
||||
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Member Card PDF — premium design with color accent bar, structured layout
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function generateMemberCards(
|
||||
client: ReturnType<typeof getSupabaseServerClient>,
|
||||
accountId: string,
|
||||
accountName: string,
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
const { data: members, error } = await client
|
||||
.from('members')
|
||||
.select('id, member_number, first_name, last_name, entry_date, status, date_of_birth, street, house_number, postal_code, city, email')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.order('last_name');
|
||||
|
||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
|
||||
|
||||
const { Document, Page, View, Text, StyleSheet, renderToBuffer, Svg, Rect, Circle } =
|
||||
await import('@react-pdf/renderer');
|
||||
|
||||
// — Brand colors (configurable later via account settings) —
|
||||
const PRIMARY = '#1e40af';
|
||||
const PRIMARY_LIGHT = '#dbeafe';
|
||||
const DARK = '#0f172a';
|
||||
const GRAY = '#64748b';
|
||||
const LIGHT_GRAY = '#f1f5f9';
|
||||
|
||||
const s = StyleSheet.create({
|
||||
page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' },
|
||||
|
||||
// ── Card shell ──
|
||||
card: {
|
||||
width: '47%',
|
||||
height: '45%',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
border: `1pt solid ${PRIMARY_LIGHT}`,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
|
||||
// ── Top accent bar ──
|
||||
accentBar: { height: 6, backgroundColor: PRIMARY },
|
||||
|
||||
// ── Header area ──
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
clubName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: PRIMARY },
|
||||
badge: {
|
||||
backgroundColor: PRIMARY_LIGHT,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 },
|
||||
|
||||
// ── Main content ──
|
||||
body: { flexDirection: 'row', paddingHorizontal: 14, paddingTop: 8, gap: 12, flex: 1 },
|
||||
|
||||
// Photo column
|
||||
photoCol: { width: 64, alignItems: 'center' },
|
||||
photoFrame: {
|
||||
width: 56,
|
||||
height: 68,
|
||||
borderRadius: 6,
|
||||
backgroundColor: LIGHT_GRAY,
|
||||
border: `0.5pt solid #e2e8f0`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
photoIcon: { fontSize: 20, color: '#cbd5e1' },
|
||||
memberNumber: {
|
||||
marginTop: 4,
|
||||
fontSize: 7,
|
||||
color: PRIMARY,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
|
||||
// Info column
|
||||
infoCol: { flex: 1, justifyContent: 'center' },
|
||||
memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 },
|
||||
|
||||
fieldGroup: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
||||
field: { width: '48%', marginBottom: 5 },
|
||||
fieldLabel: { fontSize: 6, color: GRAY, textTransform: 'uppercase' as const, letterSpacing: 0.6, marginBottom: 1 },
|
||||
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
|
||||
|
||||
// ── Footer ──
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: LIGHT_GRAY,
|
||||
borderTop: `0.5pt solid #e2e8f0`,
|
||||
},
|
||||
footerLeft: { fontSize: 6, color: GRAY },
|
||||
footerRight: { fontSize: 6, color: GRAY },
|
||||
validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 },
|
||||
});
|
||||
|
||||
const today = new Date().toLocaleDateString('de-DE');
|
||||
const year = new Date().getFullYear();
|
||||
const cardsPerPage = 4;
|
||||
const pages: React.ReactElement[] = [];
|
||||
|
||||
for (let i = 0; i < members.length; i += cardsPerPage) {
|
||||
const batch = members.slice(i, i + cardsPerPage);
|
||||
|
||||
pages.push(
|
||||
React.createElement(
|
||||
Page,
|
||||
{ key: `p${i}`, size: input.format === 'letter' ? 'LETTER' : (input.format.toUpperCase() as 'A4'|'A5'), orientation: input.orientation, style: s.page },
|
||||
...batch.map((m) =>
|
||||
React.createElement(View, { key: m.id, style: s.card },
|
||||
// Accent bar
|
||||
React.createElement(View, { style: s.accentBar }),
|
||||
|
||||
// Header
|
||||
React.createElement(View, { style: s.header },
|
||||
React.createElement(Text, { style: s.clubName }, accountName),
|
||||
React.createElement(View, { style: s.badge },
|
||||
React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'),
|
||||
),
|
||||
),
|
||||
|
||||
// Body: photo + info
|
||||
React.createElement(View, { style: s.body },
|
||||
// Photo column
|
||||
React.createElement(View, { style: s.photoCol },
|
||||
React.createElement(View, { style: s.photoFrame },
|
||||
React.createElement(Text, { style: s.photoIcon }, '👤'),
|
||||
),
|
||||
React.createElement(Text, { style: s.memberNumber }, `Nr. ${m.member_number ?? '–'}`),
|
||||
),
|
||||
|
||||
// Info column
|
||||
React.createElement(View, { style: s.infoCol },
|
||||
React.createElement(Text, { style: s.memberName }, `${m.first_name} ${m.last_name}`),
|
||||
React.createElement(View, { style: s.fieldGroup },
|
||||
// Entry date
|
||||
React.createElement(View, { style: s.field },
|
||||
React.createElement(Text, { style: s.fieldLabel }, 'Mitglied seit'),
|
||||
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.entry_date)),
|
||||
),
|
||||
// Date of birth
|
||||
React.createElement(View, { style: s.field },
|
||||
React.createElement(Text, { style: s.fieldLabel }, 'Geb.-Datum'),
|
||||
React.createElement(Text, { style: s.fieldValue }, fmtDate(m.date_of_birth)),
|
||||
),
|
||||
// Address
|
||||
React.createElement(View, { style: { ...s.field, width: '100%' } },
|
||||
React.createElement(Text, { style: s.fieldLabel }, 'Adresse'),
|
||||
React.createElement(Text, { style: s.fieldValue },
|
||||
[m.street, m.house_number].filter(Boolean).join(' ') || '–',
|
||||
),
|
||||
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
|
||||
[m.postal_code, m.city].filter(Boolean).join(' ') || '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer
|
||||
React.createElement(View, { style: s.footer },
|
||||
React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } },
|
||||
React.createElement(View, { style: s.validDot }),
|
||||
React.createElement(Text, { style: s.footerLeft }, `Gültig ${year}/${year + 1}`),
|
||||
),
|
||||
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const doc = React.createElement(Document, { title: input.title }, ...pages);
|
||||
const buffer = await renderToBuffer(doc);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(buffer).toString('base64'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: `${input.title || 'Mitgliedsausweise'}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Address Labels (HTML — Avery L7163)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function generateLabels(
|
||||
client: ReturnType<typeof getSupabaseServerClient>,
|
||||
accountId: string,
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
const { data: members, error } = await client
|
||||
.from('members')
|
||||
.select('first_name, last_name, street, house_number, postal_code, city, salutation, title')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.order('last_name');
|
||||
|
||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||
if (!members?.length) return { success: false, error: 'Keine aktiven Mitglieder.' };
|
||||
|
||||
const api = createDocumentGeneratorApi();
|
||||
const records = members.map((m) => ({
|
||||
line1: [m.salutation, m.title, m.first_name, m.last_name].filter(Boolean).join(' '),
|
||||
line2: [m.street, m.house_number].filter(Boolean).join(' ') || undefined,
|
||||
line3: [m.postal_code, m.city].filter(Boolean).join(' ') || undefined,
|
||||
}));
|
||||
|
||||
const html = api.generateLabelsHtml({ labelFormat: 'avery-l7163', records });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(html, 'utf-8').toString('base64'),
|
||||
mimeType: 'text/html',
|
||||
fileName: `${input.title || 'Adressetiketten'}.html`,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Member Report (Excel)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function generateMemberReport(
|
||||
client: ReturnType<typeof getSupabaseServerClient>,
|
||||
accountId: string,
|
||||
input: GenerateDocumentInput,
|
||||
): Promise<GenerateDocumentResult> {
|
||||
const { data: members, error } = await client
|
||||
.from('members')
|
||||
.select('member_number, last_name, first_name, email, postal_code, city, status, entry_date')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
|
||||
if (error) return { success: false, error: `DB-Fehler: ${error.message}` };
|
||||
if (!members?.length) return { success: false, error: 'Keine Mitglieder.' };
|
||||
|
||||
const ExcelJS = await import('exceljs');
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'MyEasyCMS';
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet('Mitglieder');
|
||||
ws.columns = [
|
||||
{ header: 'Nr.', key: 'nr', width: 12 },
|
||||
{ header: 'Name', key: 'name', width: 20 },
|
||||
{ header: 'Vorname', key: 'vorname', width: 20 },
|
||||
{ header: 'E-Mail', key: 'email', width: 28 },
|
||||
{ header: 'PLZ', key: 'plz', width: 10 },
|
||||
{ header: 'Ort', key: 'ort', width: 20 },
|
||||
{ header: 'Status', key: 'status', width: 12 },
|
||||
{ header: 'Eintritt', key: 'eintritt', width: 14 },
|
||||
];
|
||||
|
||||
const hdr = ws.getRow(1);
|
||||
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } };
|
||||
hdr.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
hdr.height = 24;
|
||||
|
||||
const SL: Record<string, string> = { active: 'Aktiv', inactive: 'Inaktiv', pending: 'Ausstehend', resigned: 'Ausgetreten', excluded: 'Ausgeschlossen' };
|
||||
|
||||
for (const m of members) {
|
||||
ws.addRow({
|
||||
nr: m.member_number ?? '',
|
||||
name: m.last_name,
|
||||
vorname: m.first_name,
|
||||
email: m.email ?? '',
|
||||
plz: m.postal_code ?? '',
|
||||
ort: m.city ?? '',
|
||||
status: SL[m.status] ?? m.status,
|
||||
eintritt: m.entry_date ? new Date(m.entry_date).toLocaleDateString('de-DE') : '',
|
||||
});
|
||||
}
|
||||
|
||||
ws.eachRow((row, n) => {
|
||||
if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
|
||||
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
|
||||
});
|
||||
|
||||
ws.addRow({});
|
||||
const sum = ws.addRow({ nr: `Gesamt: ${members.length} Mitglieder` });
|
||||
sum.font = { bold: true };
|
||||
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: Buffer.from(buf as ArrayBuffer).toString('base64'),
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, FileDown } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
import { GenerateDocumentForm } from '../_components/generate-document-form';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
searchParams: Promise<{ type?: string }>;
|
||||
@@ -45,7 +46,7 @@ export default async function GenerateDocumentPage({
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const selectedType = type ?? 'member-card';
|
||||
const selectedLabel = DOCUMENT_LABELS[selectedType] ?? 'Dokument';
|
||||
@@ -73,82 +74,16 @@ export default async function GenerateDocumentPage({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-5">
|
||||
{/* Document Type */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="documentType">Dokumenttyp</Label>
|
||||
<select
|
||||
id="documentType"
|
||||
name="documentType"
|
||||
defaultValue={selectedType}
|
||||
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>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="title">Titel / Bezeichnung</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={`z.B. ${selectedLabel} für Max Mustermann`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<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"
|
||||
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="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"
|
||||
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="portrait">Hochformat</option>
|
||||
<option value="landscape">Querformat</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-md bg-muted/50 p-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Die Dokumentgenerierung verwendet
|
||||
Ihre gespeicherten Vorlagen. Stellen Sie sicher, dass eine
|
||||
passende Vorlage für den gewählten Dokumenttyp existiert.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<GenerateDocumentForm
|
||||
accountSlug={account}
|
||||
initialType={selectedType}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<CardFooter>
|
||||
<Link href={`/home/${account}/documents`}>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Link>
|
||||
<Button type="submit">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
Generieren
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -80,25 +81,16 @@ export default async function DocumentsPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title="Dokumente">
|
||||
<CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dokumente</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Dokumente erstellen und verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/home/${account}/documents/templates`}>
|
||||
<Button variant="outline">Vorlagen</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/home/${account}/documents/templates`}>
|
||||
<Button variant="outline">Vorlagen verwalten</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Document Type Grid */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
import { EmptyState } from '~/components/empty-state';
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -23,7 +24,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
// Document templates are stored locally for now — placeholder for future DB integration
|
||||
const templates: Array<{
|
||||
|
||||
Reference in New Issue
Block a user