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

@@ -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>
);
}