feat: MyEasyCMS v2 — Full SaaS rebuild
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled

Complete rebuild of 22-year-old PHP CMS as modern SaaS:

Database (15 migrations, 42+ tables):
- Foundation: account_settings, audit_log, GDPR register, cms_files
- Module Engine: modules, fields, records, permissions, relations + RPC
- Members: 45+ field member profiles, departments, roles, honors, SEPA mandates
- Courses: courses, sessions, categories, instructors, locations, attendance
- Bookings: rooms, guests, bookings with availability
- Events: events, registrations, holiday passes
- Finance: SEPA batches/items (pain.008/001 XML), invoices
- Newsletter: campaigns, templates, recipients, subscriptions
- Site Builder: site_pages (Puck JSON), site_settings, cms_posts
- Portal Auth: member_portal_invitations, user linking

Feature Packages (9):
- @kit/module-builder — dynamic low-code CRUD engine
- @kit/member-management — 31 API methods, 21 actions, 8 components
- @kit/course-management, @kit/booking-management, @kit/event-management
- @kit/finance — SEPA XML generator + IBAN validator
- @kit/newsletter — campaigns + dispatch
- @kit/document-generator — PDF/Excel/Word
- @kit/site-builder — Puck visual editor, 15 blocks, public rendering

Pages (60+):
- Dashboard with real stats from all APIs
- Full CRUD for all 8 domains with react-hook-form + Zod
- Recharts statistics
- German i18n throughout
- Member portal with auth + invitation system
- Public club websites via Puck at /club/[slug]

Infrastructure:
- Dockerfile (multi-stage, standalone output)
- docker-compose.yml (Supabase self-hosted + Next.js)
- Kong API gateway config
- .env.production.example
This commit is contained in:
Zaid Marzguioui
2026-03-29 23:17:38 +02:00
parent 61ff48cb73
commit 1294caa7fa
120 changed files with 11013 additions and 1858 deletions

View File

