Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. "{rawData[0][Number(mapping[field.key])]}"</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
z.B. "{rawData[0][Number(mapping[field.key])]}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep('upload')}><ArrowLeft className="mr-2 h-4 w-4" />Zurück</Button>
|
||||
<Button onClick={() => setStep('preview')}>Vorschau <ArrowRight className="ml-2 h-4 w-4" /></Button>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user