feat: MyEasyCMS v2 — Full SaaS rebuild
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:
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. "{rawData[0][Number(mapping[field.key])]}"</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
69
packages/features/member-management/src/lib/member-utils.ts
Normal file
69
packages/features/member-management/src/lib/member-utils.ts
Normal 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',
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user