feat: enhance member management features; add quick stats and search capabilities
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user