- fix(member-detail): display gender in German (Männlich/Weiblich/Divers) instead of raw English enum values (male/female/diverse) - fix(member-detail): display country names in German (Österreich, Deutschland) instead of raw ISO codes (AT, DE) - fix(member-statistics): total member count was always 0 getStatistics() returns per-status counts without a total key; now computes total by summing all status counts - fix(i18n): add 56 breadcrumb segment translations for DE and EN Breadcrumbs were showing English path segments (Courses, Calendar, Registrations) because translation keys for URL path segments were missing. Added all segment-level route translations so breadcrumbs now display in German throughout the app.
915 lines
28 KiB
TypeScript
915 lines
28 KiB
TypeScript
'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={
|
|
member.gender
|
|
? { male: 'Männlich', female: 'Weiblich', diverse: 'Divers' }[member.gender as string] ?? 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={
|
|
(() => {
|
|
const code = String(member.country ?? 'DE');
|
|
const countries: Record<string, string> = {
|
|
DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz',
|
|
LI: 'Liechtenstein', LU: 'Luxemburg', IT: 'Italien',
|
|
FR: 'Frankreich', NL: 'Niederlande', BE: 'Belgien',
|
|
PL: 'Polen', CZ: 'Tschechien', DK: 'Dänemark',
|
|
};
|
|
return countries[code] ?? code;
|
|
})()
|
|
}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|