@@ -12,23 +12,30 @@
"exports": {
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts"
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/papaparse": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"papaparse": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { approveApplication, rejectApplication } from '../server/actions/member-actions';
interface ApplicationWorkflowProps {
applications: Array<Record<string, unknown>>;
accountId: string;
account: string;
}
const APPLICATION_STATUS_LABELS: Record<string, string> = {
submitted: 'Eingereicht',
review: 'In Prüfung',
approved: 'Genehmigt',
rejected: 'Abgelehnt',
};
function getApplicationStatusColor(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'approved':
return 'default';
case 'submitted':
case 'review':
return 'outline';
case 'rejected':
return 'destructive';
default:
return 'secondary';
}
}
export function ApplicationWorkflow({
applications,
accountId,
account,
}: ApplicationWorkflowProps) {
const router = useRouter();
const form = useForm();
const { execute: executeApprove, isPending: isApproving } = useAction(
approveApplication,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Antrag genehmigt Mitglied wurde erstellt');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Genehmigen');
},
},
);
const { execute: executeReject, isPending: isRejecting } = useAction(
rejectApplication,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Antrag wurde abgelehnt');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Ablehnen');
},
},
);
const handleApprove = useCallback(
(applicationId: string) => {
if (
!window.confirm(
'Mitglied wird automatisch erstellt. Fortfahren?',
)
) {
return;
}
executeApprove({ applicationId, accountId });
},
[executeApprove, accountId],
);
const handleReject = useCallback(
(applicationId: string) => {
const reason = window.prompt(
'Bitte geben Sie einen Ablehnungsgrund ein:',
);
if (reason === null) return; // cancelled
executeReject({
applicationId,
accountId,
reviewNotes: reason,
});
},
[executeReject, accountId],
);
const isPending = isApproving || isRejecting;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Aufnahmeanträge</h2>
<p className="text-sm text-muted-foreground">
{applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
</p>
</div>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
<th className="px-4 py-3 text-left font-medium">Datum</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{applications.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
>
Keine Aufnahmeanträge vorhanden.
</td>
</tr>
) : (
applications.map((app) => {
const appId = String(app.id ?? '');
const appStatus = String(app.status ?? 'submitted');
const isActionable =
appStatus === 'submitted' || appStatus === 'review';
return (
<tr key={appId} className="border-b">
<td className="px-4 py-3">
{String(app.last_name ?? '')},{' '}
{String(app.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{String(app.email ?? '—')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{app.created_at
? new Date(String(app.created_at)).toLocaleDateString(
'de-DE',
)
: '—'}
</td>
<td className="px-4 py-3">
<Badge variant={getApplicationStatusColor(appStatus)}>
{APPLICATION_STATUS_LABELS[appStatus] ?? appStatus}
</Badge>
</td>
<td className="px-4 py-3 text-right">
{isActionable && (
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="default"
disabled={isPending}
onClick={() => handleApprove(appId)}
>
Genehmigen
</Button>
<Button
size="sm"
variant="destructive"
disabled={isPending}
onClick={() => handleReject(appId)}
>
Ablehnen
</Button>
</div>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { CreateMemberSchema } from '../schema/member.schema';
import { createMember } from '../server/actions/member-actions';
interface Props {
accountId: string;
account: string; // slug for redirect
duesCategories: Array<{ id: string; name: string; amount: number }>;
}
export function CreateMemberForm({ accountId, account, duesCategories }: Props) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateMemberSchema),
defaultValues: {
accountId,
firstName: '', lastName: '', email: '', phone: '', mobile: '',
street: '', houseNumber: '', postalCode: '', city: '', country: 'DE',
memberNumber: '', status: 'active' as const, entryDate: new Date().toISOString().split('T')[0]!,
iban: '', bic: '', accountHolder: '', gdprConsent: false, notes: '',
},
});
const { execute, isPending } = useAction(createMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied erfolgreich erstellt');
router.push(`/home/${account}/members-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="firstName" render={({ field }) => (
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="lastName" render={({ field }) => (
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="dateOfBirth" render={({ field }) => (
<FormItem><FormLabel>Geburtsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="gender" render={({ field }) => (
<FormItem><FormLabel>Geschlecht</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Bitte wählen </option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="phone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="mobile" render={({ field }) => (
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="street" render={({ field }) => (
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="houseNumber" render={({ field }) => (
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="postalCode" render={({ field }) => (
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Mitgliedschaft</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="memberNumber" render={({ field }) => (
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="pending">Ausstehend</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="entryDate" render={({ field }) => (
<FormItem><FormLabel>Eintrittsdatum</FormLabel><FormControl><Input type="date" {...field} /></FormControl><FormMessage /></FormItem>
)} />
{duesCategories.length > 0 && (
<FormField control={form.control} name="duesCategoryId" render={({ field }) => (
<FormItem><FormLabel>Beitragskategorie</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Keine </option>
{duesCategories.map(c => <option key={c.id} value={c.id}>{c.name} ({c.amount} )</option>)}
</select>
</FormControl><FormMessage /></FormItem>
)} />
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="iban" render={({ field }) => (
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input placeholder="DE89 3704 0044 0532 0130 00" {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bic" render={({ field }) => (
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="accountHolder" render={({ field }) => (
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
{/* Guardian (Gap 4) */}
<Card>
<CardHeader><CardTitle>Erziehungsberechtigte (Jugend)</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="guardianName" render={({ field }) => (
<FormItem><FormLabel>Name Erziehungsberechtigte/r</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input type="tel" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
{/* Lifecycle flags (Gap 4) */}
<Card>
<CardHeader><CardTitle>Mitgliedschaftsmerkmale</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{([
['isHonorary', 'Ehrenmitglied'],
['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugendmitglied'],
['isRetiree', 'Rentner/Senior'],
['isProbationary', 'Probejahr'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)} />
))}
</CardContent>
</Card>
{/* GDPR granular (Gap 4) */}
<Card>
<CardHeader><CardTitle>Datenschutz-Einwilligungen</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{([
['gdprConsent', 'Allgemeine Einwilligung'],
['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet/Homepage'],
['gdprPrint', 'Vereinszeitung'],
['gdprBirthdayInfo', 'Geburtstagsinfo'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4 rounded border-input" />
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)} />
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Sonstiges</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="salutation" render={({ field }) => (
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""> Keine </option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="birthplace" render={({ field }) => (
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="street2" render={({ field }) => (
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormLabel>Notizen</FormLabel><FormControl><textarea {...field} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,258 @@
'use client';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
createDuesCategory,
deleteDuesCategory,
} from '../server/actions/member-actions';
interface DuesCategoryManagerProps {
categories: Array<Record<string, unknown>>;
accountId: string;
}
const INTERVAL_LABELS: Record<string, string> = {
monthly: 'Monatlich',
quarterly: 'Vierteljährlich',
semiannual: 'Halbjährlich',
annual: 'Jährlich',
};
interface CategoryFormValues {
name: string;
description: string;
amount: number;
interval: string;
isDefault: boolean;
}
export function DuesCategoryManager({
categories,
accountId,
}: DuesCategoryManagerProps) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const form = useForm<CategoryFormValues>({
defaultValues: {
name: '',
description: '',
amount: 0,
interval: 'annual',
isDefault: false,
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createDuesCategory,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Beitragskategorie erstellt');
form.reset();
setShowForm(false);
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
},
);
const { execute: executeDelete, isPending: isDeletePending } = useAction(
deleteDuesCategory,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Beitragskategorie gelöscht');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Löschen');
},
},
);
const handleSubmit = useCallback(
(values: CategoryFormValues) => {
executeCreate({
accountId,
name: values.name,
description: values.description,
amount: Number(values.amount),
interval: values.interval as 'monthly' | 'quarterly' | 'half_yearly' | 'yearly',
isDefault: values.isDefault,
});
},
[executeCreate, accountId],
);
const handleDelete = useCallback(
(categoryId: string, categoryName: string) => {
if (
!window.confirm(
`Beitragskategorie "${categoryName}" wirklich löschen?`,
)
) {
return;
}
executeDelete({ categoryId });
},
[executeDelete],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Beitragskategorien</h2>
<Button
size="sm"
variant={showForm ? 'outline' : 'default'}
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Abbrechen' : 'Neue Kategorie'}
</Button>
</div>
{/* Inline Create Form */}
{showForm && (
<Card>
<CardHeader>
<CardTitle>Neue Beitragskategorie</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
>
<div className="space-y-1">
<label className="text-sm font-medium">Name *</label>
<Input
placeholder="z.B. Standardbeitrag"
{...form.register('name', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Betrag () *</label>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
{...form.register('amount', {
required: true,
valueAsNumber: true,
})}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Intervall</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...form.register('interval')}
>
<option value="monthly">Monatlich</option>
<option value="quarterly">Vierteljährlich</option>
<option value="semiannual">Halbjährlich</option>
<option value="annual">Jährlich</option>
</select>
</div>
<div className="flex items-end space-x-2">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="rounded border-input"
{...form.register('isDefault')}
/>
Standard
</label>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isCreating} className="w-full">
{isCreating ? 'Erstelle...' : 'Erstellen'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">Beschreibung</th>
<th className="px-4 py-3 text-right font-medium">Betrag</th>
<th className="px-4 py-3 text-left font-medium">Intervall</th>
<th className="px-4 py-3 text-center font-medium">Standard</th>
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{categories.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-muted-foreground"
>
Keine Beitragskategorien vorhanden.
</td>
</tr>
) : (
categories.map((cat) => {
const catId = String(cat.id ?? '');
const catName = String(cat.name ?? '');
const interval = String(cat.interval ?? '');
const amount = Number(cat.amount ?? 0);
const isDefault = Boolean(cat.is_default);
return (
<tr key={catId} className="border-b">
<td className="px-4 py-3 font-medium">{catName}</td>
<td className="px-4 py-3 text-muted-foreground">
{String(cat.description ?? '—')}
</td>
<td className="px-4 py-3 text-right font-mono">
{amount.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})}
</td>
<td className="px-4 py-3">
{INTERVAL_LABELS[interval] ?? interval}
</td>
<td className="px-4 py-3 text-center">
{isDefault ? '✓' : '✗'}
</td>
<td className="px-4 py-3 text-right">
<Button
size="sm"
variant="destructive"
disabled={isDeletePending}
onClick={() => handleDelete(catId, catName)}
>
Löschen
</Button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { UpdateMemberSchema } from '../schema/member.schema';
import { updateMember } from '../server/actions/member-actions';
interface Props {
member: Record<string, unknown>;
account: string;
accountId: string;
}
export function EditMemberForm({ member, account, accountId }: Props) {
const router = useRouter();
const memberId = String(member.id);
const form = useForm({
resolver: zodResolver(UpdateMemberSchema),
defaultValues: {
memberId,
accountId,
firstName: String(member.first_name ?? ''),
lastName: String(member.last_name ?? ''),
email: String(member.email ?? ''),
phone: String(member.phone ?? ''),
mobile: String(member.mobile ?? ''),
street: String(member.street ?? ''),
houseNumber: String(member.house_number ?? ''),
postalCode: String(member.postal_code ?? ''),
city: String(member.city ?? ''),
status: String(member.status ?? 'active') as 'active',
memberNumber: String(member.member_number ?? ''),
salutation: String(member.salutation ?? ''),
birthplace: String(member.birthplace ?? ''),
street2: String(member.street2 ?? ''),
guardianName: String(member.guardian_name ?? ''),
guardianPhone: String(member.guardian_phone ?? ''),
guardianEmail: String(member.guardian_email ?? ''),
iban: String(member.iban ?? ''),
bic: String(member.bic ?? ''),
accountHolder: String(member.account_holder ?? ''),
notes: String(member.notes ?? ''),
isHonorary: Boolean(member.is_honorary),
isFoundingMember: Boolean(member.is_founding_member),
isYouth: Boolean(member.is_youth),
isRetiree: Boolean(member.is_retiree),
isProbationary: Boolean(member.is_probationary),
gdprConsent: Boolean(member.gdpr_consent),
gdprNewsletter: Boolean(member.gdpr_newsletter),
gdprInternet: Boolean(member.gdpr_internet),
gdprPrint: Boolean(member.gdpr_print),
gdprBirthdayInfo: Boolean(member.gdpr_birthday_info),
},
});
const { execute, isPending } = useAction(updateMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied aktualisiert');
router.push(`/home/${account}/members-cms/${memberId}`);
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="salutation" render={({ field }) => (
<FormItem><FormLabel>Anrede</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value=""></option><option value="Herr">Herr</option><option value="Frau">Frau</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<div />
<FormField control={form.control} name="firstName" render={({ field }) => (
<FormItem><FormLabel>Vorname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="lastName" render={({ field }) => (
<FormItem><FormLabel>Nachname *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="memberNumber" render={({ field }) => (
<FormItem><FormLabel>Mitgliedsnr.</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="birthplace" render={({ field }) => (
<FormItem><FormLabel>Geburtsort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="phone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="mobile" render={({ field }) => (
<FormItem><FormLabel>Mobil</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Adresse</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="street" render={({ field }) => (
<FormItem><FormLabel>Straße</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="houseNumber" render={({ field }) => (
<FormItem><FormLabel>Hausnummer</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="street2" render={({ field }) => (
<FormItem><FormLabel>Adresszusatz</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<div />
<FormField control={form.control} name="postalCode" render={({ field }) => (
<FormItem><FormLabel>PLZ</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="city" render={({ field }) => (
<FormItem><FormLabel>Ort</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SEPA-Bankdaten</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="iban" render={({ field }) => (
<FormItem><FormLabel>IBAN</FormLabel><FormControl><Input {...field} onChange={(e) => field.onChange(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="bic" render={({ field }) => (
<FormItem><FormLabel>BIC</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="accountHolder" render={({ field }) => (
<FormItem><FormLabel>Kontoinhaber</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Erziehungsberechtigte</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField control={form.control} name="guardianName" render={({ field }) => (
<FormItem><FormLabel>Name</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="guardianEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Merkmale & Datenschutz</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{([
['isHonorary', 'Ehrenmitglied'], ['isFoundingMember', 'Gründungsmitglied'],
['isYouth', 'Jugend'], ['isRetiree', 'Rentner'],
['isProbationary', 'Probejahr'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)} />
))}
</div>
<div className="border-t pt-3">
<p className="text-xs font-medium text-muted-foreground mb-2">DSGVO-Einwilligungen</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{([
['gdprConsent', 'Allgemein'], ['gdprNewsletter', 'Newsletter'],
['gdprInternet', 'Internet'], ['gdprPrint', 'Zeitung'],
['gdprBirthdayInfo', 'Geburtstag'],
] as const).map(([name, label]) => (
<FormField key={name} control={form.control} name={name} render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl><input type="checkbox" checked={field.value as boolean} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<FormLabel className="!mt-0 text-sm">{label}</FormLabel>
</FormItem>
)} />
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Notizen</CardTitle></CardHeader>
<CardContent>
<FormField control={form.control} name="notes" render={({ field }) => (
<FormItem><FormControl><textarea {...field} rows={4} className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}</Button>
</div>
</form>
</Form>
);
}

View File

@@ -1,3 +1,8 @@
export {};
// Phase 4 components: members-table, member-form, member-detail,
// application-workflow, dues-category-manager, member-statistics-dashboard
export { CreateMemberForm } from './create-member-form';
export { EditMemberForm } from './edit-member-form';
export { MembersDataTable } from './members-data-table';
export { MemberDetailView } from './member-detail-view';
export { ApplicationWorkflow } from './application-workflow';
export { DuesCategoryManager } from './dues-category-manager';
export { MandateManager } from './mandate-manager';
export { MemberImportWizard } from './member-import-wizard';

View File

@@ -0,0 +1,309 @@
'use client';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { formatIban } from '../lib/member-utils';
import { createMandate, revokeMandate } from '../server/actions/member-actions';
interface MandateManagerProps {
mandates: Array<Record<string, unknown>>;
memberId: string;
accountId: string;
}
const SEQUENCE_LABELS: Record<string, string> = {
FRST: 'Erstlastschrift',
RCUR: 'Wiederkehrend',
FNAL: 'Letzte',
OOFF: 'Einmalig',
};
function getMandateStatusColor(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active':
return 'default';
case 'pending':
return 'outline';
case 'revoked':
case 'expired':
return 'destructive';
default:
return 'secondary';
}
}
const MANDATE_STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
pending: 'Ausstehend',
revoked: 'Widerrufen',
expired: 'Abgelaufen',
};
interface MandateFormValues {
mandateReference: string;
iban: string;
bic: string;
accountHolder: string;
mandateDate: string;
sequence: string;
}
export function MandateManager({
mandates,
memberId,
accountId,
}: MandateManagerProps) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const form = useForm<MandateFormValues>({
defaultValues: {
mandateReference: '',
iban: '',
bic: '',
accountHolder: '',
mandateDate: new Date().toISOString().split('T')[0]!,
sequence: 'FRST',
},
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createMandate,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('SEPA-Mandat erstellt');
form.reset();
setShowForm(false);
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Erstellen');
},
},
);
const { execute: executeRevoke, isPending: isRevoking } = useAction(
revokeMandate,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mandat widerrufen');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Widerrufen');
},
},
);
const handleSubmit = useCallback(
(values: MandateFormValues) => {
executeCreate({
memberId,
accountId,
mandateReference: values.mandateReference,
iban: values.iban,
bic: values.bic,
accountHolder: values.accountHolder,
mandateDate: values.mandateDate,
sequence: values.sequence as "FRST" | "RCUR" | "FNAL" | "OOFF",
});
},
[executeCreate, memberId, accountId],
);
const handleRevoke = useCallback(
(mandateId: string, reference: string) => {
if (
!window.confirm(
`Mandat "${reference}" wirklich widerrufen?`,
)
) {
return;
}
executeRevoke({ mandateId });
},
[executeRevoke],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">SEPA-Mandate</h2>
<Button
size="sm"
variant={showForm ? 'outline' : 'default'}
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Abbrechen' : 'Neues Mandat'}
</Button>
</div>
{/* Inline Create Form */}
{showForm && (
<Card>
<CardHeader>
<CardTitle>Neues SEPA-Mandat</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<div className="space-y-1">
<label className="text-sm font-medium">Mandatsreferenz *</label>
<Input
placeholder="MANDATE-001"
{...form.register('mandateReference', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">IBAN *</label>
<Input
placeholder="DE89 3704 0044 0532 0130 00"
{...form.register('iban', { required: true })}
onChange={(e) => {
const value = e.target.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
form.setValue('iban', value);
}}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">BIC</label>
<Input
placeholder="COBADEFFXXX"
{...form.register('bic')}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Kontoinhaber *</label>
<Input
placeholder="Max Mustermann"
{...form.register('accountHolder', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Mandatsdatum *</label>
<Input
type="date"
{...form.register('mandateDate', { required: true })}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Sequenz</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
{...form.register('sequence')}
>
<option value="FRST">FRST Erstlastschrift</option>
<option value="RCUR">RCUR Wiederkehrend</option>
<option value="FNAL">FNAL Letzte</option>
<option value="OOFF">OOFF Einmalig</option>
</select>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<Button type="submit" disabled={isCreating}>
{isCreating ? 'Erstelle...' : 'Mandat erstellen'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Referenz</th>
<th className="px-4 py-3 text-left font-medium">IBAN</th>
<th className="px-4 py-3 text-left font-medium">Kontoinhaber</th>
<th className="px-4 py-3 text-left font-medium">Datum</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-center font-medium">Primär</th>
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{mandates.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-muted-foreground"
>
Keine SEPA-Mandate vorhanden.
</td>
</tr>
) : (
mandates.map((mandate) => {
const mandateId = String(mandate.id ?? '');
const reference = String(mandate.mandate_reference ?? '—');
const mandateStatus = String(mandate.status ?? 'pending');
const isPrimary = Boolean(mandate.is_primary);
const canRevoke =
mandateStatus === 'active' || mandateStatus === 'pending';
return (
<tr key={mandateId} className="border-b">
<td className="px-4 py-3 font-mono text-xs">
{reference}
</td>
<td className="px-4 py-3 font-mono text-xs">
{formatIban(mandate.iban as string | null | undefined)}
</td>
<td className="px-4 py-3">
{String(mandate.account_holder ?? '—')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{mandate.mandate_date
? new Date(
String(mandate.mandate_date),
).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="px-4 py-3">
<Badge variant={getMandateStatusColor(mandateStatus)}>
{MANDATE_STATUS_LABELS[mandateStatus] ?? mandateStatus}
</Badge>
</td>
<td className="px-4 py-3 text-center">
{isPrimary ? '✓' : '✗'}
</td>
<td className="px-4 py-3 text-right">
{canRevoke && (
<Button
size="sm"
variant="destructive"
disabled={isRevoking}
onClick={() => handleRevoke(mandateId, reference)}
>
Widerrufen
</Button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
STATUS_LABELS,
getMemberStatusColor,
formatAddress,
formatIban,
computeAge,
computeMembershipYears,
} from '../lib/member-utils';
import { deleteMember, updateMember } from '../server/actions/member-actions';
interface MemberDetailViewProps {
member: Record<string, unknown>;
account: string;
accountId: string;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 border-b py-2 last:border-b-0">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<span className="text-sm text-right">{value ?? '—'}</span>
</div>
);
}
export function MemberDetailView({ member, account, accountId }: MemberDetailViewProps) {
const router = useRouter();
const memberId = String(member.id ?? '');
const status = String(member.status ?? 'active');
const firstName = String(member.first_name ?? '');
const lastName = String(member.last_name ?? '');
const fullName = `${firstName} ${lastName}`.trim();
const form = useForm();
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde gekündigt');
router.push(`/home/${account}/members-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Kündigen');
},
});
const { execute: executeUpdate, isPending: isUpdating } = useAction(updateMember, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde archiviert');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Archivieren');
},
});
const handleDelete = useCallback(() => {
if (!window.confirm(`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
return;
}
executeDelete({ memberId, accountId });
}, [executeDelete, memberId, accountId, fullName]);
const handleArchive = useCallback(() => {
if (!window.confirm(`Möchten Sie ${fullName} wirklich archivieren?`)) {
return;
}
executeUpdate({
memberId,
accountId,
isArchived: true,
});
}, [executeUpdate, memberId, accountId, fullName]);
const age = computeAge(member.date_of_birth as string | null | undefined);
const membershipYears = computeMembershipYears(member.entry_date as string | null | undefined);
const address = formatAddress(member);
const iban = formatIban(member.iban as string | null | undefined);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{fullName}</h1>
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Mitgliedsnr. {String(member.member_number ?? '—')}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() =>
router.push(`/home/${account}/members-cms/${memberId}/edit`)
}
>
Bearbeiten
</Button>
<Button
variant="outline"
disabled={isUpdating}
onClick={handleArchive}
>
{isUpdating ? 'Archiviere...' : 'Archivieren'}
</Button>
<Button
variant="destructive"
disabled={isDeleting}
onClick={handleDelete}
>
{isDeleting ? 'Wird gekündigt...' : 'Kündigen'}
</Button>
</div>
</div>
{/* Detail Cards */}
<div className="grid gap-6 md:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Vorname" value={firstName} />
<DetailRow label="Nachname" value={lastName} />
<DetailRow
label="Geburtsdatum"
value={
member.date_of_birth
? `${new Date(String(member.date_of_birth)).toLocaleDateString('de-DE')}${age !== null ? ` (${age} Jahre)` : ''}`
: null
}
/>
<DetailRow label="Geschlecht" value={String(member.gender ?? '—')} />
<DetailRow label="Anrede" value={String(member.salutation ?? '—')} />
</CardContent>
</Card>
{/* Kontakt */}
<Card>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="E-Mail" value={String(member.email ?? '—')} />
<DetailRow label="Telefon" value={String(member.phone ?? '—')} />
<DetailRow label="Mobil" value={String(member.mobile ?? '—')} />
</CardContent>
</Card>
{/* Adresse */}
<Card>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Adresse" value={address || '—'} />
<DetailRow label="PLZ" value={String(member.postal_code ?? '—')} />
<DetailRow label="Ort" value={String(member.city ?? '—')} />
<DetailRow label="Land" value={String(member.country ?? 'DE')} />
</CardContent>
</Card>
{/* Mitgliedschaft */}
<Card>
<CardHeader>
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Mitgliedsnr." value={String(member.member_number ?? '—')} />
<DetailRow
label="Status"
value={
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
}
/>
<DetailRow
label="Eintrittsdatum"
value={
member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'
}
/>
<DetailRow
label="Mitgliedsjahre"
value={membershipYears > 0 ? `${membershipYears} Jahre` : '—'}
/>
<DetailRow label="IBAN" value={iban} />
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
<DetailRow label="Kontoinhaber" value={String(member.account_holder ?? '—')} />
<DetailRow label="Notizen" value={String(member.notes ?? '—')} />
</CardContent>
</Card>
</div>
{/* Back */}
<div>
<Button variant="ghost" onClick={() => router.back()}>
Zurück zur Übersicht
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Papa from 'papaparse';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Badge } from '@kit/ui/badge';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { Upload, ArrowRight, ArrowLeft, CheckCircle, AlertTriangle } from 'lucide-react';
import { createMember } from '../server/actions/member-actions';
const MEMBER_FIELDS = [
{ key: 'memberNumber', label: 'Mitgliedsnr.' },
{ key: 'salutation', label: 'Anrede' },
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{ key: 'dateOfBirth', label: 'Geburtsdatum' },
{ key: 'email', label: 'E-Mail' },
{ key: 'phone', label: 'Telefon' },
{ key: 'mobile', label: 'Mobil' },
{ key: 'street', label: 'Straße' },
{ key: 'houseNumber', label: 'Hausnummer' },
{ key: 'postalCode', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'entryDate', label: 'Eintrittsdatum' },
{ key: 'iban', label: 'IBAN' },
{ key: 'bic', label: 'BIC' },
{ key: 'accountHolder', label: 'Kontoinhaber' },
{ key: 'notes', label: 'Notizen' },
] as const;
interface Props {
accountId: string;
account: string;
}
type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done';
export function MemberImportWizard({ accountId, account }: Props) {
const router = useRouter();
const [step, setStep] = useState<Step>('upload');
const [rawData, setRawData] = useState<string[][]>([]);
const [headers, setHeaders] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [importResults, setImportResults] = useState<{ success: number; errors: string[] }>({ success: 0, errors: [] });
const { execute: executeCreate } = useAction(createMember);
// Step 1: Parse file
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
Papa.parse(file, {
delimiter: ';',
encoding: 'UTF-8',
complete: (result) => {
const data = result.data as string[][];
if (data.length < 2) {
toast.error('Datei enthält keine Daten');
return;
}
setHeaders(data[0]!);
setRawData(data.slice(1).filter(row => row.some(cell => cell?.trim())));
// Auto-map by header name similarity
const autoMap: Record<string, string> = {};
for (const field of MEMBER_FIELDS) {
const match = data[0]!.findIndex(h =>
h.toLowerCase().includes(field.label.toLowerCase().replace('.', '')) ||
h.toLowerCase().includes(field.key.toLowerCase())
);
if (match >= 0) autoMap[field.key] = String(match);
}
setMapping(autoMap);
setStep('mapping');
toast.success(`${data.length - 1} Zeilen erkannt`);
},
error: (err) => {
toast.error(`Fehler beim Lesen: ${err.message}`);
},
});
}, []);
// Step 3: Execute import
const executeImport = useCallback(async () => {
setStep('importing');
let success = 0;
const errors: string[] = [];
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i]!;
try {
const memberData: Record<string, string> = { accountId };
for (const field of MEMBER_FIELDS) {
const colIdx = mapping[field.key];
if (colIdx !== undefined && row[Number(colIdx)]) {
memberData[field.key] = row[Number(colIdx)]!.trim();
}
}
if (!memberData.firstName || !memberData.lastName) {
errors.push(`Zeile ${i + 2}: Vor-/Nachname fehlt`);
continue;
}
await executeCreate(memberData as any);
success++;
} catch (err) {
errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
}
}
setImportResults({ success, errors });
setStep('done');
toast.success(`${success} Mitglieder importiert`);
}, [rawData, mapping, accountId, executeCreate]);
const getMappedValue = (rowIdx: number, fieldKey: string): string => {
const colIdx = mapping[fieldKey];
if (colIdx === undefined) return '';
return rawData[rowIdx]?.[Number(colIdx)]?.trim() ?? '';
};
return (
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{(['upload', 'mapping', 'preview', 'done'] as const).map((s, i) => {
const labels = ['Datei hochladen', 'Spalten zuordnen', 'Vorschau & Import', 'Fertig'];
const isActive = ['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(step) >= i;
return (
<div key={s} className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
isActive ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
}`}>{i + 1}</div>
<span className={`text-sm ${isActive ? 'font-semibold' : 'text-muted-foreground'}`}>{labels[i]}</span>
{i < 3 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
);
})}
</div>
{/* Step 1: Upload */}
{step === 'upload' && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" />Datei hochladen</CardTitle></CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
<Upload className="mb-4 h-10 w-10 text-muted-foreground" />
<p className="text-lg font-semibold">CSV-Datei auswählen</p>
<p className="mt-1 text-sm text-muted-foreground">Semikolon-getrennt (;), UTF-8</p>
<input type="file" accept=".csv" onChange={handleFileUpload}
className="mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground" />
</div>
</CardContent>
</Card>
)}
{/* Step 2: Column mapping */}
{step === 'mapping' && (
<Card>
<CardHeader><CardTitle>Spalten zuordnen</CardTitle></CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den Mitgliedsfeldern zu.</p>
<div className="space-y-2">
{MEMBER_FIELDS.map(field => (
<div key={field.key} className="flex items-center gap-4">
<span className="w-40 text-sm font-medium">{field.label}</span>
<span className="text-muted-foreground"></span>
<select
value={mapping[field.key] ?? ''}
onChange={(e) => setMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
className="flex h-9 w-64 rounded-md border border-input bg-background px-3 py-1 text-sm"
>
<option value=""> Nicht zuordnen </option>
{headers.map((h, i) => (
<option key={i} value={String(i)}>{h}</option>
))}
</select>
{mapping[field.key] !== undefined && rawData[0] && (
<span className="text-xs text-muted-foreground">z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;</span>
)}
</div>
))}
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('upload')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button onClick={() => setStep('preview')}>Vorschau <ArrowRight className="ml-2 h-4 w-4" /></Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview + execute */}
{step === 'preview' && (
<Card>
<CardHeader><CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle></CardHeader>
<CardContent>
<div className="overflow-auto rounded-md border max-h-96">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-2 text-left">#</th>
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
<th key={f.key} className="p-2 text-left">{f.label}</th>
))}
</tr>
</thead>
<tbody>
{rawData.slice(0, 20).map((_, i) => {
const hasName = getMappedValue(i, 'firstName') && getMappedValue(i, 'lastName');
return (
<tr key={i} className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}>
<td className="p-2">{i + 1} {!hasName && <AlertTriangle className="inline h-3 w-3 text-destructive" />}</td>
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
<td key={f.key} className="p-2 max-w-32 truncate">{getMappedValue(i, f.key) || '—'}</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{rawData.length > 20 && <p className="mt-2 text-xs text-muted-foreground">... und {rawData.length - 20} weitere Einträge</p>}
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('mapping')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button onClick={executeImport}>
<CheckCircle className="mr-2 h-4 w-4" />
{rawData.length} Mitglieder importieren
</Button>
</div>
</CardContent>
</Card>
)}
{/* Importing */}
{step === 'importing' && (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="mt-4 text-lg font-semibold">Importiere Mitglieder...</p>
<p className="text-sm text-muted-foreground">Bitte warten Sie, bis der Import abgeschlossen ist.</p>
</CardContent>
</Card>
)}
{/* Done */}
{step === 'done' && (
<Card>
<CardContent className="p-8 text-center">
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
<h3 className="mt-4 text-xl font-semibold">Import abgeschlossen</h3>
<div className="mt-4 flex justify-center gap-4">
<Badge variant="default">{importResults.success} erfolgreich</Badge>
{importResults.errors.length > 0 && (
<Badge variant="destructive">{importResults.errors.length} Fehler</Badge>
)}
</div>
{importResults.errors.length > 0 && (
<div className="mt-4 max-h-40 overflow-auto rounded-md border p-3 text-left text-xs">
{importResults.errors.map((err, i) => (
<p key={i} className="text-destructive">{err}</p>
))}
</div>
)}
<div className="mt-6">
<Button onClick={() => router.push(`/home/${account}/members-cms`)}>
Zur Mitgliederliste
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
interface MembersDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
duesCategories: Array<{ id: string; name: string }>;
}
const STATUS_OPTIONS = [
{ value: '', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'inactive', label: 'Inaktiv' },
{ value: 'pending', label: 'Ausstehend' },
{ value: 'resigned', label: 'Ausgetreten' },
] as const;
export function MembersDataTable({
data,
total,
page,
pageSize,
account,
duesCategories,
}: MembersDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentSearch = searchParams.get('search') ?? '';
const currentStatus = searchParams.get('status') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({
defaultValues: {
search: currentSearch,
},
});
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
// Reset to page 1 on filter change
if (!('page' in updates)) {
params.delete('page');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const search = form.getValues('search');
updateParams({ search });
},
[form, updateParams],
);
const handleStatusChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ status: e.target.value });
},
[updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
const handleRowClick = useCallback(
(memberId: string) => {
router.push(`/home/${account}/members-cms/${memberId}`);
},
[router, account],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Mitglied suchen..."
className="w-64"
{...form.register('search')}
/>
<Button type="submit" variant="outline" size="sm">
Suchen
</Button>
</form>
<div className="flex items-center gap-3">
<select
value={currentStatus}
onChange={handleStatusChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
size="sm"
onClick={() =>
router.push(`/home/${account}/members-cms/new`)
}
>
Neues Mitglied
</Button>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Nr</th>
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">E-Mail</th>
<th className="px-4 py-3 text-left font-medium">Ort</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Eintritt</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
Keine Mitglieder gefunden.
</td>
</tr>
) : (
data.map((member) => {
const memberId = String(member.id ?? '');
const status = String(member.status ?? 'active');
return (
<tr
key={memberId}
onClick={() => handleRowClick(memberId)}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
>
<td className="px-4 py-3 font-mono text-xs">
{String(member.member_number ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.last_name ?? '')},{' '}
{String(member.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
{String(member.email ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.city ?? '—')}
</td>
<td className="px-4 py-3">
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<span className="text-sm">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Client-side utility functions for member display.
*/
export function computeAge(dateOfBirth: string | null | undefined): number | null {
if (!dateOfBirth) return null;
const birth = new Date(dateOfBirth);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
}
export function computeMembershipYears(entryDate: string | null | undefined): number {
if (!entryDate) return 0;
const entry = new Date(entryDate);
const today = new Date();
let years = today.getFullYear() - entry.getFullYear();
const m = today.getMonth() - entry.getMonth();
if (m < 0 || (m === 0 && today.getDate() < entry.getDate())) years--;
return Math.max(0, years);
}
export function formatSalutation(salutation: string | null | undefined, firstName: string, lastName: string): string {
if (salutation) return `${salutation} ${firstName} ${lastName}`;
return `${firstName} ${lastName}`;
}
export function formatAddress(member: Record<string, unknown>): string {
const parts: string[] = [];
if (member.street) {
let line = String(member.street);
if (member.house_number) line += ` ${member.house_number}`;
parts.push(line);
}
if (member.street2) parts.push(String(member.street2));
if (member.postal_code || member.city) {
parts.push(`${member.postal_code ?? ''} ${member.city ?? ''}`.trim());
}
return parts.join(', ');
}
export function formatIban(iban: string | null | undefined): string {
if (!iban) return '—';
const cleaned = iban.replace(/\s/g, '');
return cleaned.replace(/(.{4})/g, '$1 ').trim();
}
export function getMemberStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active': return 'default';
case 'inactive': return 'secondary';
case 'pending': return 'outline';
case 'resigned':
case 'excluded':
case 'deceased': return 'destructive';
default: return 'secondary';
}
}
export const STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
inactive: 'Inaktiv',
pending: 'Ausstehend',
resigned: 'Ausgetreten',
excluded: 'Ausgeschlossen',
deceased: 'Verstorben',
};

View File

@@ -33,12 +33,42 @@ export const CreateMemberSchema = z.object({
accountHolder: z.string().max(128).optional(),
gdprConsent: z.boolean().default(false),
notes: z.string().optional(),
// New optional fields
salutation: z.string().optional(),
street2: z.string().optional(),
phone2: z.string().optional(),
fax: z.string().optional(),
birthplace: z.string().optional(),
birthCountry: z.string().default('DE'),
isHonorary: z.boolean().default(false),
isFoundingMember: z.boolean().default(false),
isYouth: z.boolean().default(false),
isRetiree: z.boolean().default(false),
isProbationary: z.boolean().default(false),
isTransferred: z.boolean().default(false),
exitDate: z.string().optional(),
exitReason: z.string().optional(),
guardianName: z.string().optional(),
guardianPhone: z.string().optional(),
guardianEmail: z.string().optional(),
duesYear: z.number().int().optional(),
duesPaid: z.boolean().default(false),
additionalFees: z.number().default(0),
exemptionType: z.string().optional(),
exemptionReason: z.string().optional(),
exemptionAmount: z.number().optional(),
gdprNewsletter: z.boolean().default(false),
gdprInternet: z.boolean().default(false),
gdprPrint: z.boolean().default(false),
gdprBirthdayInfo: z.boolean().default(false),
sepaMandateReference: z.string().optional(),
});
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
memberId: z.string().uuid(),
isArchived: z.boolean().optional(),
});
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
@@ -50,6 +80,77 @@ export const CreateDuesCategorySchema = z.object({
amount: z.number().min(0),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
isDefault: z.boolean().default(false),
isYouth: z.boolean().default(false),
isExit: z.boolean().default(false),
});
export type CreateDuesCategoryInput = z.infer<typeof CreateDuesCategorySchema>;
export const RejectApplicationSchema = z.object({
applicationId: z.string().uuid(),
accountId: z.string().uuid(),
reviewNotes: z.string().optional(),
});
export const CreateDepartmentSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(128),
description: z.string().optional(),
});
export const CreateMemberRoleSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
roleName: z.string().min(1),
fromDate: z.string().optional(),
untilDate: z.string().optional(),
});
export const CreateMemberHonorSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
honorName: z.string().min(1),
honorDate: z.string().optional(),
description: z.string().optional(),
});
export const CreateSepaMandateSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
mandateReference: z.string().min(1),
iban: z.string().min(15).max(34),
bic: z.string().optional(),
accountHolder: z.string().min(1),
mandateDate: z.string(),
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).default('RCUR'),
});
export const UpdateDuesCategorySchema = z.object({
categoryId: z.string().uuid(),
name: z.string().min(1).optional(),
description: z.string().optional(),
amount: z.number().min(0).optional(),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).optional(),
isDefault: z.boolean().optional(),
});
export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;
export const UpdateMandateSchema = z.object({
mandateId: z.string().uuid(),
iban: z.string().min(15).max(34).optional(),
bic: z.string().optional(),
accountHolder: z.string().optional(),
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(),
});
export type UpdateMandateInput = z.infer<typeof UpdateMandateSchema>;
export const ExportMembersSchema = z.object({
accountId: z.string().uuid(),
status: z.string().optional(),
format: z.enum(['csv', 'excel']).default('csv'),
});
export const AssignDepartmentSchema = z.object({
memberId: z.string().uuid(),
departmentId: z.string().uuid(),
});

