feat: enhance member management features; add quick stats and search capabilities

This commit is contained in:
T. Zehetbauer
2026-04-02 22:56:04 +02:00
parent 0932c57fa1
commit f43770999f
35 changed files with 4370 additions and 159 deletions

View File

@@ -28,6 +28,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-table": "catalog:",
"@types/papaparse": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",

View File

@@ -6,3 +6,11 @@ export { ApplicationWorkflow } from './application-workflow';
export { DuesCategoryManager } from './dues-category-manager';
export { MandateManager } from './mandate-manager';
export { MemberImportWizard } from './member-import-wizard';
// New v2 components
export { MemberAvatar } from './member-avatar';
export { MemberStatsBar } from './member-stats-bar';
export { MembersListView } from './members-list-view';
export { MemberDetailTabs } from './member-detail-tabs';
export { MemberCreateWizard } from './member-create-wizard';
export { MemberCommandPalette } from './member-command-palette';

View File

@@ -0,0 +1,54 @@
'use client';
import { Avatar, AvatarFallback } from '@kit/ui/avatar';
import { cn } from '@kit/ui/utils';
interface MemberAvatarProps {
firstName: string;
lastName: string;
size?: 'default' | 'sm' | 'lg';
className?: string;
}
function getInitials(firstName: string, lastName: string): string {
const f = firstName.trim().charAt(0).toUpperCase();
const l = lastName.trim().charAt(0).toUpperCase();
return `${f}${l}`;
}
function getColorClass(firstName: string, lastName: string): string {
const name = `${firstName}${lastName}`;
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = [
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
'bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300',
];
return colors[Math.abs(hash) % colors.length]!;
}
export function MemberAvatar({
firstName,
lastName,
size = 'default',
className,
}: MemberAvatarProps) {
const initials = getInitials(firstName, lastName);
const colorClass = getColorClass(firstName, lastName);
return (
<Avatar size={size} className={cn('rounded-full', className)}>
<AvatarFallback className={cn('rounded-full font-medium', colorClass)}>
{initials}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { FileUp, Plus, User } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { Badge } from '@kit/ui/badge';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@kit/ui/command';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
import { quickSearchMembers } from '../server/actions/member-actions';
import { MemberAvatar } from './member-avatar';
interface MemberCommandPaletteProps {
account: string;
accountId: string;
}
export function MemberCommandPalette({
account,
accountId,
}: MemberCommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<
Array<{
id: string;
first_name: string;
last_name: string;
email: string | null;
member_number: string | null;
status: string;
}>
>([]);
const { execute } = useAction(quickSearchMembers, {
onSuccess: ({ data }) => {
if (data?.data) setResults(data.data);
},
});
// Keyboard shortcut
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setOpen((v) => !v);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
// Search on query change
useEffect(() => {
if (query.length >= 2) {
execute({ accountId, query, limit: 8 });
} else {
setResults([]);
}
}, [query, accountId, execute]);
const handleSelect = useCallback(
(memberId: string) => {
setOpen(false);
setQuery('');
router.push(`/home/${account}/members-cms/${memberId}`);
},
[router, account],
);
const basePath = `/home/${account}/members-cms`;
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Mitglied suchen... (Name, E-Mail, Nr.)"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>Keine Mitglieder gefunden.</CommandEmpty>
{results.length > 0 && (
<CommandGroup heading="Mitglieder">
{results.map((m) => (
<CommandItem
key={m.id}
value={`${m.first_name} ${m.last_name} ${m.email ?? ''} ${m.member_number ?? ''}`}
onSelect={() => handleSelect(m.id)}
className="flex items-center gap-3"
>
<MemberAvatar
firstName={m.first_name}
lastName={m.last_name}
size="sm"
/>
<div className="min-w-0 flex-1">
<span className="font-medium">
{m.first_name} {m.last_name}
</span>
{m.member_number && (
<span className="text-muted-foreground ml-1.5 text-xs">
Nr. {m.member_number}
</span>
)}
</div>
<Badge
variant={getMemberStatusColor(m.status)}
className="text-xs"
>
{STATUS_LABELS[m.status] ?? m.status}
</Badge>
</CommandItem>
))}
</CommandGroup>
)}
<CommandSeparator />
<CommandGroup heading="Aktionen">
<CommandItem
onSelect={() => {
setOpen(false);
router.push(`${basePath}/new`);
}}
>
<Plus className="mr-2 size-4" />
Neues Mitglied erstellen
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false);
router.push(`${basePath}/import`);
}}
>
<FileUp className="mr-2 size-4" />
Import starten
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false);
router.push(`${basePath}/applications`);
}}
>
<User className="mr-2 size-4" />
Aufnahmeanträge anzeigen
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@@ -0,0 +1,770 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Check } from 'lucide-react';
import { useForm } from 'react-hook-form';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Textarea } from '@kit/ui/textarea';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import { cn } from '@kit/ui/utils';
import { CreateMemberSchema } from '../schema/member.schema';
import { createMember } from '../server/actions/member-actions';
interface Props {
accountId: string;
account: string;
duesCategories: Array<{ id: string; name: string; amount: number }>;
}
interface DuplicateEntry {
field: string;
message: string;
id?: string;
}
const STEPS = [
{ id: 1, title: 'Basisdaten', description: 'Name und Mitgliedschaft' },
{ id: 2, title: 'Weitere Angaben', description: 'Kontakt und Adresse' },
{
id: 3,
title: 'Mitgliedschaft & Finanzen',
description: 'Beiträge und Bankverbindung',
},
] as const;
export function MemberCreateWizard({
accountId,
account,
duesCategories,
}: Props) {
const router = useRouter();
const [step, setStep] = useState(1);
const [duplicates, setDuplicates] = useState<DuplicateEntry[]>([]);
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]!,
dateOfBirth: '',
gender: undefined,
salutation: '',
title: '',
duesCategoryId: undefined,
iban: '',
bic: '',
accountHolder: '',
gdprConsent: false,
gdprNewsletter: false,
gdprInternet: false,
gdprPrint: false,
gdprBirthdayInfo: false,
isHonorary: false,
isFoundingMember: false,
isYouth: false,
isRetiree: false,
isProbationary: false,
guardianName: '',
guardianPhone: '',
guardianEmail: '',
notes: '',
},
});
const { execute, isPending } = useActionWithToast(createMember, {
successMessage: 'Mitglied erstellt',
onSuccess: ({ data }: any) => {
if (data?.validationErrors) {
setDuplicates(data.validationErrors);
return;
}
router.push(`/home/${account}/members-cms`);
},
});
const canProceedStep1 =
form.watch('firstName')?.trim() && form.watch('lastName')?.trim();
const handleNext = () => {
if (step < 3) setStep(step + 1);
};
const handleBack = () => {
if (step > 1) setStep(step - 1);
};
const handleSubmit = form.handleSubmit((data) => {
// Clean empty strings
const cleanData = { ...data };
for (const [key, value] of Object.entries(cleanData)) {
if (value === '') {
(cleanData as any)[key] = undefined;
}
}
execute(cleanData);
});
return (
<div className="mx-auto max-w-2xl space-y-6">
{/* Step indicator */}
<nav className="flex items-center justify-center gap-2">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center gap-2">
<button
onClick={() => {
if (s.id < step) setStep(s.id);
}}
className={cn(
'flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
step === s.id
? 'bg-primary text-primary-foreground'
: step > s.id
? 'bg-primary/10 text-primary cursor-pointer'
: 'bg-muted text-muted-foreground',
)}
>
{step > s.id ? (
<Check className="size-4" />
) : (
<span className="flex size-5 items-center justify-center rounded-full text-xs">
{s.id}
</span>
)}
<span className="hidden sm:inline">{s.title}</span>
</button>
{i < STEPS.length - 1 && (
<div
className={cn(
'h-px w-8',
step > s.id ? 'bg-primary' : 'bg-border',
)}
/>
)}
</div>
))}
</nav>
<Form {...form}>
<form onSubmit={handleSubmit}>
{/* Step 1: Basisdaten */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Basisdaten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Vorname *</FormLabel>
<FormControl>
<Input
{...field}
data-test="member-first-name"
autoFocus
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Nachname *</FormLabel>
<FormControl>
<Input {...field} data-test="member-last-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input
type="email"
{...field}
data-test="member-email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="memberNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Mitgliedsnr.</FormLabel>
<FormControl>
<Input {...field} data-test="member-number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger data-test="member-status">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">Aktiv</SelectItem>
<SelectItem value="inactive">Inaktiv</SelectItem>
<SelectItem value="pending">Ausstehend</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="entryDate"
render={({ field }) => (
<FormItem>
<FormLabel>Eintrittsdatum</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{/* Step 2: Weitere Angaben */}
{step === 2 && (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<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>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="street"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Straße</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="houseNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Hausnr.</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<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 className="sm:col-span-2">
<FormLabel>Ort</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<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>
<Select
value={field.value ?? ''}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="male">Männlich</SelectItem>
<SelectItem value="female">Weiblich</SelectItem>
<SelectItem value="diverse">Divers</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="salutation"
render={({ field }) => (
<FormItem>
<FormLabel>Anrede</FormLabel>
<FormControl>
<Input {...field} placeholder="z.B. Herr, Frau" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titel</FormLabel>
<FormControl>
<Input {...field} placeholder="z.B. Dr., Prof." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
)}
{/* Step 3: Mitgliedschaft & Finanzen */}
{step === 3 && (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Beitrag</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{duesCategories.length > 0 && (
<FormField
control={form.control}
name="duesCategoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Beitragskategorie</FormLabel>
<Select
value={field.value ?? ''}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Keine" />
</SelectTrigger>
</FormControl>
<SelectContent>
{duesCategories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name} ({c.amount.toFixed(2)} EUR)
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bankverbindung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="iban"
render={({ field }) => (
<FormItem>
<FormLabel>IBAN</FormLabel>
<FormControl>
<Input {...field} data-test="member-iban" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bic"
render={({ field }) => (
<FormItem>
<FormLabel>BIC</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="accountHolder"
render={({ field }) => (
<FormItem>
<FormLabel>Kontoinhaber</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Merkmale</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{[
{ name: 'isHonorary' as const, label: 'Ehrenmitglied' },
{
name: 'isFoundingMember' as const,
label: 'Gründungsmitglied',
},
{ name: 'isYouth' as const, label: 'Jugendmitglied' },
{ name: 'isRetiree' as const, label: 'Senior' },
{ name: 'isProbationary' as const, label: 'Probezeit' },
].map(({ name, label }) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{label}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>DSGVO-Einwilligungen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{
name: 'gdprConsent' as const,
label: 'Allgemeine Einwilligung',
},
{ name: 'gdprNewsletter' as const, label: 'Newsletter' },
{
name: 'gdprInternet' as const,
label: 'Internetveröffentlichung',
},
{
name: 'gdprPrint' as const,
label: 'Printveröffentlichung',
},
{
name: 'gdprBirthdayInfo' as const,
label: 'Geburtstagsinfo',
},
].map(({ name, label }) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{label}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notizen</CardTitle>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
rows={3}
placeholder="Interne Anmerkungen..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between pt-6">
<Button
type="button"
variant="ghost"
onClick={step === 1 ? () => router.back() : handleBack}
>
{step === 1 ? 'Abbrechen' : 'Zurück'}
</Button>
<div className="flex gap-2">
{step < 3 && (
<Button
type="button"
variant="outline"
disabled={step === 1 && !canProceedStep1}
onClick={() => handleSubmit()}
data-test="wizard-skip-submit"
>
Direkt speichern
</Button>
)}
{step < 3 ? (
<Button
type="button"
disabled={step === 1 && !canProceedStep1}
onClick={handleNext}
data-test="wizard-next"
>
Weiter
</Button>
) : (
<Button
type="submit"
disabled={isPending}
data-test="wizard-submit"
>
{isPending ? 'Speichere...' : 'Mitglied erstellen'}
</Button>
)}
</div>
</div>
</form>
</Form>
{/* Duplicate dialog */}
<AlertDialog
open={duplicates.length > 0}
onOpenChange={() => setDuplicates([])}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mögliche Duplikate</AlertDialogTitle>
<AlertDialogDescription>
Folgende ähnliche Mitglieder wurden gefunden:
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="list-disc space-y-1 pl-6 text-sm">
{duplicates.map((d, i) => (
<li key={i}>{d.message}</li>
))}
</ul>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDuplicates([]);
// Force submit ignoring duplicates — would need a flag
}}
>
Trotzdem erstellen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Mail, Pencil } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { toast } from '@kit/ui/sonner';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
import { deleteMember, updateMember } from '../server/actions/member-actions';
import { MemberAvatar } from './member-avatar';
interface MemberDetailHeaderProps {
member: Record<string, unknown>;
account: string;
accountId: string;
}
export function MemberDetailHeader({
member,
account,
accountId,
}: MemberDetailHeaderProps) {
const router = useRouter();
const memberId = String(member.id);
const firstName = String(member.first_name ?? '');
const lastName = String(member.last_name ?? '');
const status = String(member.status ?? 'active');
const email = member.email ? String(member.email) : null;
const memberNumber = member.member_number
? String(member.member_number)
: null;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
const { execute: executeDelete, isPending: isDeleting } = useAction(
deleteMember,
{
onSuccess: () => {
toast.success('Mitglied wurde gekündigt');
router.push(`/home/${account}/members-cms`);
},
onError: () => toast.error('Fehler beim Kündigen'),
},
);
const { execute: executeArchive, isPending: isArchiving } = useAction(
updateMember,
{
onSuccess: () => {
toast.success('Mitglied wurde archiviert');
setArchiveDialogOpen(false);
router.refresh();
},
onError: () => toast.error('Fehler beim Archivieren'),
},
);
return (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => router.push(`/home/${account}/members-cms`)}
>
<ArrowLeft className="mr-1 size-4" />
Zurück
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<MemberAvatar
firstName={firstName}
lastName={lastName}
size="lg"
className="size-14"
/>
<div>
<h1 className="text-2xl font-bold">
{firstName} {lastName}
</h1>
<div className="mt-1 flex flex-wrap items-center gap-2">
{memberNumber && (
<span className="text-muted-foreground text-sm">
Nr. {memberNumber}
</span>
)}
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
{Boolean(member.is_honorary) && (
<Badge variant="outline">Ehrenmitglied</Badge>
)}
{Boolean(member.is_founding_member) && (
<Badge variant="outline">Gründungsmitglied</Badge>
)}
{Boolean(member.is_youth) && (
<Badge variant="outline">Jugend</Badge>
)}
{Boolean(member.is_retiree) && (
<Badge variant="outline">Senior</Badge>
)}
</div>
</div>
</div>
<div className="flex gap-2">
{email && (
<a
href={`mailto:${email}`}
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-sm font-medium transition-colors"
>
<Mail className="size-4" />
E-Mail
</a>
)}
<Link
href={`/home/${account}/members-cms/${memberId}/edit`}
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-sm font-medium transition-colors"
>
<Pencil className="size-4" />
Bearbeiten
</Link>
<DropdownMenu>
<DropdownMenuTrigger className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1 rounded-lg border px-2.5 text-sm font-medium transition-colors">
Aktionen
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setArchiveDialogOpen(true)}
disabled={isArchiving}
>
Archivieren
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={isDeleting}
variant="destructive"
>
Kündigen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Archive confirmation */}
<AlertDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mitglied archivieren</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie {firstName} {lastName} wirklich archivieren?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
executeArchive({ memberId, accountId, isArchived: true })
}
>
Archivieren
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mitglied kündigen</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie {firstName} {lastName} wirklich kündigen? Das Mitglied
wird als &quot;Ausgetreten&quot; markiert.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => executeDelete({ memberId, accountId })}
>
Kündigen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,899 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
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 { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import {
STATUS_LABELS,
getMemberStatusColor,
formatIban,
computeAge,
computeMembershipYears,
} from '../lib/member-utils';
import {
createMemberRole,
deleteMemberRole,
createMemberHonor,
deleteMemberHonor,
createMandate,
revokeMandate,
} from '../server/actions/member-actions';
import { MemberDetailHeader } from './member-detail-header';
interface MemberRole {
id: string;
role_name: string;
from_date: string | null;
until_date: string | null;
is_active: boolean;
}
interface MemberHonor {
id: string;
honor_name: string;
honor_date: string | null;
description: string | null;
}
interface SepaMandate {
id: string;
mandate_reference: string;
iban: string;
bic: string | null;
account_holder: string;
mandate_date: string;
status: string;
is_primary: boolean;
sequence: string;
}
interface MemberDetailTabsProps {
member: Record<string, unknown>;
account: string;
accountId: string;
roles?: MemberRole[];
honors?: MemberHonor[];
mandates?: SepaMandate[];
}
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-muted-foreground text-sm font-medium">{label}</span>
<span className="text-right text-sm">{value ?? '—'}</span>
</div>
);
}
export function MemberDetailTabs({
member,
account,
accountId,
roles = [],
honors = [],
mandates = [],
}: MemberDetailTabsProps) {
const memberId = String(member.id);
const status = String(member.status ?? 'active');
const age = computeAge(member.date_of_birth as string | null);
const membershipYears = computeMembershipYears(
member.entry_date as string | null,
);
return (
<div className="space-y-6">
<MemberDetailHeader
member={member}
account={account}
accountId={accountId}
/>
<Tabs defaultValue="stammdaten">
<TabsList variant="line">
<TabsTrigger value="stammdaten">Stammdaten</TabsTrigger>
<TabsTrigger value="mitgliedschaft">Mitgliedschaft</TabsTrigger>
<TabsTrigger value="finanzen">Finanzen</TabsTrigger>
<TabsTrigger value="funktionen">
Funktionen & Ehrungen
{(roles.length > 0 || honors.length > 0) && (
<Badge
variant="secondary"
className="ml-1.5 h-5 min-w-5 px-1 text-xs"
>
{roles.length + honors.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="datenschutz">Datenschutz</TabsTrigger>
</TabsList>
{/* Tab 1: Stammdaten */}
<TabsContent value="stammdaten">
<div className="grid gap-6 pt-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent>
<DetailRow
label="Vorname"
value={String(member.first_name ?? '—')}
/>
<DetailRow
label="Nachname"
value={String(member.last_name ?? '—')}
/>
<DetailRow
label="Anrede"
value={String(member.salutation ?? '—')}
/>
<DetailRow label="Titel" value={String(member.title ?? '—')} />
<DetailRow
label="Geburtsdatum"
value={
member.date_of_birth
? `${formatDate(String(member.date_of_birth))}${age !== null ? ` (${age} Jahre)` : ''}`
: '—'
}
/>
<DetailRow
label="Geburtsort"
value={String(member.birthplace ?? '—')}
/>
<DetailRow
label="Geschlecht"
value={String(member.gender ?? '—')}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="E-Mail" value={String(member.email ?? '—')} />
<DetailRow
label="Telefon"
value={String(member.phone ?? '—')}
/>
<DetailRow label="Mobil" value={String(member.mobile ?? '—')} />
<DetailRow
label="Telefon 2"
value={String(member.phone2 ?? '—')}
/>
<DetailRow label="Fax" value={String(member.fax ?? '—')} />
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent>
<DetailRow
label="Straße"
value={
member.street
? `${member.street}${member.house_number ? ` ${member.house_number}` : ''}`
: '—'
}
/>
{Boolean(member.street2) && (
<DetailRow
label="Adresszusatz"
value={String(member.street2)}
/>
)}
<DetailRow
label="PLZ / Ort"
value={
member.postal_code || member.city
? `${member.postal_code ?? ''} ${member.city ?? ''}`.trim()
: '—'
}
/>
<DetailRow
label="Land"
value={String(member.country ?? 'DE')}
/>
</CardContent>
</Card>
{/* Guardian info for youth members */}
{Boolean(member.is_youth) && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Erziehungsberechtigte/r</CardTitle>
</CardHeader>
<CardContent>
<DetailRow
label="Name"
value={String(member.guardian_name ?? '—')}
/>
<DetailRow
label="Telefon"
value={String(member.guardian_phone ?? '—')}
/>
<DetailRow
label="E-Mail"
value={String(member.guardian_email ?? '—')}
/>
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* Tab 2: Mitgliedschaft */}
<TabsContent value="mitgliedschaft">
<div className="grid gap-6 pt-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Mitgliedschaft</CardTitle>
</CardHeader>
<CardContent>
<DetailRow
label="Mitgliedsnr."
value={String(member.member_number ?? '—')}
/>
<DetailRow
label="Status"
value={
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
}
/>
<DetailRow
label="Eintrittsdatum"
value={
member.entry_date
? formatDate(String(member.entry_date))
: '—'
}
/>
<DetailRow
label="Mitgliedsjahre"
value={
membershipYears > 0
? `${membershipYears} Jahre`
: '< 1 Jahr'
}
/>
{Boolean(member.exit_date) && (
<>
<DetailRow
label="Austrittsdatum"
value={formatDate(String(member.exit_date))}
/>
<DetailRow
label="Austrittsgrund"
value={String(member.exit_reason ?? '—')}
/>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Merkmale</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2 py-2">
{Boolean(member.is_honorary) && <Badge>Ehrenmitglied</Badge>}
{Boolean(member.is_founding_member) && (
<Badge>Gründungsmitglied</Badge>
)}
{Boolean(member.is_youth) && <Badge>Jugend</Badge>}
{Boolean(member.is_retiree) && <Badge>Senior</Badge>}
{Boolean(member.is_probationary) && (
<Badge variant="outline">Probezeit</Badge>
)}
{Boolean(member.is_transferred) && (
<Badge variant="outline">Überweisung</Badge>
)}
{!member.is_honorary &&
!member.is_founding_member &&
!member.is_youth &&
!member.is_retiree &&
!member.is_probationary &&
!member.is_transferred && (
<span className="text-muted-foreground text-sm">
Keine besonderen Merkmale
</span>
)}
</div>
{Boolean(member.notes) && (
<div className="border-t pt-3">
<p className="text-muted-foreground mb-1 text-xs font-medium">
Notizen
</p>
<p className="text-sm whitespace-pre-wrap">
{String(member.notes)}
</p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Tab 3: Finanzen */}
<TabsContent value="finanzen">
<div className="space-y-6 pt-4">
<Card>
<CardHeader>
<CardTitle>Bankverbindung</CardTitle>
</CardHeader>
<CardContent>
<DetailRow
label="IBAN"
value={formatIban(member.iban as string | null)}
/>
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
<DetailRow
label="Kontoinhaber"
value={String(member.account_holder ?? '—')}
/>
</CardContent>
</Card>
<MandatesSection
mandates={mandates}
memberId={memberId}
accountId={accountId}
/>
</div>
</TabsContent>
{/* Tab 4: Funktionen & Ehrungen */}
<TabsContent value="funktionen">
<div className="space-y-6 pt-4">
<RolesSection
roles={roles}
memberId={memberId}
accountId={accountId}
/>
<HonorsSection
honors={honors}
memberId={memberId}
accountId={accountId}
/>
</div>
</TabsContent>
{/* Tab 5: Datenschutz */}
<TabsContent value="datenschutz">
<div className="grid gap-6 pt-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>DSGVO-Einwilligungen</CardTitle>
</CardHeader>
<CardContent>
<ConsentRow
label="Allgemeine Einwilligung"
value={Boolean(member.gdpr_consent)}
/>
<ConsentRow
label="Newsletter"
value={Boolean(member.gdpr_newsletter)}
/>
<ConsentRow
label="Internetveröffentlichung"
value={Boolean(member.gdpr_internet)}
/>
<ConsentRow
label="Printveröffentlichung"
value={Boolean(member.gdpr_print)}
/>
<ConsentRow
label="Geburtstagsinfo"
value={Boolean(member.gdpr_birthday_info)}
/>
{Boolean(member.gdpr_consent_date) && (
<DetailRow
label="Einwilligungsdatum"
value={formatDate(String(member.gdpr_consent_date))}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Datenqualität</CardTitle>
</CardHeader>
<CardContent>
<ConsentRow
label="Adresse gültig"
value={!member.address_invalid}
/>
<ConsentRow
label="Datenabgleich nötig"
value={Boolean(member.data_reconciliation_needed)}
invert
/>
<DetailRow
label="Online-Zugang"
value={member.user_id ? 'Verknüpft' : 'Nicht verknüpft'}
/>
<DetailRow
label="Zugang gesperrt"
value={member.online_access_blocked ? 'Ja' : 'Nein'}
/>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
);
}
/* ─── Consent Row ─── */
function ConsentRow({
label,
value,
invert,
}: {
label: string;
value: boolean;
invert?: boolean;
}) {
const isGood = invert ? !value : value;
return (
<div className="flex items-center justify-between border-b py-2 last:border-b-0">
<span className="text-muted-foreground text-sm font-medium">{label}</span>
<Badge variant={isGood ? 'default' : 'secondary'}>
{value ? 'Ja' : 'Nein'}
</Badge>
</div>
);
}
/* ─── Roles Section ─── */
function RolesSection({
roles,
memberId,
accountId,
}: {
roles: MemberRole[];
memberId: string;
accountId: string;
}) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const [roleName, setRoleName] = useState('');
const [fromDate, setFromDate] = useState('');
const [untilDate, setUntilDate] = useState('');
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
createMemberRole,
{
successMessage: 'Funktion erstellt',
onSuccess: () => {
setShowForm(false);
setRoleName('');
setFromDate('');
setUntilDate('');
router.refresh();
},
},
);
const { execute: executeDeleteRole } = useActionWithToast(deleteMemberRole, {
successMessage: 'Funktion gelöscht',
onSuccess: () => router.refresh(),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Funktionen</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(!showForm)}
data-test="add-role-btn"
>
{showForm ? 'Abbrechen' : '+ Funktion'}
</Button>
</CardHeader>
<CardContent>
{showForm && (
<div className="mb-4 space-y-3 rounded-lg border p-3">
<div>
<Label className="text-xs">Bezeichnung</Label>
<Input
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="z.B. Kassier"
data-test="role-name-input"
/>
</div>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-xs">Von</Label>
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
/>
</div>
<div className="flex-1">
<Label className="text-xs">Bis</Label>
<Input
type="date"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
/>
</div>
</div>
<Button
size="sm"
disabled={!roleName || isCreating}
onClick={() =>
executeCreate({
memberId,
accountId,
roleName,
fromDate: fromDate || undefined,
untilDate: untilDate || undefined,
})
}
data-test="save-role-btn"
>
Speichern
</Button>
</div>
)}
{roles.length > 0 ? (
<div className="space-y-2">
{roles.map((role) => (
<div
key={role.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div>
<p className="text-sm font-medium">{role.role_name}</p>
<p className="text-muted-foreground text-xs">
{role.from_date ? formatDate(role.from_date) : '—'}
{' — '}
{role.until_date ? formatDate(role.until_date) : 'heute'}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8"
onClick={() => executeDeleteRole({ roleId: role.id })}
>
Löschen
</Button>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">
Keine Funktionen zugewiesen.
</p>
)}
</CardContent>
</Card>
);
}
/* ─── Honors Section ─── */
function HonorsSection({
honors,
memberId,
accountId,
}: {
honors: MemberHonor[];
memberId: string;
accountId: string;
}) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const [honorName, setHonorName] = useState('');
const [honorDate, setHonorDate] = useState('');
const [description, setDescription] = useState('');
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
createMemberHonor,
{
successMessage: 'Ehrung erstellt',
onSuccess: () => {
setShowForm(false);
setHonorName('');
setHonorDate('');
setDescription('');
router.refresh();
},
},
);
const { execute: executeDeleteHonor } = useActionWithToast(
deleteMemberHonor,
{
successMessage: 'Ehrung gelöscht',
onSuccess: () => router.refresh(),
},
);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Ehrungen</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(!showForm)}
data-test="add-honor-btn"
>
{showForm ? 'Abbrechen' : '+ Ehrung'}
</Button>
</CardHeader>
<CardContent>
{showForm && (
<div className="mb-4 space-y-3 rounded-lg border p-3">
<div>
<Label className="text-xs">Bezeichnung</Label>
<Input
value={honorName}
onChange={(e) => setHonorName(e.target.value)}
placeholder="z.B. 25 Jahre Mitgliedschaft"
data-test="honor-name-input"
/>
</div>
<div>
<Label className="text-xs">Datum</Label>
<Input
type="date"
value={honorDate}
onChange={(e) => setHonorDate(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">Beschreibung</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
/>
</div>
<Button
size="sm"
disabled={!honorName || isCreating}
onClick={() =>
executeCreate({
memberId,
accountId,
honorName,
honorDate: honorDate || undefined,
description: description || undefined,
})
}
data-test="save-honor-btn"
>
Speichern
</Button>
</div>
)}
{honors.length > 0 ? (
<div className="space-y-2">
{honors.map((honor) => (
<div
key={honor.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div>
<p className="text-sm font-medium">{honor.honor_name}</p>
<p className="text-muted-foreground text-xs">
{honor.honor_date ? formatDate(honor.honor_date) : '—'}
{honor.description && `${honor.description}`}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive h-8"
onClick={() => executeDeleteHonor({ honorId: honor.id })}
>
Löschen
</Button>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">
Keine Ehrungen vorhanden.
</p>
)}
</CardContent>
</Card>
);
}
/* ─── Mandates Section ─── */
function MandatesSection({
mandates,
memberId,
accountId,
}: {
mandates: SepaMandate[];
memberId: string;
accountId: string;
}) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const [mandateRef, setMandateRef] = useState('');
const [iban, setIban] = useState('');
const [bic, setBic] = useState('');
const [holder, setHolder] = useState('');
const [mandateDate, setMandateDate] = useState(
new Date().toISOString().split('T')[0]!,
);
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
createMandate,
{
successMessage: 'Mandat erstellt',
onSuccess: () => {
setShowForm(false);
setMandateRef('');
setIban('');
setBic('');
setHolder('');
router.refresh();
},
},
);
const { execute: executeRevoke } = useActionWithToast(revokeMandate, {
successMessage: 'Mandat widerrufen',
onSuccess: () => router.refresh(),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>SEPA-Mandate</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(!showForm)}
data-test="add-mandate-btn"
>
{showForm ? 'Abbrechen' : '+ Mandat'}
</Button>
</CardHeader>
<CardContent>
{showForm && (
<div className="mb-4 space-y-3 rounded-lg border p-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Mandatsreferenz</Label>
<Input
value={mandateRef}
onChange={(e) => setMandateRef(e.target.value)}
data-test="mandate-ref-input"
/>
</div>
<div>
<Label className="text-xs">Datum</Label>
<Input
type="date"
value={mandateDate}
onChange={(e) => setMandateDate(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">IBAN</Label>
<Input
value={iban}
onChange={(e) => setIban(e.target.value)}
data-test="mandate-iban-input"
/>
</div>
<div>
<Label className="text-xs">BIC</Label>
<Input value={bic} onChange={(e) => setBic(e.target.value)} />
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Kontoinhaber</Label>
<Input
value={holder}
onChange={(e) => setHolder(e.target.value)}
data-test="mandate-holder-input"
/>
</div>
</div>
<Button
size="sm"
disabled={!mandateRef || !iban || !holder || isCreating}
onClick={() =>
executeCreate({
memberId,
accountId,
mandateReference: mandateRef,
iban,
bic: bic || undefined,
accountHolder: holder,
mandateDate,
})
}
data-test="save-mandate-btn"
>
Speichern
</Button>
</div>
)}
{mandates.length > 0 ? (
<div className="space-y-2">
{mandates.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{m.mandate_reference}</p>
<Badge
variant={m.status === 'active' ? 'default' : 'secondary'}
>
{m.status}
</Badge>
{m.is_primary && <Badge variant="outline">Primär</Badge>}
</div>
<p className="text-muted-foreground text-xs">
{formatIban(m.iban)} {m.account_holder}
</p>
</div>
{m.status === 'active' && (
<Button
variant="ghost"
size="sm"
className="text-destructive h-8"
onClick={() => executeRevoke({ mandateId: m.id })}
>
Widerrufen
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">
Keine SEPA-Mandate vorhanden.
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
Mail,
Phone,
MapPin,
Calendar,
ExternalLink,
Hash,
} from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@kit/ui/sheet';
import { toast } from '@kit/ui/sonner';
import {
STATUS_LABELS,
getMemberStatusColor,
computeMembershipYears,
} from '../lib/member-utils';
import { updateMember } from '../server/actions/member-actions';
import { MemberAvatar } from './member-avatar';
import type { MemberRow } from './members-table-columns';
interface MemberQuickPreviewProps {
member: MemberRow | null;
account: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MemberQuickPreview({
member,
account,
open,
onOpenChange,
}: MemberQuickPreviewProps) {
const router = useRouter();
const { execute: executeStatusChange } = useAction(updateMember, {
onSuccess: () => {
toast.success('Status aktualisiert');
router.refresh();
},
onError: () => toast.error('Fehler beim Aktualisieren'),
});
if (!member) return null;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[400px] sm:w-[440px]">
<SheetHeader>
<SheetTitle className="sr-only">Mitglied Vorschau</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-6 pt-4">
{/* Header */}
<div className="flex items-start gap-4">
<MemberAvatar
firstName={member.firstName}
lastName={member.lastName}
size="lg"
/>
<div className="min-w-0 flex-1">
<h3 className="truncate text-lg font-semibold">
{member.firstName} {member.lastName}
</h3>
{member.memberNumber && (
<p className="text-muted-foreground text-sm">
Nr. {member.memberNumber}
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1.5">
<Badge variant={getMemberStatusColor(member.status)}>
{STATUS_LABELS[member.status] ?? member.status}
</Badge>
{member.isHonorary && (
<Badge variant="outline">Ehrenmitglied</Badge>
)}
{member.isFoundingMember && (
<Badge variant="outline">Gründungsmitglied</Badge>
)}
{member.isYouth && <Badge variant="outline">Jugend</Badge>}
</div>
</div>
</div>
{/* Quick status change */}
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs font-medium">
Status ändern
</label>
<Select
value={member.status}
onValueChange={(status) => {
if (status && status !== member.status) {
executeStatusChange({
memberId: member.id,
status: status as any,
});
}
}}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktiv</SelectItem>
<SelectItem value="inactive">Inaktiv</SelectItem>
<SelectItem value="pending">Ausstehend</SelectItem>
<SelectItem value="resigned">Ausgetreten</SelectItem>
</SelectContent>
</Select>
</div>
{/* Details */}
<div className="space-y-3">
{member.email && (
<InfoRow icon={Mail} label="E-Mail">
<a
href={`mailto:${member.email}`}
className="text-primary truncate hover:underline"
>
{member.email}
</a>
</InfoRow>
)}
{(member.phone || member.mobile) && (
<InfoRow icon={Phone} label="Telefon">
{member.phone || member.mobile}
</InfoRow>
)}
{member.city && (
<InfoRow icon={MapPin} label="Ort">
{member.city}
</InfoRow>
)}
{member.entryDate && (
<InfoRow icon={Calendar} label="Eintritt">
{formatDate(member.entryDate)} (
{computeMembershipYears(member.entryDate)} Jahre)
</InfoRow>
)}
{member.memberNumber && (
<InfoRow icon={Hash} label="Mitgliedsnr.">
{member.memberNumber}
</InfoRow>
)}
</div>
{/* Actions */}
<div className="flex gap-2 border-t pt-4">
<Link
href={`/home/${account}/members-cms/${member.id}`}
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors"
data-test="preview-view-full"
>
<ExternalLink className="size-4" />
Vollansicht
</Link>
<Link
href={`/home/${account}/members-cms/${member.id}/edit`}
className="border-border bg-background hover:bg-muted inline-flex flex-1 items-center justify-center rounded-lg border px-2.5 py-1.5 text-sm font-medium transition-colors"
>
Bearbeiten
</Link>
</div>
</div>
</SheetContent>
</Sheet>
);
}
function InfoRow({
icon: Icon,
label,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start gap-3">
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="truncate text-sm">{children}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Card, CardContent } from '@kit/ui/card';
import { cn } from '@kit/ui/utils';
interface QuickStats {
total: number;
active: number;
pending: number;
newThisYear: number;
pendingApplications: number;
}
interface MemberStatsBarProps {
stats: QuickStats;
className?: string;
}
function StatCard({
label,
value,
accent,
}: {
label: string;
value: number;
accent?: string;
}) {
return (
<Card className="min-w-0 flex-1">
<CardContent className="p-3">
<p className="text-muted-foreground truncate text-xs">{label}</p>
<p className={cn('text-2xl font-semibold tabular-nums', accent)}>
{value}
</p>
</CardContent>
</Card>
);
}
export function MemberStatsBar({ stats, className }: MemberStatsBarProps) {
return (
<div className={cn('grid grid-cols-2 gap-3 md:grid-cols-4', className)}>
<StatCard label="Gesamt" value={stats.total} />
<StatCard
label="Aktiv"
value={stats.active}
accent="text-green-600 dark:text-green-400"
/>
<StatCard
label="Ausstehend"
value={stats.pending}
accent="text-amber-600 dark:text-amber-400"
/>
<StatCard
label="Neu dieses Jahr"
value={stats.newThisYear}
accent="text-blue-600 dark:text-blue-400"
/>
</div>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
interface MembersFilterPanelProps {
departments: Array<{ id: string; name: string; memberCount: number }>;
duesCategories: Array<{ id: string; name: string }>;
onClose: () => void;
}
const FLAG_OPTIONS = [
{ key: 'honorary', label: 'Ehrenmitglied' },
{ key: 'founding', label: 'Gründungsmitglied' },
{ key: 'youth', label: 'Jugend' },
{ key: 'retiree', label: 'Senior' },
{ key: 'probationary', label: 'Probezeit' },
] as const;
export function MembersFilterPanel({
departments,
duesCategories,
onClose,
}: MembersFilterPanelProps) {
const router = useRouter();
const searchParams = useSearchParams();
const applyFilter = useCallback(
(key: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
params.delete('page');
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const clearAllFilters = useCallback(() => {
router.push('?');
onClose();
}, [router, onClose]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Erweiterte Filter</h4>
<Button variant="ghost" size="sm" onClick={clearAllFilters}>
Zurücksetzen
</Button>
</div>
{/* Department */}
{departments.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Abteilung</Label>
<Select
value={searchParams.get('departmentId') ?? ''}
onValueChange={(v) =>
applyFilter('departmentId', v === 'all' ? null : v)
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Alle Abteilungen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Abteilungen</SelectItem>
{departments.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name} ({d.memberCount})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Dues Category */}
{duesCategories.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Beitragskategorie</Label>
<Select
value={searchParams.get('duesCategoryId') ?? ''}
onValueChange={(v) =>
applyFilter('duesCategoryId', v === 'all' ? null : v)
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Alle Kategorien" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Kategorien</SelectItem>
{duesCategories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Entry date range */}
<div className="space-y-1.5">
<Label className="text-xs">Eintrittsdatum</Label>
<div className="flex gap-2">
<Input
type="date"
className="h-8 text-xs"
value={searchParams.get('entryDateFrom') ?? ''}
onChange={(e) =>
applyFilter('entryDateFrom', e.target.value || null)
}
placeholder="Von"
/>
<Input
type="date"
className="h-8 text-xs"
value={searchParams.get('entryDateTo') ?? ''}
onChange={(e) => applyFilter('entryDateTo', e.target.value || null)}
placeholder="Bis"
/>
</div>
</div>
{/* Flags */}
<div className="space-y-1.5">
<Label className="text-xs">Merkmale</Label>
<div className="space-y-2">
{FLAG_OPTIONS.map((flag) => {
const currentFlags = searchParams.get('flags')?.split(',') ?? [];
const isChecked = currentFlags.includes(flag.key);
return (
<div key={flag.key} className="flex items-center gap-2">
<Checkbox
id={`flag-${flag.key}`}
checked={isChecked}
onCheckedChange={(checked) => {
const newFlags = checked
? [...currentFlags, flag.key]
: currentFlags.filter((f) => f !== flag.key);
const filtered = newFlags.filter(Boolean);
applyFilter(
'flags',
filtered.length > 0 ? filtered.join(',') : null,
);
}}
/>
<Label htmlFor={`flag-${flag.key}`} className="text-xs">
{flag.label}
</Label>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,265 @@
'use client';
import { useCallback, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import {
type RowSelectionState,
getCoreRowModel,
useReactTable,
flexRender,
} from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, Plus, Users } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { MemberQuickPreview } from './member-quick-preview';
import { createMembersColumns, type MemberRow } from './members-table-columns';
import { MembersToolbar } from './members-toolbar';
interface MembersListViewProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
accountId: string;
duesCategories: Array<{ id: string; name: string }>;
departments: Array<{ id: string; name: string; memberCount: number }>;
}
export function MembersListView({
data,
total,
page,
pageSize,
account,
accountId,
duesCategories,
departments,
}: MembersListViewProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [previewMember, setPreviewMember] = useState<MemberRow | null>(null);
const members: MemberRow[] = data.map((m) => ({
id: String(m.id),
firstName: String(m.first_name ?? ''),
lastName: String(m.last_name ?? ''),
email: String(m.email ?? ''),
phone: String(m.phone ?? ''),
mobile: String(m.mobile ?? ''),
memberNumber: String(m.member_number ?? ''),
city: String(m.city ?? ''),
status: String(m.status ?? 'active'),
entryDate: String(m.entry_date ?? ''),
isHonorary: Boolean(m.is_honorary),
isFoundingMember: Boolean(m.is_founding_member),
isYouth: Boolean(m.is_youth),
}));
const columns = createMembersColumns({
account,
onPreview: setPreviewMember,
onNavigate: (path) => router.push(path),
});
const table = useReactTable({
data: members,
columns,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
state: { rowSelection },
getRowId: (row) => row.id,
manualPagination: true,
pageCount: Math.ceil(total / pageSize),
});
const selectedIds = Object.keys(rowSelection).filter(
(key) => rowSelection[key],
);
const updateSearchParam = useCallback(
(key: string, value: string | null) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
if (key !== 'page') params.delete('page');
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const hasFilters = searchParams.get('q') || searchParams.get('status');
const totalPages = Math.ceil(total / pageSize);
return (
<div className="space-y-4">
<MembersToolbar
account={account}
accountId={accountId}
searchValue={searchParams.get('q') ?? ''}
statusFilter={searchParams.get('status') ?? ''}
onSearchChange={(value) => updateSearchParam('q', value || null)}
onStatusChange={(value) => updateSearchParam('status', value || null)}
selectedCount={selectedIds.length}
selectedIds={selectedIds}
departments={departments}
duesCategories={duesCategories}
/>
{/* Table or empty state */}
{total === 0 && !hasFilters ? (
<EmptyState account={account} />
) : (
<>
<div className="overflow-auto rounded-md border">
<Table>
<TableHeader className="bg-muted/40 sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className="cursor-pointer transition-colors"
onClick={() => setPreviewMember(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-muted-foreground h-32 text-center"
>
<div className="space-y-1">
<p className="font-medium">Keine Mitglieder gefunden</p>
<p className="text-sm">
Versuchen Sie andere Suchbegriffe oder Filter.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm tabular-nums">
{total} Mitglieder
{selectedIds.length > 0 && `${selectedIds.length} ausgewählt`}
</p>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => updateSearchParam('page', String(page - 1))}
data-test="members-prev-page"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => updateSearchParam('page', String(page + 1))}
data-test="members-next-page"
>
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
</>
)}
{/* Quick preview sheet */}
<MemberQuickPreview
member={previewMember}
account={account}
open={!!previewMember}
onOpenChange={(open) => {
if (!open) setPreviewMember(null);
}}
/>
</div>
);
}
/* ─── Empty State ─── */
function EmptyState({ account }: { account: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
<div className="bg-muted mb-4 flex size-16 items-center justify-center rounded-full">
<Users className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-semibold">Noch keine Mitglieder</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-center text-sm">
Erstellen Sie Ihr erstes Mitglied oder importieren Sie bestehende
Mitgliederdaten aus einer CSV-Datei.
</p>
<div className="mt-6 flex gap-3">
<Link
href={`/home/${account}/members-cms/new`}
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors"
data-test="empty-create-member"
>
<Plus className="size-4" />
Mitglied erstellen
</Link>
<Link
href={`/home/${account}/members-cms/import`}
className="border-border bg-background hover:bg-muted inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium transition-colors"
>
CSV importieren
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,263 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Mail, Eye, Pencil, Archive } from 'lucide-react';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Checkbox } from '@kit/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
import { MemberAvatar } from './member-avatar';
export interface MemberRow {
id: string;
firstName: string;
lastName: string;
email: string;
phone: string;
mobile: string;
memberNumber: string;
city: string;
status: string;
entryDate: string;
isHonorary: boolean;
isFoundingMember: boolean;
isYouth: boolean;
}
interface ColumnOptions {
account: string;
onPreview: (member: MemberRow) => void;
onNavigate: (path: string) => void;
}
export function createMembersColumns({
account,
onPreview,
onNavigate,
}: ColumnOptions): ColumnDef<MemberRow>[] {
return [
// Checkbox column
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Alle auswählen"
data-test="members-select-all"
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Auswählen"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
size: 40,
},
// Avatar + Name column
{
id: 'name',
header: 'Name',
cell: ({ row }) => {
const m = row.original;
return (
<div className="flex items-center gap-3">
<MemberAvatar
firstName={m.firstName}
lastName={m.lastName}
size="sm"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{m.lastName}, {m.firstName}
</p>
{m.memberNumber && (
<p className="text-muted-foreground truncate text-xs">
Nr. {m.memberNumber}
</p>
)}
</div>
</div>
);
},
size: 220,
},
// Contact column
{
id: 'contact',
header: 'Kontakt',
cell: ({ row }) => {
const m = row.original;
return (
<div className="min-w-0">
{m.email && (
<p className="text-muted-foreground truncate text-sm">
{m.email}
</p>
)}
{(m.phone || m.mobile) && (
<p className="text-muted-foreground truncate text-xs">
{m.phone || m.mobile}
</p>
)}
{!m.email && !m.phone && !m.mobile && (
<span className="text-muted-foreground/50 text-sm"></span>
)}
</div>
);
},
size: 200,
},
// City
{
accessorKey: 'city',
header: 'Ort',
cell: ({ getValue }) => (
<span className="text-muted-foreground text-sm">
{String(getValue() ?? '') || '—'}
</span>
),
size: 120,
},
// Status with color dot
{
accessorKey: 'status',
header: 'Status',
cell: ({ getValue }) => {
const status = String(getValue());
return (
<div className="flex items-center gap-2">
<span
className={`size-2 rounded-full ${
status === 'active'
? 'bg-green-500'
: status === 'pending'
? 'bg-amber-500'
: status === 'inactive'
? 'bg-gray-400'
: 'bg-red-500'
}`}
/>
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</div>
);
},
size: 130,
},
// Entry date
{
accessorKey: 'entryDate',
header: 'Eintritt',
cell: ({ getValue }) => {
const date = String(getValue());
return (
<span className="text-muted-foreground text-sm tabular-nums">
{date ? formatDate(date) : '—'}
</span>
);
},
size: 100,
},
// Flags
{
id: 'flags',
header: '',
cell: ({ row }) => {
const m = row.original;
const flags = [];
if (m.isHonorary) flags.push({ key: 'E', label: 'Ehrenmitglied' });
if (m.isFoundingMember)
flags.push({ key: 'G', label: 'Gründungsmitglied' });
if (m.isYouth) flags.push({ key: 'J', label: 'Jugend' });
if (flags.length === 0) return null;
return (
<div className="flex gap-1">
{flags.map((f) => (
<span
key={f.key}
title={f.label}
className="bg-muted text-muted-foreground inline-flex size-6 items-center justify-center rounded text-xs font-medium"
>
{f.key}
</span>
))}
</div>
);
},
size: 80,
},
// Row actions
{
id: 'actions',
cell: ({ row }: { row: { original: MemberRow } }) => {
const m = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger
className="hover:bg-accent inline-flex size-8 items-center justify-center rounded-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
data-test="member-row-actions"
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onPreview(m)}>
<Eye className="mr-2 size-4" />
Ansehen
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onNavigate(`/home/${account}/members-cms/${m.id}/edit`)
}
>
<Pencil className="mr-2 size-4" />
Bearbeiten
</DropdownMenuItem>
{m.email && (
<DropdownMenuItem
onClick={() => {
window.open(`mailto:${m.email}`, '_self');
}}
>
<Mail className="mr-2 size-4" />
E-Mail senden
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Archive className="mr-2 size-4" />
Archivieren
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
size: 50,
},
];
}

View File

@@ -0,0 +1,304 @@
'use client';
import { useEffect, useRef, useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Download, Filter, Plus, Search, X } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
import { useFileDownloadAction } from '@kit/ui/use-file-download-action';
import {
exportMembers,
exportMembersExcel,
bulkUpdateStatus,
bulkArchiveMembers,
} from '../server/actions/member-actions';
import { MembersFilterPanel } from './members-filter-panel';
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'active', label: 'Aktiv' },
{ value: 'inactive', label: 'Inaktiv' },
{ value: 'pending', label: 'Ausstehend' },
{ value: 'resigned', label: 'Ausgetreten' },
] as const;
interface MembersToolbarProps {
account: string;
accountId: string;
searchValue: string;
statusFilter: string;
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
selectedCount: number;
selectedIds: string[];
departments: Array<{ id: string; name: string; memberCount: number }>;
duesCategories: Array<{ id: string; name: string }>;
}
export function MembersToolbar({
account,
accountId,
searchValue,
statusFilter,
onSearchChange,
onStatusChange,
selectedCount,
selectedIds,
departments,
duesCategories,
}: MembersToolbarProps) {
const router = useRouter();
const [, startTransition] = useTransition();
const [searchInput, setSearchInput] = useState(searchValue);
const [filterOpen, setFilterOpen] = useState(false);
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const { execute: csvDownload, isPending: isCsvDownloading } =
useFileDownloadAction(exportMembers);
const { execute: excelDownload, isPending: isExcelDownloading } =
useFileDownloadAction(exportMembersExcel);
// Debounced search — auto-triggers after 300ms
useEffect(() => {
if (searchInput === searchValue) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onSearchChange(searchInput);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchInput, searchValue, onSearchChange]);
const { execute: executeBulkStatus } = useAction(bulkUpdateStatus, {
onSuccess: () => {
toast.success('Status aktualisiert');
startTransition(() => router.refresh());
},
onError: () => toast.error('Fehler beim Aktualisieren'),
});
const { execute: executeBulkArchive } = useAction(bulkArchiveMembers, {
onSuccess: () => {
toast.success('Mitglieder archiviert');
setArchiveDialogOpen(false);
startTransition(() => router.refresh());
},
onError: () => toast.error('Fehler beim Archivieren'),
});
// Detect OS for shortcut hint
const isMac =
typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac');
const shortcutHint = isMac ? '⌘K' : 'Ctrl+K';
return (
<div className="space-y-3">
{/* Main toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* Search */}
<div className="relative max-w-md min-w-[200px] flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="Suchen..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pr-16 pl-9"
data-test="members-search"
/>
<div className="absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1">
{searchInput ? (
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setSearchInput('');
onSearchChange('');
}}
>
<X className="size-4" />
</button>
) : (
<kbd className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium">
{shortcutHint}
</kbd>
)}
</div>
</div>
{/* Status filter */}
<Select
value={statusFilter}
onValueChange={(v) => onStatusChange(v === 'all' ? '' : (v ?? ''))}
>
<SelectTrigger
className="w-[150px]"
data-test="members-status-filter"
>
<SelectValue placeholder="Alle Status" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value || 'all'} value={opt.value || 'all'}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Advanced filter */}
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger
className="border-border bg-background hover:bg-muted inline-flex h-7 items-center gap-1 rounded-lg border px-2.5 text-sm font-medium"
data-test="members-filter-btn"
>
<Filter className="size-4" />
<span className="hidden sm:inline">Filter</span>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<MembersFilterPanel
departments={departments}
duesCategories={duesCategories}
onClose={() => setFilterOpen(false)}
/>
</PopoverContent>
</Popover>
<div className="flex-1" />
{/* Export — collapsed on mobile */}
<div className="hidden items-center gap-2 sm:flex">
<Button
variant="outline"
size="sm"
onClick={() =>
csvDownload({
accountId,
status: statusFilter || undefined,
format: 'csv' as const,
})
}
disabled={isCsvDownloading}
data-test="members-export-csv"
>
<Download className="mr-1.5 size-4" />
CSV
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
excelDownload({
accountId,
status: statusFilter || undefined,
format: 'excel' as const,
})
}
disabled={isExcelDownloading}
data-test="members-export-excel"
>
<Download className="mr-1.5 size-4" />
Excel
</Button>
</div>
{/* Create */}
<Link
href={`/home/${account}/members-cms/new`}
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-sm font-medium transition-colors"
data-test="members-create-btn"
>
<Plus className="size-4" />
<span className="hidden sm:inline">Neues Mitglied</span>
<span className="sm:hidden">Neu</span>
</Link>
</div>
{/* Bulk actions bar */}
{selectedCount > 0 && (
<div className="bg-muted/50 border-primary/20 flex items-center gap-3 rounded-lg border px-4 py-2">
<Badge variant="secondary" className="tabular-nums">
{selectedCount} ausgewählt
</Badge>
<Select
onValueChange={(status) =>
executeBulkStatus({
memberIds: selectedIds,
accountId,
status: status as any,
})
}
>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Status ändern" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Aktiv setzen</SelectItem>
<SelectItem value="inactive">Inaktiv setzen</SelectItem>
<SelectItem value="pending">Ausstehend setzen</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setArchiveDialogOpen(true)}
data-test="members-bulk-archive"
>
Archivieren
</Button>
</div>
)}
{/* Archive confirmation dialog */}
<AlertDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mitglieder archivieren</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie {selectedCount} Mitglied
{selectedCount > 1 ? 'er' : ''} wirklich archivieren?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
executeBulkArchive({ memberIds: selectedIds, accountId })
}
>
Archivieren
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -166,3 +166,57 @@ export const AssignDepartmentSchema = z.object({
memberId: z.string().uuid(),
departmentId: z.string().uuid(),
});
// --- Bulk operations & advanced search schemas ---
export const BulkStatusUpdateSchema = z.object({
memberIds: z.array(z.string().uuid()).min(1),
accountId: z.string().uuid(),
status: MembershipStatusEnum,
});
export const BulkDepartmentAssignSchema = z.object({
memberIds: z.array(z.string().uuid()).min(1),
accountId: z.string().uuid(),
departmentId: z.string().uuid(),
});
export const BulkArchiveSchema = z.object({
memberIds: z.array(z.string().uuid()).min(1),
accountId: z.string().uuid(),
});
export const MemberSearchFiltersSchema = z.object({
accountId: z.string().uuid(),
search: z.string().optional(),
status: z.array(MembershipStatusEnum).optional(),
departmentIds: z.array(z.string().uuid()).optional(),
duesCategoryId: z.string().uuid().optional(),
flags: z
.array(
z.enum([
'honorary',
'founding',
'youth',
'retiree',
'probationary',
'transferred',
]),
)
.optional(),
entryDateFrom: z.string().optional(),
entryDateTo: z.string().optional(),
hasEmail: z.boolean().optional(),
sortBy: z.string().default('last_name'),
sortDirection: z.enum(['asc', 'desc']).default('asc'),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(25),
});
export type MemberSearchFilters = z.infer<typeof MemberSearchFiltersSchema>;
export const QuickSearchSchema = z.object({
accountId: z.string().uuid(),
query: z.string().min(1),
limit: z.number().int().min(1).max(20).default(8),
});

View File

@@ -19,6 +19,10 @@ import {
UpdateMandateSchema,
ExportMembersSchema,
AssignDepartmentSchema,
BulkStatusUpdateSchema,
BulkDepartmentAssignSchema,
BulkArchiveSchema,
QuickSearchSchema,
} from '../../schema/member.schema';
import { createMemberManagementApi } from '../api';
@@ -358,7 +362,7 @@ export const inviteMemberToPortal = authActionClient
// 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 },
{ name: 'portal.invite', invitationId: invitation.id },
'Invitation created',
);
@@ -373,3 +377,72 @@ export const revokePortalInvitation = authActionClient
await api.revokePortalInvitation(input.invitationId);
return { success: true };
});
// --- Bulk operations ---
export const bulkUpdateStatus = authActionClient
.inputSchema(BulkStatusUpdateSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info(
{ name: 'member.bulkStatus', count: input.memberIds.length },
`Bulk updating status to ${input.status}...`,
);
await api.bulkUpdateStatus(input.memberIds, input.status, ctx.user.id);
return { success: true };
});
export const bulkAssignDepartment = authActionClient
.inputSchema(BulkDepartmentAssignSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info(
{ name: 'member.bulkDepartment', count: input.memberIds.length },
'Bulk assigning department...',
);
await api.bulkAssignDepartment(input.memberIds, input.departmentId);
return { success: true };
});
export const bulkArchiveMembers = authActionClient
.inputSchema(BulkArchiveSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
logger.info(
{ name: 'member.bulkArchive', count: input.memberIds.length },
'Bulk archiving members...',
);
await api.bulkArchiveMembers(input.memberIds, ctx.user.id);
return { success: true };
});
export const quickSearchMembers = authActionClient
.inputSchema(QuickSearchSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const results = await api.quickSearchMembers(
input.accountId,
input.query,
input.limit,
);
return { success: true, data: results };
});
export const getNextMemberNumber = authActionClient
.inputSchema(z.object({ accountId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const next = await api.getNextMemberNumber(input.accountId);
return { success: true, data: next };
});

View File

@@ -1,3 +1,4 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { todayISO } from '@kit/shared/dates';
@@ -5,6 +6,7 @@ import type { Database } from '@kit/supabase/database';
import type {
CreateMemberInput,
MemberSearchFilters,
UpdateMemberInput,
} from '../schema/member.schema';
@@ -49,11 +51,12 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getMember(memberId: string) {
async getMember(accountId: string, memberId: string) {
const { data, error } = await client
.from('members')
.select('*')
.eq('id', memberId)
.eq('account_id', accountId)
.single();
if (error) throw error;
return data;
@@ -789,5 +792,231 @@ export function createMemberManagementApi(client: SupabaseClient<Database>) {
if (error) throw error;
return data;
},
// --- Quick stats for inline KPI bar ---
async getMemberQuickStats(accountId: string) {
const { data, error } = await (client.rpc as any)(
'get_member_quick_stats',
{
p_account_id: accountId,
},
);
if (error) throw error;
const row = Array.isArray(data) ? data[0] : data;
return {
total: Number(row?.total ?? 0),
active: Number(row?.active ?? 0),
inactive: Number(row?.inactive ?? 0),
pending: Number(row?.pending ?? 0),
resigned: Number(row?.resigned ?? 0),
newThisYear: Number(row?.new_this_year ?? 0),
pendingApplications: Number(row?.pending_applications ?? 0),
};
},
// --- Advanced search with multi-filter ---
async searchMembers(filters: MemberSearchFilters) {
const {
accountId,
search,
status,
departmentIds,
duesCategoryId,
flags,
entryDateFrom,
entryDateTo,
hasEmail,
sortBy,
sortDirection,
page,
pageSize,
} = filters;
let query = client
.from('members')
.select('*', { count: 'exact' })
.eq('account_id', accountId);
// Multi-status filter
if (status && status.length > 0) {
query = query.in('status', status);
}
// Text search
if (search) {
query = query.or(
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%,member_number.ilike.%${search}%,city.ilike.%${search}%`,
);
}
// Dues category filter
if (duesCategoryId) {
query = query.eq('dues_category_id', duesCategoryId);
}
// Flag filters
if (flags && flags.length > 0) {
for (const flag of flags) {
const col = `is_${flag === 'founding' ? 'founding_member' : flag}`;
query = query.eq(col, true);
}
}
// Date range filters
if (entryDateFrom) {
query = query.gte('entry_date', entryDateFrom);
}
if (entryDateTo) {
query = query.lte('entry_date', entryDateTo);
}
// Has email filter
if (hasEmail === true) {
query = query.not('email', 'is', null).neq('email', '');
} else if (hasEmail === false) {
query = query.or('email.is.null,email.eq.');
}
// Department filter — requires subquery via member_department_assignments
if (departmentIds && departmentIds.length > 0) {
const { data: memberIds } = await client
.from('member_department_assignments')
.select('member_id')
.in('department_id', departmentIds);
const ids = (memberIds ?? []).map((d) => d.member_id);
if (ids.length > 0) {
query = query.in('id', ids);
} else {
// No members in selected departments
return { data: [], total: 0, page, pageSize };
}
}
// Sorting
const ascending = sortDirection === 'asc';
const sortColumn =
sortBy === 'first_name'
? 'first_name'
: sortBy === 'entry_date'
? 'entry_date'
: sortBy === 'member_number'
? 'member_number'
: sortBy === 'city'
? 'city'
: sortBy === 'status'
? 'status'
: 'last_name';
query = query.order(sortColumn, { ascending });
if (sortColumn !== 'first_name') {
query = query.order('first_name', { ascending: true });
}
// Pagination
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
// --- Quick search for command palette ---
async quickSearchMembers(
accountId: string,
searchQuery: string,
limit = 8,
) {
const { data, error } = await client
.from('members')
.select('id, first_name, last_name, email, member_number, status')
.eq('account_id', accountId)
.or(
`last_name.ilike.%${searchQuery}%,first_name.ilike.%${searchQuery}%,email.ilike.%${searchQuery}%,member_number.ilike.%${searchQuery}%`,
)
.order('last_name')
.limit(limit);
if (error) throw error;
return data ?? [];
},
// --- Next member number ---
async getNextMemberNumber(accountId: string) {
const { data, error } = await (client.rpc as any)(
'get_next_member_number',
{
p_account_id: accountId,
},
);
if (error) throw error;
return String(data ?? '0001');
},
// --- Bulk operations ---
async bulkUpdateStatus(
memberIds: string[],
status: string,
userId: string,
) {
const { error } = await client
.from('members')
.update({
status: status as any,
updated_by: userId,
...(status === 'resigned' ? { exit_date: todayISO() } : {}),
})
.in('id', memberIds);
if (error) throw error;
},
async bulkAssignDepartment(memberIds: string[], departmentId: string) {
const rows = memberIds.map((memberId) => ({
member_id: memberId,
department_id: departmentId,
}));
const { error } = await client
.from('member_department_assignments')
.upsert(rows, { onConflict: 'member_id,department_id' });
if (error) throw error;
},
async bulkArchiveMembers(memberIds: string[], userId: string) {
const { error } = await client
.from('members')
.update({
is_archived: true,
updated_by: userId,
})
.in('id', memberIds);
if (error) throw error;
},
// --- Departments with member counts ---
async listDepartmentsWithCounts(accountId: string) {
const { data: departments, error: deptError } = await client
.from('member_departments')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (deptError) throw deptError;
const deptIds = (departments ?? []).map((d) => d.id);
if (deptIds.length === 0) {
return [];
}
const { data: assignments, error: assignError } = await client
.from('member_department_assignments')
.select('department_id')
.in('department_id', deptIds);
if (assignError) throw assignError;
const counts = new Map<string, number>();
for (const a of assignments ?? []) {
counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
}
return (departments ?? []).map((d) => ({
...d,
memberCount: counts.get(d.id) ?? 0,
}));
},
};
}

View File

@@ -14,7 +14,11 @@ interface Props {
initialData: Record<string, unknown>;
}
export function SiteEditor({ pageId, accountId: _accountId, initialData }: Props) {
export function SiteEditor({
pageId,
accountId: _accountId,
initialData,
}: Props) {
const { execute: execPublish } = useActionWithToast(publishPage, {
successMessage: 'Seite veröffentlicht',
});

View File

@@ -6404,6 +6404,22 @@ export type Database = {
total_upcoming_events: number
}[]
}
get_member_quick_stats: {
Args: { p_account_id: string }
Returns: {
active: number
inactive: number
new_this_year: number
pending: number
pending_applications: number
resigned: number
total: number
}[]
}
get_next_member_number: {
Args: { p_account_id: string }
Returns: string
}
get_nonce_status: { Args: { p_id: string }; Returns: Json }
get_upper_system_role: { Args: never; Returns: string }
get_user_visible_accounts: { Args: never; Returns: string[] }