Files
myeasycms-v2/packages/features/member-management/src/components/member-detail-tabs.tsx
Zaid Marzguioui 1215e351c1
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
fix: UX improvements for German association users
- 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.
2026-04-03 22:10:02 +02:00

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>
);
}