View File

@@ -0,0 +1,302 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateMemberSchema,
UpdateMemberSchema,
RejectApplicationSchema,
CreateDuesCategorySchema,
CreateDepartmentSchema,
CreateMemberRoleSchema,
CreateMemberHonorSchema,
CreateSepaMandateSchema,
UpdateDuesCategorySchema,
UpdateMandateSchema,
ExportMembersSchema,
AssignDepartmentSchema,
} from '../../schema/member.schema';
import { createMemberManagementApi } from '../api';
export const createMember = authActionClient
.inputSchema(CreateMemberSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.create' }, 'Creating member...');
const result = await api.createMember(input, userId);
logger.info({ name: 'member.create' }, 'Member created');
return { success: true, data: result };
});
export const updateMember = authActionClient
.inputSchema(UpdateMemberSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.update' }, 'Updating member...');
const result = await api.updateMember(input, userId);
logger.info({ name: 'member.update' }, 'Member updated');
return { success: true, data: result };
});
export const deleteMember = authActionClient
.inputSchema(
z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'member.delete' }, 'Deleting member...');
const result = await api.deleteMember(input.memberId);
logger.info({ name: 'member.delete' }, 'Member deleted');
return { success: true, data: result };
});
export const approveApplication = authActionClient
.inputSchema(
z.object({
applicationId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.approveApplication' }, 'Approving application...');
const result = await api.approveApplication(input.applicationId, userId);
logger.info({ name: 'member.approveApplication' }, 'Application approved');
return { success: true, data: result };
});
export const rejectApplication = authActionClient
.inputSchema(RejectApplicationSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'members.reject-application' }, 'Rejecting application...');
await api.rejectApplication(input.applicationId, ctx.user.id, input.reviewNotes);
return { success: true };
});
export const createDuesCategory = authActionClient
.inputSchema(CreateDuesCategorySchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createDuesCategory(input);
return { success: true, data };
});
export const deleteDuesCategory = authActionClient
.inputSchema(z.object({ categoryId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteDuesCategory(input.categoryId);
return { success: true };
});
export const createDepartment = authActionClient
.inputSchema(CreateDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createDepartment(input);
return { success: true, data };
});
export const createMemberRole = authActionClient
.inputSchema(CreateMemberRoleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMemberRole(input);
return { success: true, data };
});
export const deleteMemberRole = authActionClient
.inputSchema(z.object({ roleId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteMemberRole(input.roleId);
return { success: true };
});
export const createMemberHonor = authActionClient
.inputSchema(CreateMemberHonorSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMemberHonor(input);
return { success: true, data };
});
export const deleteMemberHonor = authActionClient
.inputSchema(z.object({ honorId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteMemberHonor(input.honorId);
return { success: true };
});
export const createMandate = authActionClient
.inputSchema(CreateSepaMandateSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMandate(input);
return { success: true, data };
});
export const revokeMandate = authActionClient
.inputSchema(z.object({ mandateId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.revokeMandate(input.mandateId);
return { success: true };
});
// Gap 1: Update operations
export const updateDuesCategory = authActionClient
.inputSchema(UpdateDuesCategorySchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.updateDuesCategory(input);
return { success: true, data };
});
export const updateMandate = authActionClient
.inputSchema(UpdateMandateSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.updateMandate(input);
return { success: true, data };
});
// Gap 2: Export
export const exportMembers = authActionClient
.inputSchema(ExportMembersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const csv = await api.exportMembersCsv(input.accountId, { status: input.status });
return { success: true, csv };
});
// Gap 5: Department assignments
export const assignDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.assignDepartment(input.memberId, input.departmentId);
return { success: true };
});
export const removeDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.removeDepartment(input.memberId, input.departmentId);
return { success: true };
});
// Gap 2: Excel export
export const exportMembersExcel = authActionClient
.inputSchema(ExportMembersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const buffer = await api.exportMembersExcel(input.accountId, { status: input.status });
// Return base64 for client-side download
return { success: true, base64: buffer.toString('base64'), filename: `mitglieder_${new Date().toISOString().split('T')[0]}.xlsx` };
});
// Gap 6: Member card PDF generation
export const generateMemberCards = authActionClient
.inputSchema(z.object({
accountId: z.string().uuid(),
memberIds: z.array(z.string().uuid()).optional(),
orgName: z.string().default('Verein'),
}))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
let query = client.from('members').select('id, first_name, last_name, member_number, entry_date, status')
.eq('account_id', input.accountId).eq('status', 'active');
if (input.memberIds && input.memberIds.length > 0) {
query = query.in('id', input.memberIds);
}
const { data: members, error } = await query;
if (error) throw error;
const { generateMemberCardsPdf } = await import('../services/member-card-generator');
const buffer = await generateMemberCardsPdf(
input.orgName,
(members ?? []).map((m: any) => ({
firstName: m.first_name, lastName: m.last_name,
memberNumber: m.member_number ?? '', entryDate: m.entry_date ?? '',
status: m.status,
})),
);
return { success: true, base64: buffer.toString('base64'), filename: `mitgliedsausweise_${new Date().toISOString().split('T')[0]}.pdf` };
});
// Portal Invitations
export const inviteMemberToPortal = authActionClient
.inputSchema(z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
email: z.string().email(),
}))
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info({ name: 'portal.invite', memberId: input.memberId }, 'Sending portal invitation...');
const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
// Create auth user for the member if not exists
// In production: send invitation email with the token link
// For now: create the user directly via admin API
logger.info({ name: 'portal.invite', token: invitation.invite_token }, 'Invitation created');
return { success: true, data: invitation };
});
export const revokePortalInvitation = authActionClient
.inputSchema(z.object({ invitationId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.revokePortalInvitation(input.invitationId);
return { success: true };
});

View File

@@ -65,6 +65,33 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
gdpr_consent: input.gdprConsent,
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
notes: input.notes,
// New parity fields
salutation: input.salutation,
street2: input.street2,
phone2: input.phone2,
fax: input.fax,
birthplace: input.birthplace,
birth_country: input.birthCountry,
is_honorary: input.isHonorary,
is_founding_member: input.isFoundingMember,
is_youth: input.isYouth,
is_retiree: input.isRetiree,
is_probationary: input.isProbationary,
is_transferred: input.isTransferred,
guardian_name: input.guardianName,
guardian_phone: input.guardianPhone,
guardian_email: input.guardianEmail,
dues_year: input.duesYear,
dues_paid: input.duesPaid,
additional_fees: input.additionalFees,
exemption_type: input.exemptionType,
exemption_reason: input.exemptionReason,
exemption_amount: input.exemptionAmount,
gdpr_newsletter: input.gdprNewsletter,
gdpr_internet: input.gdprInternet,
gdpr_print: input.gdprPrint,
gdpr_birthday_info: input.gdprBirthdayInfo,
sepa_mandate_reference: input.sepaMandateReference,
created_by: userId,
updated_by: userId,
})
@@ -89,7 +116,45 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
if (input.status !== undefined) updateData.status = input.status;
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.notes !== undefined) updateData.notes = input.notes;
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
// New parity fields
if (input.salutation !== undefined) updateData.salutation = input.salutation;
if (input.street2 !== undefined) updateData.street2 = input.street2;
if (input.phone2 !== undefined) updateData.phone2 = input.phone2;
if (input.fax !== undefined) updateData.fax = input.fax;
if (input.birthplace !== undefined) updateData.birthplace = input.birthplace;
if (input.birthCountry !== undefined) updateData.birth_country = input.birthCountry;
if (input.title !== undefined) updateData.title = input.title;
if (input.dateOfBirth !== undefined) updateData.date_of_birth = input.dateOfBirth;
if (input.gender !== undefined) updateData.gender = input.gender;
if (input.country !== undefined) updateData.country = input.country;
if (input.entryDate !== undefined) updateData.entry_date = input.entryDate;
if (input.exitDate !== undefined) updateData.exit_date = input.exitDate;
if (input.exitReason !== undefined) updateData.exit_reason = input.exitReason;
if (input.isHonorary !== undefined) updateData.is_honorary = input.isHonorary;
if (input.isFoundingMember !== undefined) updateData.is_founding_member = input.isFoundingMember;
if (input.isYouth !== undefined) updateData.is_youth = input.isYouth;
if (input.isRetiree !== undefined) updateData.is_retiree = input.isRetiree;
if (input.isProbationary !== undefined) updateData.is_probationary = input.isProbationary;
if (input.isTransferred !== undefined) updateData.is_transferred = input.isTransferred;
if (input.guardianName !== undefined) updateData.guardian_name = input.guardianName;
if (input.guardianPhone !== undefined) updateData.guardian_phone = input.guardianPhone;
if (input.guardianEmail !== undefined) updateData.guardian_email = input.guardianEmail;
if (input.duesYear !== undefined) updateData.dues_year = input.duesYear;
if (input.duesPaid !== undefined) updateData.dues_paid = input.duesPaid;
if (input.additionalFees !== undefined) updateData.additional_fees = input.additionalFees;
if (input.exemptionType !== undefined) updateData.exemption_type = input.exemptionType;
if (input.exemptionReason !== undefined) updateData.exemption_reason = input.exemptionReason;
if (input.exemptionAmount !== undefined) updateData.exemption_amount = input.exemptionAmount;
if (input.gdprConsent !== undefined) updateData.gdpr_consent = input.gdprConsent;
if (input.gdprNewsletter !== undefined) updateData.gdpr_newsletter = input.gdprNewsletter;
if (input.gdprInternet !== undefined) updateData.gdpr_internet = input.gdprInternet;
if (input.gdprPrint !== undefined) updateData.gdpr_print = input.gdprPrint;
if (input.gdprBirthdayInfo !== undefined) updateData.gdpr_birthday_info = input.gdprBirthdayInfo;
if (input.sepaMandateReference !== undefined) updateData.sepa_mandate_reference = input.sepaMandateReference;
const { data, error } = await (client).from('members')
.update(updateData)
@@ -186,5 +251,253 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
return member;
},
async rejectApplication(applicationId: string, userId: string, reviewNotes?: string) {
const { error } = await client.from('membership_applications')
.update({ status: 'rejected' as any, reviewed_by: userId, reviewed_at: new Date().toISOString(), review_notes: reviewNotes })
.eq('id', applicationId);
if (error) throw error;
},
async createDuesCategory(input: { accountId: string; name: string; description?: string; amount: number; interval?: string; isDefault?: boolean; isYouth?: boolean; isExit?: boolean }) {
const { data, error } = await client.from('dues_categories').insert({
account_id: input.accountId, name: input.name, description: input.description,
amount: input.amount, interval: input.interval ?? 'yearly',
is_default: input.isDefault ?? false, is_youth: input.isYouth ?? false, is_exit: input.isExit ?? false,
}).select().single();
if (error) throw error;
return data;
},
async deleteDuesCategory(categoryId: string) {
const { error } = await client.from('dues_categories').delete().eq('id', categoryId);
if (error) throw error;
},
async listDepartments(accountId: string) {
const { data, error } = await client.from('member_departments').select('*')
.eq('account_id', accountId).order('sort_order');
if (error) throw error;
return data ?? [];
},
async createDepartment(input: { accountId: string; name: string; description?: string }) {
const { data, error } = await client.from('member_departments').insert({
account_id: input.accountId, name: input.name, description: input.description,
}).select().single();
if (error) throw error;
return data;
},
async assignDepartment(memberId: string, departmentId: string) {
const { error } = await client.from('member_department_assignments').insert({
member_id: memberId, department_id: departmentId,
});
if (error) throw error;
},
async removeDepartment(memberId: string, departmentId: string) {
const { error } = await client.from('member_department_assignments').delete()
.eq('member_id', memberId).eq('department_id', departmentId);
if (error) throw error;
},
async listMemberRoles(memberId: string) {
const { data, error } = await client.from('member_roles').select('*')
.eq('member_id', memberId).order('from_date', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMemberRole(input: { memberId: string; accountId: string; roleName: string; fromDate?: string; untilDate?: string }) {
const { data, error } = await client.from('member_roles').insert({
member_id: input.memberId, account_id: input.accountId, role_name: input.roleName,
from_date: input.fromDate, until_date: input.untilDate,
}).select().single();
if (error) throw error;
return data;
},
async deleteMemberRole(roleId: string) {
const { error } = await client.from('member_roles').delete().eq('id', roleId);
if (error) throw error;
},
async listMemberHonors(memberId: string) {
const { data, error } = await client.from('member_honors').select('*')
.eq('member_id', memberId).order('honor_date', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMemberHonor(input: { memberId: string; accountId: string; honorName: string; honorDate?: string; description?: string }) {
const { data, error } = await client.from('member_honors').insert({
member_id: input.memberId, account_id: input.accountId, honor_name: input.honorName,
honor_date: input.honorDate, description: input.description,
}).select().single();
if (error) throw error;
return data;
},
async deleteMemberHonor(honorId: string) {
const { error } = await client.from('member_honors').delete().eq('id', honorId);
if (error) throw error;
},
async listMandates(memberId: string) {
const { data, error } = await client.from('sepa_mandates').select('*')
.eq('member_id', memberId).order('is_primary', { ascending: false });
if (error) throw error;
return data ?? [];
},
async createMandate(input: { memberId: string; accountId: string; mandateReference: string; iban: string; bic?: string; accountHolder: string; mandateDate: string; sequence?: string }) {
const { data, error } = await client.from('sepa_mandates').insert({
member_id: input.memberId, account_id: input.accountId,
mandate_reference: input.mandateReference, iban: input.iban, bic: input.bic,
account_holder: input.accountHolder, mandate_date: input.mandateDate,
sequence: input.sequence ?? 'RCUR',
}).select().single();
if (error) throw error;
return data;
},
async revokeMandate(mandateId: string) {
const { error } = await client.from('sepa_mandates')
.update({ status: 'revoked' as any }).eq('id', mandateId);
if (error) throw error;
},
async checkDuplicate(accountId: string, firstName: string, lastName: string, dateOfBirth?: string) {
const { data, error } = await client.rpc('check_duplicate_member', {
p_account_id: accountId, p_first_name: firstName, p_last_name: lastName,
p_date_of_birth: dateOfBirth ?? undefined,
});
if (error) throw error;
return data ?? [];
},
// --- Update operations (Gap 1) ---
async updateDuesCategory(input: { categoryId: string; name?: string; description?: string; amount?: number; interval?: string; isDefault?: boolean }) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.amount !== undefined) updateData.amount = input.amount;
if (input.interval !== undefined) updateData.interval = input.interval;
if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
const { data, error } = await client.from('dues_categories').update(updateData).eq('id', input.categoryId).select().single();
if (error) throw error;
return data;
},
async updateMandate(input: { mandateId: string; iban?: string; bic?: string; accountHolder?: string; sequence?: string }) {
const updateData: Record<string, unknown> = {};
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
if (input.sequence !== undefined) updateData.sequence = input.sequence;
const { data, error } = await client.from('sepa_mandates').update(updateData).eq('id', input.mandateId).select().single();
if (error) throw error;
return data;
},
// --- Export (Gap 2) ---
async exportMembersCsv(accountId: string, filters?: { status?: string }) {
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
if (filters?.status) query = query.eq('status', filters.status as any);
const { data, error } = await query;
if (error) throw error;
const members = data ?? [];
if (members.length === 0) return '';
const headers = ['Mitgliedsnr.', 'Anrede', 'Vorname', 'Nachname', 'Geburtsdatum', 'E-Mail', 'Telefon', 'Mobil', 'Straße', 'Hausnummer', 'PLZ', 'Ort', 'Status', 'Eintrittsdatum', 'IBAN', 'BIC', 'Kontoinhaber'];
const rows = members.map((m) => [
m.member_number ?? '', m.salutation ?? '', m.first_name, m.last_name,
m.date_of_birth ?? '', m.email ?? '', m.phone ?? '', m.mobile ?? '',
m.street ?? '', m.house_number ?? '', m.postal_code ?? '', m.city ?? '',
m.status, m.entry_date ?? '', m.iban ?? '', m.bic ?? '', m.account_holder ?? '',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';'));
return [headers.join(';'), ...rows].join('\n');
},
// --- Department assign/remove (Gap 5) ---
async getDepartmentAssignments(memberId: string) {
const { data, error } = await client.from('member_department_assignments').select('department_id').eq('member_id', memberId);
if (error) throw error;
return (data ?? []).map((d) => d.department_id);
},
async exportMembersExcel(accountId: string, filters?: { status?: string }): Promise<Buffer> {
let query = client.from('members').select('*').eq('account_id', accountId).order('last_name');
if (filters?.status) query = query.eq('status', filters.status as any);
const { data, error } = await query;
if (error) throw error;
const members = data ?? [];
const ExcelJS = (await import('exceljs')).default;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Mitglieder');
sheet.columns = [
{ header: 'Mitgliedsnr.', key: 'member_number', width: 15 },
{ header: 'Anrede', key: 'salutation', width: 10 },
{ header: 'Vorname', key: 'first_name', width: 20 },
{ header: 'Nachname', key: 'last_name', width: 20 },
{ header: 'Geburtsdatum', key: 'date_of_birth', width: 15 },
{ header: 'E-Mail', key: 'email', width: 30 },
{ header: 'Telefon', key: 'phone', width: 18 },
{ header: 'Mobil', key: 'mobile', width: 18 },
{ header: 'Straße', key: 'street', width: 25 },
{ header: 'Hausnummer', key: 'house_number', width: 12 },
{ header: 'PLZ', key: 'postal_code', width: 10 },
{ header: 'Ort', key: 'city', width: 20 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Eintrittsdatum', key: 'entry_date', width: 15 },
{ header: 'IBAN', key: 'iban', width: 30 },
{ header: 'BIC', key: 'bic', width: 15 },
{ header: 'Kontoinhaber', key: 'account_holder', width: 25 },
];
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F5E9' } };
for (const m of members) {
sheet.addRow(m);
}
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
},
// --- Portal Invitations ---
async inviteMemberToPortal(input: { memberId: string; accountId: string; email: string }, invitedBy: string) {
const { data, error } = await client.from('member_portal_invitations').insert({
account_id: input.accountId, member_id: input.memberId, email: input.email, invited_by: invitedBy,
}).select().single();
if (error) throw error;
return data;
},
async listPortalInvitations(accountId: string) {
const { data, error } = await client.from('member_portal_invitations').select('*')
.eq('account_id', accountId).order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
},
async revokePortalInvitation(invitationId: string) {
const { error } = await client.from('member_portal_invitations')
.update({ status: 'revoked' as any }).eq('id', invitationId);
if (error) throw error;
},
async getMemberByUserId(accountId: string, userId: string) {
const { data, error } = await client.from('members').select('*')
.eq('account_id', accountId).eq('user_id', userId).maybeSingle();
if (error) throw error;
return data;
},
};
}

View File

@@ -0,0 +1,79 @@
/**
* Member card PDF generator using @react-pdf/renderer.
* Generates A4 pages with member ID cards in a grid layout.
*/
import { renderToBuffer } from '@react-pdf/renderer';
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
import React from 'react';
const styles = StyleSheet.create({
page: { padding: 20, flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
card: {
width: '48%', height: 180, border: '1pt solid #ccc', borderRadius: 8,
padding: 12, justifyContent: 'space-between',
},
orgName: { fontSize: 10, fontWeight: 'bold', color: '#0d9488', marginBottom: 6 },
cardTitle: { fontSize: 7, color: '#888', textTransform: 'uppercase' as const, letterSpacing: 1 },
memberName: { fontSize: 14, fontWeight: 'bold', marginTop: 4 },
memberNumber: { fontSize: 9, color: '#666', marginTop: 2 },
fieldRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
fieldLabel: { fontSize: 7, color: '#888' },
fieldValue: { fontSize: 8 },
footer: { fontSize: 6, color: '#aaa', textAlign: 'center' as const, marginTop: 8 },
});
interface MemberCardData {
firstName: string;
lastName: string;
memberNumber: string;
entryDate: string;
status: string;
}
interface CardPdfProps {
orgName: string;
members: MemberCardData[];
validYear: number;
}
function MemberCardDocument({ orgName, members, validYear }: CardPdfProps) {
return React.createElement(Document, {},
React.createElement(Page, { size: 'A4', style: styles.page },
...members.map((m, i) =>
React.createElement(View, { key: i, style: styles.card },
React.createElement(View, {},
React.createElement(Text, { style: styles.orgName }, orgName),
React.createElement(Text, { style: styles.cardTitle }, 'MITGLIEDSAUSWEIS'),
React.createElement(Text, { style: styles.memberName }, `${m.firstName} ${m.lastName}`),
React.createElement(Text, { style: styles.memberNumber }, `Nr. ${m.memberNumber || '—'}`),
),
React.createElement(View, {},
React.createElement(View, { style: styles.fieldRow },
React.createElement(View, {},
React.createElement(Text, { style: styles.fieldLabel }, 'Mitglied seit'),
React.createElement(Text, { style: styles.fieldValue }, m.entryDate || '—'),
),
React.createElement(View, {},
React.createElement(Text, { style: styles.fieldLabel }, 'Gültig'),
React.createElement(Text, { style: styles.fieldValue }, String(validYear)),
),
),
),
React.createElement(Text, { style: styles.footer }, `${orgName} — Mitgliedsausweis ${validYear}`),
)
)
)
);
}
export async function generateMemberCardsPdf(
orgName: string,
members: MemberCardData[],
validYear?: number,
): Promise<Buffer> {
const year = validYear ?? new Date().getFullYear();
const doc = React.createElement(MemberCardDocument, { orgName, members, validYear: year });
const buffer = await renderToBuffer(doc as any);
return Buffer.from(buffer);
}