Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -73,7 +73,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
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"
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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<option value="member-card">Mitgliedsausweis</option>
<option value="invoice">Rechnung</option>
@@ -92,7 +92,8 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
<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.
befindet sich noch in Entwicklung und wird in Kürze verfügbar
sein.
</p>
</div>
</div>
@@ -118,7 +119,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
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"
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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
>
<option value="A4">A4</option>
<option value="A5">A5</option>
@@ -131,7 +132,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
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"
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:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-50"
>
<option value="portrait">Hochformat</option>
<option value="landscape">Querformat</option>
@@ -140,7 +141,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
</div>
{/* Hint */}
<div className="text-muted-foreground rounded-md bg-muted/50 p-4 text-sm">
<div className="text-muted-foreground bg-muted/50 rounded-md p-4 text-sm">
<p>
<strong>Hinweis:</strong>{' '}
{selectedType === 'member-card'
@@ -211,11 +212,7 @@ export function GenerateDocumentForm({ accountSlug, initialType }: Props) {
* 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,
) {
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++) {

View File

@@ -2,8 +2,10 @@
import React from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createDocumentGeneratorApi } from '@kit/document-generator/api';
import { formatDate } from '@kit/shared/dates';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type GenerateDocumentInput = {
accountSlug: string;
@@ -55,7 +57,11 @@ export async function generateDocumentAction(
return { success: false, error: 'Unbekannter Dokumenttyp.' };
}
} catch (err) {
console.error('Document generation error:', err);
const logger = await getLogger();
logger.error(
{ error: err, context: 'document-generation' },
'Document generation error',
);
return {
success: false,
error: err instanceof Error ? err.message : 'Unbekannter Fehler.',
@@ -73,8 +79,7 @@ const LABELS: Record<string, string> = {
};
function fmtDate(d: string | null): string {
if (!d) return '';
try { return new Date(d).toLocaleDateString('de-DE'); } catch { return d; }
return formatDate(d);
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -88,16 +93,28 @@ async function generateMemberCards(
): 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')
.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.' };
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');
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';
@@ -107,7 +124,13 @@ async function generateMemberCards(
const LIGHT_GRAY = '#f1f5f9';
const s = StyleSheet.create({
page: { padding: 24, flexDirection: 'row', flexWrap: 'wrap', gap: 16, fontFamily: 'Helvetica' },
page: {
padding: 24,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
fontFamily: 'Helvetica',
},
// ── Card shell ──
card: {
@@ -138,10 +161,22 @@ async function generateMemberCards(
paddingHorizontal: 6,
paddingVertical: 2,
},
badgeText: { fontSize: 6, color: PRIMARY, fontFamily: 'Helvetica-Bold', textTransform: 'uppercase' as const, letterSpacing: 0.8 },
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 },
body: {
flexDirection: 'row',
paddingHorizontal: 14,
paddingTop: 8,
gap: 12,
flex: 1,
},
// Photo column
photoCol: { width: 64, alignItems: 'center' },
@@ -165,11 +200,22 @@ async function generateMemberCards(
// Info column
infoCol: { flex: 1, justifyContent: 'center' },
memberName: { fontSize: 14, fontFamily: 'Helvetica-Bold', color: DARK, marginBottom: 6 },
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 },
fieldLabel: {
fontSize: 6,
color: GRAY,
textTransform: 'uppercase' as const,
letterSpacing: 0.6,
marginBottom: 1,
},
fieldValue: { fontSize: 8, color: DARK, fontFamily: 'Helvetica-Bold' },
// ── Footer ──
@@ -184,10 +230,16 @@ async function generateMemberCards(
},
footerLeft: { fontSize: 6, color: GRAY },
footerRight: { fontSize: 6, color: GRAY },
validDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e', marginRight: 3 },
validDot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: '#22c55e',
marginRight: 3,
},
});
const today = new Date().toLocaleDateString('de-DE');
const today = formatDate(new Date());
const year = new Date().getFullYear();
const cardsPerPage = 4;
const pages: React.ReactElement[] = [];
@@ -198,51 +250,118 @@ async function generateMemberCards(
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 },
{
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 },
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(
View,
{ style: s.header },
React.createElement(Text, { style: s.clubName }, accountName),
React.createElement(View, { style: s.badge },
React.createElement(Text, { style: s.badgeText }, 'Mitgliedsausweis'),
React.createElement(
View,
{ style: s.badge },
React.createElement(
Text,
{ style: s.badgeText },
'Mitgliedsausweis',
),
),
),
// Body: photo + info
React.createElement(View, { style: s.body },
React.createElement(
View,
{ style: s.body },
// Photo column
React.createElement(View, { style: s.photoCol },
React.createElement(View, { style: s.photoFrame },
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 ?? ''}`),
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 },
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)),
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)),
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(
View,
{ style: { ...s.field, width: '100%' } },
React.createElement(
Text,
{ style: s.fieldLabel },
'Adresse',
),
React.createElement(Text, { style: { ...s.fieldValue, marginTop: 1 } },
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(' ') || '',
),
),
@@ -251,12 +370,24 @@ async function generateMemberCards(
),
// Footer
React.createElement(View, { style: s.footer },
React.createElement(View, { style: { flexDirection: 'row', alignItems: 'center' } },
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.footerLeft },
`Gültig ${year}/${year + 1}`,
),
),
React.createElement(
Text,
{ style: s.footerRight },
`Ausgestellt ${today}`,
),
React.createElement(Text, { style: s.footerRight }, `Ausgestellt ${today}`),
),
),
),
@@ -285,17 +416,22 @@ async function generateLabels(
): Promise<GenerateDocumentResult> {
const { data: members, error } = await client
.from('members')
.select('first_name, last_name, street, house_number, postal_code, city, salutation, title')
.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.' };
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(' '),
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,
}));
@@ -320,7 +456,9 @@ async function generateMemberReport(
): Promise<GenerateDocumentResult> {
const { data: members, error } = await client
.from('members')
.select('member_number, last_name, first_name, email, postal_code, city, status, entry_date')
.select(
'member_number, last_name, first_name, email, postal_code, city, status, entry_date',
)
.eq('account_id', accountId)
.order('last_name');
@@ -346,11 +484,21 @@ async function generateMemberReport(
const hdr = ws.getRow(1);
hdr.font = { bold: true, color: { argb: 'FFFFFFFF' } };
hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E40AF' } };
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' };
const SL: Record<string, string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
resigned: 'Ausgetreten',
excluded: 'Ausgeschlossen',
};
for (const m of members) {
ws.addRow({
@@ -361,12 +509,17 @@ async function generateMemberReport(
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') : '',
eintritt: m.entry_date ? formatDate(m.entry_date) : '',
});
}
ws.eachRow((row, n) => {
if (n > 1 && n % 2 === 0) row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
if (n > 1 && n % 2 === 0)
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF1F5F9' },
};
row.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } };
});
@@ -379,7 +532,8 @@ async function generateMemberReport(
return {
success: true,
data: Buffer.from(buf as ArrayBuffer).toString('base64'),
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
mimeType:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
fileName: `${input.title || 'Mitgliederbericht'}.xlsx`,
};
}

View File

@@ -13,10 +13,10 @@ import {
CardTitle,
} from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
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 }>;

View File

@@ -13,8 +13,8 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
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';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
@@ -40,32 +40,28 @@ const DOCUMENT_TYPES = [
{
id: 'labels',
title: 'Etiketten',
description:
'Adressetiketten für Serienbriefe im Avery-Format drucken.',
description: 'Adressetiketten für Serienbriefe im Avery-Format drucken.',
icon: Tag,
color: 'text-orange-600 bg-orange-50',
},
{
id: 'report',
title: 'Bericht',
description:
'Statistische Auswertungen und Berichte als PDF oder Excel.',
description: 'Statistische Auswertungen und Berichte als PDF oder Excel.',
icon: BarChart3,
color: 'text-purple-600 bg-purple-50',
},
{
id: 'letter',
title: 'Brief',
description:
'Serienbriefe mit personalisierten Platzhaltern erstellen.',
description: 'Serienbriefe mit personalisierten Platzhaltern erstellen.',
icon: Mail,
color: 'text-rose-600 bg-rose-50',
},
{
id: 'certificate',
title: 'Zertifikat',
description:
'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
description: 'Teilnahmebescheinigungen und Zertifikate mit Unterschrift.',
icon: Award,
color: 'text-amber-600 bg-amber-50',
},
@@ -84,7 +80,11 @@ export default async function DocumentsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
return (
<CmsPageShell account={account} title="Dokumente" description="Dokumente erstellen und verwalten">
<CmsPageShell
account={account}
title="Dokumente"
description="Dokumente erstellen und verwalten"
>
<div className="flex w-full flex-col gap-6">
{/* Actions */}
<div className="flex items-center justify-end">
@@ -108,7 +108,7 @@ export default async function DocumentsPage({ params }: PageProps) {
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col justify-between gap-4">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{docType.description}
</p>
<Link

View File

@@ -6,9 +6,9 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
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 }>;
@@ -69,7 +69,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="p-3 text-left font-medium">Name</th>
<th className="p-3 text-left font-medium">Typ</th>
<th className="p-3 text-left font-medium">
@@ -81,11 +81,11 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
{templates.map((template) => (
<tr
key={template.id}
className="border-b hover:bg-muted/30"
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">{template.name}</td>
<td className="p-3">{template.type}</td>
<td className="p-3 text-muted-foreground">
<td className="text-muted-foreground p-3">
{template.description}
</td>
</tr>