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

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

View File

@@ -1,17 +1,24 @@
'use client';
import { useCallback } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { toast } from '@kit/ui/sonner';
import { useAction } from 'next-safe-action/hooks';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
import { approveApplication, rejectApplication } from '../server/actions/member-actions';
import {
APPLICATION_STATUS_VARIANT,
APPLICATION_STATUS_LABEL,
} from '../lib/member-utils';
import {
approveApplication,
rejectApplication,
} from '../server/actions/member-actions';
interface ApplicationWorkflowProps {
applications: Array<Record<string, unknown>>;
@@ -58,11 +65,7 @@ export function ApplicationWorkflow({
const handleApprove = useCallback(
(applicationId: string) => {
if (
!window.confirm(
'Mitglied wird automatisch erstellt. Fortfahren?',
)
) {
if (!window.confirm('Mitglied wird automatisch erstellt. Fortfahren?')) {
return;
}
executeApprove({ applicationId, accountId });
@@ -91,7 +94,7 @@ export function ApplicationWorkflow({
<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">
<p className="text-muted-foreground text-sm">
{applications.length} Antrag{applications.length !== 1 ? 'e' : ''}
</p>
</div>
@@ -99,7 +102,7 @@ export function ApplicationWorkflow({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="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>
@@ -112,7 +115,7 @@ export function ApplicationWorkflow({
<tr>
<td
colSpan={5}
className="px-4 py-8 text-center text-muted-foreground"
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Aufnahmeanträge vorhanden.
</td>
@@ -130,18 +133,18 @@ export function ApplicationWorkflow({
{String(app.last_name ?? '')},{' '}
{String(app.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="text-muted-foreground px-4 py-3">
{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 className="text-muted-foreground px-4 py-3">
{formatDate(app.created_at as string)}
</td>
<td className="px-4 py-3">
<Badge variant={APPLICATION_STATUS_VARIANT[appStatus] ?? 'secondary'}>
<Badge
variant={
APPLICATION_STATUS_VARIANT[appStatus] ?? 'secondary'
}
>
{APPLICATION_STATUS_LABEL[appStatus] ?? appStatus}
</Badge>
</td>

View File

@@ -1,14 +1,24 @@
'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 { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreateMemberSchema } from '../schema/member.schema';
import { createMember } from '../server/actions/member-actions';
@@ -18,16 +28,34 @@ interface Props {
duesCategories: Array<{ id: string; name: string; amount: number }>;
}
export function CreateMemberForm({ accountId, account, duesCategories }: Props) {
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: '',
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: '',
},
});
@@ -45,197 +73,517 @@ export function CreateMemberForm({ accountId, account, duesCategories }: Props)
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<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>
)} />
<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="border-input bg-background flex h-10 w-full rounded-md border 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>
<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>
)} />
<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>
<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>
)} />
<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>
<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>
)} />
<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="border-input bg-background flex h-10 w-full rounded-md border 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>
)} />
<FormField
control={form.control}
name="duesCategoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Beitragskategorie</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border 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>
<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>
)} />
<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>
<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>
)} />
<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>
<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>
)} />
{(
[
['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="border-input h-4 w-4 rounded"
/>
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)}
/>
))}
</CardContent>
</Card>
{/* GDPR granular (Gap 4) */}
<Card>
<CardHeader><CardTitle>Datenschutz-Einwilligungen</CardTitle></CardHeader>
<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>
)} />
{(
[
['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="border-input h-4 w-4 rounded"
/>
</FormControl>
<FormLabel className="!mt-0">{label}</FormLabel>
</FormItem>
)}
/>
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Sonstiges</CardTitle></CardHeader>
<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>
)} />
<FormField
control={form.control}
name="salutation"
render={({ field }) => (
<FormItem>
<FormLabel>Anrede</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border 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>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notizen</FormLabel>
<FormControl>
<textarea
{...field}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border 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>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Mitglied erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,13 +1,17 @@
'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 { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatCurrencyAmount } from '@kit/shared/formatters';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import {
createDuesCategory,
@@ -90,7 +94,11 @@ export function DuesCategoryManager({
name: values.name,
description: values.description,
amount: Number(values.amount),
interval: values.interval as 'monthly' | 'quarterly' | 'half_yearly' | 'yearly',
interval: values.interval as
| 'monthly'
| 'quarterly'
| 'half_yearly'
| 'yearly',
isDefault: values.isDefault,
});
},
@@ -100,9 +108,7 @@ export function DuesCategoryManager({
const handleDelete = useCallback(
(categoryId: string, categoryName: string) => {
if (
!window.confirm(
`Beitragskategorie "${categoryName}" wirklich löschen?`,
)
!window.confirm(`Beitragskategorie "${categoryName}" wirklich löschen?`)
) {
return;
}
@@ -158,7 +164,7 @@ export function DuesCategoryManager({
<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"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
{...form.register('interval')}
>
<option value="monthly">Monatlich</option>
@@ -171,7 +177,7 @@ export function DuesCategoryManager({
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="rounded border-input"
className="border-input rounded"
{...form.register('isDefault')}
/>
Standard
@@ -191,7 +197,7 @@ export function DuesCategoryManager({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="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>
@@ -205,7 +211,7 @@ export function DuesCategoryManager({
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-muted-foreground"
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Beitragskategorien vorhanden.
</td>
@@ -221,14 +227,11 @@ export function DuesCategoryManager({
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">
<td className="text-muted-foreground px-4 py-3">
{String(cat.description ?? '—')}
</td>
<td className="px-4 py-3 text-right font-mono">
{amount.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})}
{formatCurrencyAmount(amount)}
</td>
<td className="px-4 py-3">
{INTERVAL_LABELS[interval] ?? interval}

View File

@@ -1,14 +1,22 @@
'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 { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { UpdateMemberSchema } from '../schema/member.schema';
@@ -78,131 +86,382 @@ export function EditMemberForm({ member, account, accountId }: Props) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
<Card>
<CardHeader><CardTitle>Persönliche Daten</CardTitle></CardHeader>
<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>
)} />
<FormField
control={form.control}
name="salutation"
render={({ field }) => (
<FormItem>
<FormLabel>Anrede</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border 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>
)} />
<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>
<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>
)} />
<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>
<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>
)} />
<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>
)} />
<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>
<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>
)} />
<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>
<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>
)} />
<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>
<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>
)} />
{(
[
['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>
<p className="text-muted-foreground mb-2 text-xs font-medium">
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>
)} />
{(
[
['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>
@@ -210,17 +469,36 @@ export function EditMemberForm({ member, account, accountId }: Props) {
</Card>
<Card>
<CardHeader><CardTitle>Notizen</CardTitle></CardHeader>
<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>
)} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormControl>
<textarea
{...field}
rows={4}
className="border-input bg-background flex min-h-[80px] w-full rounded-md border 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>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Änderungen speichern'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,14 +1,18 @@
'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 { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
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 { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { formatIban } from '../lib/member-utils';
import { createMandate, revokeMandate } from '../server/actions/member-actions';
@@ -119,7 +123,7 @@ export function MandateManager({
bic: values.bic,
accountHolder: values.accountHolder,
mandateDate: values.mandateDate,
sequence: values.sequence as "FRST" | "RCUR" | "FNAL" | "OOFF",
sequence: values.sequence as 'FRST' | 'RCUR' | 'FNAL' | 'OOFF',
});
},
[executeCreate, memberId, accountId],
@@ -127,11 +131,7 @@ export function MandateManager({
const handleRevoke = useCallback(
(mandateId: string, reference: string) => {
if (
!window.confirm(
`Mandat "${reference}" wirklich widerrufen?`,
)
) {
if (!window.confirm(`Mandat "${reference}" wirklich widerrufen?`)) {
return;
}
executeRevoke({ mandateId });
@@ -185,10 +185,7 @@ export function MandateManager({
</div>
<div className="space-y-1">
<label className="text-sm font-medium">BIC</label>
<Input
placeholder="COBADEFFXXX"
{...form.register('bic')}
/>
<Input placeholder="COBADEFFXXX" {...form.register('bic')} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Kontoinhaber *</label>
@@ -207,7 +204,7 @@ export function MandateManager({
<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"
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
{...form.register('sequence')}
>
<option value="FRST">FRST Erstlastschrift</option>
@@ -230,7 +227,7 @@ export function MandateManager({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="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>
@@ -245,7 +242,7 @@ export function MandateManager({
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-muted-foreground"
className="text-muted-foreground px-4 py-8 text-center"
>
Keine SEPA-Mandate vorhanden.
</td>
@@ -261,21 +258,15 @@ export function MandateManager({
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">{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 className="text-muted-foreground px-4 py-3">
{formatDate(mandate.mandate_date as string)}
</td>
<td className="px-4 py-3">
<Badge variant={getMandateStatusColor(mandateStatus)}>

View File

@@ -1,13 +1,17 @@
'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 { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import {
STATUS_LABELS,
@@ -25,16 +29,26 @@ interface MemberDetailViewProps {
accountId: string;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
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>
<span className="text-muted-foreground text-sm font-medium">{label}</span>
<span className="text-right text-sm">{value ?? '—'}</span>
</div>
);
}
export function MemberDetailView({ member, account, accountId }: MemberDetailViewProps) {
export function MemberDetailView({
member,
account,
accountId,
}: MemberDetailViewProps) {
const router = useRouter();
const memberId = String(member.id ?? '');
@@ -45,32 +59,42 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
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`);
}
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');
},
},
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();
}
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');
},
},
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.`)) {
if (
!window.confirm(
`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`,
)
) {
return;
}
executeDelete({ memberId, accountId });
@@ -88,7 +112,9 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
}, [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 membershipYears = computeMembershipYears(
member.entry_date as string | null | undefined,
);
const address = formatAddress(member);
const iban = formatIban(member.iban as string | null | undefined);
@@ -103,7 +129,7 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
{STATUS_LABELS[status] ?? status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Mitgliedsnr. {String(member.member_number ?? '—')}
</p>
</div>
@@ -147,12 +173,18 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
label="Geburtsdatum"
value={
member.date_of_birth
? `${new Date(String(member.date_of_birth)).toLocaleDateString('de-DE')}${age !== null ? ` (${age} Jahre)` : ''}`
? `${formatDate(member.date_of_birth as string)}${age !== null ? ` (${age} Jahre)` : ''}`
: null
}
/>
<DetailRow label="Geschlecht" value={String(member.gender ?? '—')} />
<DetailRow label="Anrede" value={String(member.salutation ?? '—')} />
<DetailRow
label="Geschlecht"
value={String(member.gender ?? '—')}
/>
<DetailRow
label="Anrede"
value={String(member.salutation ?? '—')}
/>
</CardContent>
</Card>
@@ -187,7 +219,10 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Mitgliedsnr." value={String(member.member_number ?? '—')} />
<DetailRow
label="Mitgliedsnr."
value={String(member.member_number ?? '—')}
/>
<DetailRow
label="Status"
value={
@@ -198,11 +233,7 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
/>
<DetailRow
label="Eintrittsdatum"
value={
member.entry_date
? new Date(String(member.entry_date)).toLocaleDateString('de-DE')
: '—'
}
value={formatDate(member.entry_date as string)}
/>
<DetailRow
label="Mitgliedsjahre"
@@ -210,7 +241,10 @@ export function MemberDetailView({ member, account, accountId }: MemberDetailVie
/>
<DetailRow label="IBAN" value={iban} />
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
<DetailRow label="Kontoinhaber" value={String(member.account_holder ?? '—')} />
<DetailRow
label="Kontoinhaber"
value={String(member.account_holder ?? '—')}
/>
<DetailRow label="Notizen" value={String(member.notes ?? '—')} />
</CardContent>
</Card>

View File

@@ -1,15 +1,23 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Upload,
ArrowRight,
ArrowLeft,
CheckCircle,
AlertTriangle,
} from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import Papa from 'papaparse';
import { Badge } from '@kit/ui/badge';
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';
@@ -46,45 +54,56 @@ export function MemberImportWizard({ accountId, account }: Props) {
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 [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;
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())
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())),
);
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}`);
},
});
}, []);
// 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 () => {
@@ -111,7 +130,9 @@ export function MemberImportWizard({ accountId, account }: Props) {
await executeCreate(memberData as any);
success++;
} catch (err) {
errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
errors.push(
`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`,
);
}
}
@@ -131,15 +152,35 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* 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;
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
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="text-muted-foreground h-4 w-4" />
)}
</div>
);
})}
@@ -148,14 +189,25 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step 1: Upload */}
{step === 'upload' && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" />Datei hochladen</CardTitle></CardHeader>
<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" />
<Upload className="text-muted-foreground mb-4 h-10 w-10" />
<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" />
<p className="text-muted-foreground mt-1 text-sm">
Semikolon-getrennt (;), UTF-8
</p>
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
className="file:bg-primary file:text-primary-foreground mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-semibold"
/>
</div>
</CardContent>
</Card>
@@ -164,33 +216,54 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step 2: Column mapping */}
{step === 'mapping' && (
<Card>
<CardHeader><CardTitle>Spalten zuordnen</CardTitle></CardHeader>
<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>
<p className="text-muted-foreground mb-4 text-sm">
{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den
Mitgliedsfeldern zu.
</p>
<div className="space-y-2">
{MEMBER_FIELDS.map(field => (
{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="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"
onChange={(e) =>
setMapping((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
className="border-input bg-background flex h-9 w-64 rounded-md border px-3 py-1 text-sm"
>
<option value=""> Nicht zuordnen </option>
{headers.map((h, i) => (
<option key={i} value={String(i)}>{h}</option>
<option key={i} value={String(i)}>
{h}
</option>
))}
</select>
{mapping[field.key] !== undefined && rawData[0] && (
<span className="text-xs text-muted-foreground">z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;</span>
<span className="text-muted-foreground text-xs">
z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;
</span>
)}
</div>
))}
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('upload')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
<Button onClick={() => setStep('preview')}>Vorschau <ArrowRight className="ml-2 h-4 w-4" /></Button>
<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>
@@ -199,26 +272,46 @@ export function MemberImportWizard({ accountId, account }: Props) {
{/* Step 3: Preview + execute */}
{step === 'preview' && (
<Card>
<CardHeader><CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-auto rounded-md border max-h-96">
<div className="max-h-96 overflow-auto rounded-md border">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<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>
{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');
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
key={i}
className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}
>
<td className="p-2">
{i + 1}{' '}
{!hasName && (
<AlertTriangle className="text-destructive inline h-3 w-3" />
)}
</td>
{MEMBER_FIELDS.filter(
(f) => mapping[f.key] !== undefined,
).map((f) => (
<td key={f.key} className="max-w-32 truncate p-2">
{getMappedValue(i, f.key) || '—'}
</td>
))}
</tr>
);
@@ -226,9 +319,16 @@ export function MemberImportWizard({ accountId, account }: Props) {
</tbody>
</table>
</div>
{rawData.length > 20 && <p className="mt-2 text-xs text-muted-foreground">... und {rawData.length - 20} weitere Einträge</p>}
{rawData.length > 20 && (
<p className="text-muted-foreground mt-2 text-xs">
... 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 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
@@ -242,9 +342,13 @@ export function MemberImportWizard({ accountId, account }: Props) {
{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>
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
<p className="mt-4 text-lg font-semibold">
Importiere Mitglieder...
</p>
<p className="text-muted-foreground text-sm">
Bitte warten Sie, bis der Import abgeschlossen ist.
</p>
</CardContent>
</Card>
)}
@@ -256,20 +360,28 @@ export function MemberImportWizard({ accountId, account }: Props) {
<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>
<Badge variant="default">
{importResults.success} erfolgreich
</Badge>
{importResults.errors.length > 0 && (
<Badge variant="destructive">{importResults.errors.length} Fehler</Badge>
<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>
<p key={i} className="text-destructive">
{err}
</p>
))}
</div>
)}
<div className="mt-6">
<Button onClick={() => router.push(`/home/${account}/members-cms`)}>
<Button
onClick={() => router.push(`/home/${account}/members-cms`)}
>
Zur Mitgliederliste
</Button>
</div>

View File

@@ -1,13 +1,17 @@
'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 { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
@@ -117,7 +121,7 @@ export function MembersDataTable({
<select
value={currentStatus}
onChange={handleStatusChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -128,9 +132,7 @@ export function MembersDataTable({
<Button
size="sm"
onClick={() =>
router.push(`/home/${account}/members-cms/new`)
}
onClick={() => router.push(`/home/${account}/members-cms/new`)}
>
Neues Mitglied
</Button>
@@ -141,7 +143,7 @@ export function MembersDataTable({
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<tr className="bg-muted/50 border-b">
<th className="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>
@@ -153,7 +155,10 @@ export function MembersDataTable({
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
<td
colSpan={6}
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Mitglieder gefunden.
</td>
</tr>
@@ -165,7 +170,7 @@ export function MembersDataTable({
<tr
key={memberId}
onClick={() => handleRowClick(memberId)}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
className="hover:bg-muted/50 cursor-pointer border-b transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">
{String(member.member_number ?? '—')}
@@ -174,21 +179,17 @@ export function MembersDataTable({
{String(member.last_name ?? '')},{' '}
{String(member.first_name ?? '')}
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="text-muted-foreground px-4 py-3">
{String(member.email ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.city ?? '—')}
</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 className="text-muted-foreground px-4 py-3">
{formatDate(member.entry_date as string)}
</td>
</tr>
);
@@ -200,7 +201,7 @@ export function MembersDataTable({
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
</p>
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,9 @@
* Client-side utility functions for member display.
*/
export function computeAge(dateOfBirth: string | null | undefined): number | null {
export function computeAge(
dateOfBirth: string | null | undefined,
): number | null {
if (!dateOfBirth) return null;
const birth = new Date(dateOfBirth);
const today = new Date();
@@ -12,7 +14,9 @@ export function computeAge(dateOfBirth: string | null | undefined): number | nul
return age;
}
export function computeMembershipYears(entryDate: string | null | undefined): number {
export function computeMembershipYears(
entryDate: string | null | undefined,
): number {
if (!entryDate) return 0;
const entry = new Date(entryDate);
const today = new Date();
@@ -22,7 +26,11 @@ export function computeMembershipYears(entryDate: string | null | undefined): nu
return Math.max(0, years);
}
export function formatSalutation(salutation: string | null | undefined, firstName: string, lastName: string): string {
export function formatSalutation(
salutation: string | null | undefined,
firstName: string,
lastName: string,
): string {
if (salutation) return `${salutation} ${firstName} ${lastName}`;
return `${firstName} ${lastName}`;
}
@@ -47,15 +55,22 @@ export function formatIban(iban: string | null | undefined): string {
return cleaned.replace(/(.{4})/g, '$1 ').trim();
}
export function getMemberStatusColor(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
export function getMemberStatusColor(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active': return 'default';
case 'inactive': return 'secondary';
case 'pending': return 'outline';
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'pending':
return 'outline';
case 'resigned':
case 'excluded':
case 'deceased': return 'destructive';
default: return 'secondary';
case 'deceased':
return 'destructive';
default:
return 'secondary';
}
}
@@ -68,7 +83,10 @@ export const STATUS_LABELS: Record<string, string> = {
deceased: 'Verstorben',
};
export const APPLICATION_STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
export const APPLICATION_STATUS_VARIANT: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
submitted: 'outline',
review: 'secondary',
approved: 'default',

View File

@@ -1,12 +1,20 @@
import { z } from 'zod';
export const MembershipStatusEnum = z.enum([
'active', 'inactive', 'pending', 'resigned', 'excluded', 'deceased',
'active',
'inactive',
'pending',
'resigned',
'excluded',
'deceased',
]);
export type MembershipStatus = z.infer<typeof MembershipStatusEnum>;
export const SepaMandateStatusEnum = z.enum([
'active', 'pending', 'revoked', 'expired',
'active',
'pending',
'revoked',
'expired',
]);
export const CreateMemberSchema = z.object({
@@ -78,7 +86,9 @@ export const CreateDuesCategorySchema = z.object({
name: z.string().min(1).max(128),
description: z.string().optional(),
amount: z.number().min(0),
interval: z.enum(['monthly', 'quarterly', 'half_yearly', 'yearly']).default('yearly'),
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),
@@ -130,7 +140,9 @@ export const UpdateDuesCategorySchema = z.object({
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(),
interval: z
.enum(['monthly', 'quarterly', 'half_yearly', 'yearly'])
.optional(),
isDefault: z.boolean().optional(),
});
export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;

View File

@@ -1,9 +1,11 @@
'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,
@@ -79,7 +81,10 @@ export const approveApplication = authActionClient
const api = createMemberManagementApi(client);
const userId = ctx.user.id;
logger.info({ name: 'member.approveApplication' }, 'Approving application...');
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 };
@@ -91,8 +96,15 @@ export const rejectApplication = authActionClient
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);
logger.info(
{ name: 'members.reject-application' },
'Rejecting application...',
);
await api.rejectApplication(
input.applicationId,
ctx.user.id,
input.reviewNotes,
);
return { success: true };
});
@@ -202,7 +214,9 @@ export const exportMembers = authActionClient
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const csv = await api.exportMembersCsv(input.accountId, { status: input.status });
const csv = await api.exportMembersCsv(input.accountId, {
status: input.status,
});
return { success: true, csv };
});
@@ -231,63 +245,89 @@ export const exportMembersExcel = authActionClient
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const buffer = await api.exportMembersExcel(input.accountId, { status: input.status });
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` };
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'),
}))
.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');
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 { 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 ?? '',
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` };
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(),
}))
.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...');
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');
logger.info(
{ name: 'portal.invite', token: invitation.invite_token },
'Invitation created',
);
return { success: true, data: invitation };
});

View File

@@ -1,23 +1,43 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateMemberInput, UpdateMemberInput } from '../schema/member.schema';
import { todayISO } from '@kit/shared/dates';
import type { Database } from '@kit/supabase/database';
import type {
CreateMemberInput,
UpdateMemberInput,
} from '../schema/member.schema';
/**
* Factory for the Member Management API.
*/
export function createMemberManagementApi(client: SupabaseClient<Database>) {
return {
async listMembers(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
let query = (client).from('members')
async listMembers(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('members')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('last_name')
.order('first_name');
if (opts?.status) query = query.eq('status', opts.status as Database['public']['Enums']['membership_status']);
if (opts?.status)
query = query.eq(
'status',
opts.status as Database['public']['Enums']['membership_status'],
);
if (opts?.search) {
query = query.or(`last_name.ilike.%${opts.search}%,first_name.ilike.%${opts.search}%,email.ilike.%${opts.search}%,member_number.ilike.%${opts.search}%`);
query = query.or(
`last_name.ilike.%${opts.search}%,first_name.ilike.%${opts.search}%,email.ilike.%${opts.search}%,member_number.ilike.%${opts.search}%`,
);
}
const page = opts?.page ?? 1;
@@ -30,7 +50,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
async getMember(memberId: string) {
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.select('*')
.eq('id', memberId)
.single();
@@ -39,7 +60,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
async createMember(input: CreateMemberInput, userId: string) {
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.insert({
account_id: input.accountId,
member_number: input.memberNumber,
@@ -63,7 +85,9 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
bic: input.bic,
account_holder: input.accountHolder,
gdpr_consent: input.gdprConsent,
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
gdpr_consent_date: input.gdprConsent
? new Date().toISOString()
: null,
notes: input.notes,
// New parity fields
salutation: input.salutation,
@@ -104,59 +128,90 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
async updateMember(input: UpdateMemberInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.firstName !== undefined) updateData.first_name = input.firstName;
if (input.firstName !== undefined)
updateData.first_name = input.firstName;
if (input.lastName !== undefined) updateData.last_name = input.lastName;
if (input.email !== undefined) updateData.email = input.email;
if (input.phone !== undefined) updateData.phone = input.phone;
if (input.mobile !== undefined) updateData.mobile = input.mobile;
if (input.street !== undefined) updateData.street = input.street;
if (input.houseNumber !== undefined) updateData.house_number = input.houseNumber;
if (input.postalCode !== undefined) updateData.postal_code = input.postalCode;
if (input.houseNumber !== undefined)
updateData.house_number = input.houseNumber;
if (input.postalCode !== undefined)
updateData.postal_code = input.postalCode;
if (input.city !== undefined) updateData.city = input.city;
if (input.status !== undefined) updateData.status = input.status;
if (input.duesCategoryId !== undefined) updateData.dues_category_id = input.duesCategoryId;
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.accountHolder !== undefined)
updateData.account_holder = input.accountHolder;
if (input.notes !== undefined) updateData.notes = input.notes;
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
if (input.isArchived !== undefined)
updateData.is_archived = input.isArchived;
// New parity fields
if (input.salutation !== undefined) updateData.salutation = input.salutation;
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.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.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.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.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.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;
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')
const { data, error } = await client
.from('members')
.update(updateData)
.eq('id', input.memberId)
.select()
@@ -166,20 +221,31 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
async deleteMember(memberId: string) {
const { error } = await (client).from('members')
.update({ status: 'resigned', exit_date: new Date().toISOString().split('T')[0] })
const { error } = await client
.from('members')
.update({
status: 'resigned',
exit_date: todayISO(),
})
.eq('id', memberId);
if (error) throw error;
},
async getMemberStatistics(accountId: string) {
const { data, error } = await (client).from('members')
const { data, error } = await client
.from('members')
.select('status')
.eq('account_id', accountId);
if (error) throw error;
const stats = { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
for (const m of (data ?? [])) {
const stats = {
total: 0,
active: 0,
inactive: 0,
pending: 0,
resigned: 0,
};
for (const m of data ?? []) {
stats.total++;
if (m.status === 'active') stats.active++;
else if (m.status === 'inactive') stats.inactive++;
@@ -191,7 +257,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
// Dues categories
async listDuesCategories(accountId: string) {
const { data, error } = await (client).from('dues_categories')
const { data, error } = await client
.from('dues_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
@@ -201,11 +268,16 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
// Applications
async listApplications(accountId: string, status?: string) {
let query = (client).from('membership_applications')
let query = client
.from('membership_applications')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (status) query = query.eq('status', status as Database['public']['Enums']['application_status']);
if (status)
query = query.eq(
'status',
status as Database['public']['Enums']['application_status'],
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
@@ -213,14 +285,16 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
async approveApplication(applicationId: string, userId: string) {
// Get application
const { data: app, error: appError } = await (client).from('membership_applications')
const { data: app, error: appError } = await client
.from('membership_applications')
.select('*')
.eq('id', applicationId)
.single();
if (appError) throw appError;
// Create member from application
const { data: member, error: memberError } = await (client).from('members')
const { data: member, error: memberError } = await client
.from('members')
.insert({
account_id: app.account_id,
first_name: app.first_name,
@@ -240,7 +314,8 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
if (memberError) throw memberError;
// Update application
await (client).from('membership_applications')
await client
.from('membership_applications')
.update({
status: 'approved',
reviewed_by: userId,
@@ -252,125 +327,242 @@ 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 })
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();
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);
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');
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();
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,
});
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);
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 });
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();
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);
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 });
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();
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);
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 });
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();
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);
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) {
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_account_id: accountId,
p_first_name: firstName,
p_last_name: lastName,
p_date_of_birth: dateOfBirth ?? undefined,
});
if (error) throw error;
@@ -378,58 +570,133 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
// --- Update operations (Gap 1) ---
async updateDuesCategory(input: { categoryId: string; name?: string; description?: string; amount?: number; interval?: string; isDefault?: boolean }) {
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.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 (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 }) {
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.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();
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');
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(';'));
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);
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');
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;
@@ -461,7 +728,11 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
// Style header row
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F5E9' } };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F5E9' },
};
for (const m of members) {
sheet.addRow(m);
@@ -472,30 +743,49 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
},
// --- 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();
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 });
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);
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();
const { data, error } = await client
.from('members')
.select('*')
.eq('account_id', accountId)
.eq('user_id', userId)
.maybeSingle();
if (error) throw error;
return data;
},

View File

@@ -3,24 +3,48 @@
* Generates A4 pages with member ID cards in a grid layout.
*/
import React from 'react';
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',
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,
},
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 },
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 },
footer: {
fontSize: 6,
color: '#aaa',
textAlign: 'center' as const,
marginTop: 8,
},
});
interface MemberCardData {
@@ -38,32 +62,80 @@ interface CardPdfProps {
}
function MemberCardDocument({ orgName, members, validYear }: CardPdfProps) {
return React.createElement(Document, {},
React.createElement(Page, { size: 'A4', style: styles.page },
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(
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(
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(
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(
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}`),
)
)
)
React.createElement(
Text,
{ style: styles.footer },
`${orgName} — Mitgliedsausweis ${validYear}`,
),
),
),
),
);
}
@@ -73,7 +145,11 @@ export async function generateMemberCardsPdf(
validYear?: number,
): Promise<Buffer> {
const year = validYear ?? new Date().getFullYear();
const doc = React.createElement(MemberCardDocument, { orgName, members, validYear: year });
const doc = React.createElement(MemberCardDocument, {
orgName,
members,
validYear: year,
});
const buffer = await renderToBuffer(doc as any);
return Buffer.from(buffer);
}

View File

@@ -1,6 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}