Files
myeasycms-v2/packages/features/member-management/src/components/member-detail-view.tsx
T. Zehetbauer db4e19c3af
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped
feat: add invitations management and import wizard; enhance audit logging and member detail fetching
2026-04-01 19:02:55 +02:00

977 lines
30 KiB
TypeScript

'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAction } from 'next-safe-action/hooks';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
import {
STATUS_LABELS,
getMemberStatusColor,
formatAddress,
formatIban,
computeAge,
computeMembershipYears,
} from '../lib/member-utils';
import {
deleteMember,
updateMember,
createMemberRole,
deleteMemberRole,
createMemberHonor,
deleteMemberHonor,
createMandate,
revokeMandate,
} from '../server/actions/member-actions';
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 MemberDetailViewProps {
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 MemberDetailView({
member,
account,
accountId,
roles = [],
honors = [],
mandates = [],
}: MemberDetailViewProps) {
const router = useRouter();
const memberId = String(member.id ?? '');
const status = String(member.status ?? 'active');
const firstName = String(member.first_name ?? '');
const lastName = String(member.last_name ?? '');
const fullName = `${firstName} ${lastName}`.trim();
const { execute: executeDelete, isPending: isDeleting } = useAction(
deleteMember,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde gekündigt');
router.push(`/home/${account}/members-cms`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Kündigen');
},
},
);
const { execute: executeUpdate, isPending: isUpdating } = useAction(
updateMember,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Mitglied wurde archiviert');
router.refresh();
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Archivieren');
},
},
);
const handleDelete = useCallback(() => {
if (
!window.confirm(
`Möchten Sie ${fullName} wirklich kündigen? Diese Aktion kann nicht rückgängig gemacht werden.`,
)
) {
return;
}
executeDelete({ memberId, accountId });
}, [executeDelete, memberId, accountId, fullName]);
const handleArchive = useCallback(() => {
if (!window.confirm(`Möchten Sie ${fullName} wirklich archivieren?`)) {
return;
}
executeUpdate({
memberId,
accountId,
isArchived: true,
});
}, [executeUpdate, memberId, accountId, fullName]);
const age = computeAge(member.date_of_birth as string | null | undefined);
const membershipYears = computeMembershipYears(
member.entry_date as string | null | undefined,
);
const address = formatAddress(member);
const iban = formatIban(member.iban as string | null | undefined);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{fullName}</h1>
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Mitgliedsnr. {String(member.member_number ?? '—')}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
data-test="member-edit-btn"
onClick={() =>
router.push(`/home/${account}/members-cms/${memberId}/edit`)
}
>
Bearbeiten
</Button>
<Button
variant="outline"
disabled={isUpdating}
onClick={handleArchive}
>
{isUpdating ? 'Archiviere...' : 'Archivieren'}
</Button>
<Button
variant="destructive"
disabled={isDeleting}
data-test="member-terminate-btn"
onClick={handleDelete}
>
{isDeleting ? 'Wird gekündigt...' : 'Kündigen'}
</Button>
</div>
</div>
{/* Detail Cards */}
<div className="grid gap-6 md:grid-cols-2">
{/* Persönliche Daten */}
<Card>
<CardHeader>
<CardTitle>Persönliche Daten</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Vorname" value={firstName} />
<DetailRow label="Nachname" value={lastName} />
<DetailRow
label="Geburtsdatum"
value={
member.date_of_birth
? `${formatDate(member.date_of_birth as string)}${age !== null ? ` (${age} Jahre)` : ''}`
: null
}
/>
<DetailRow
label="Geschlecht"
value={String(member.gender ?? '—')}
/>
<DetailRow
label="Anrede"
value={String(member.salutation ?? '—')}
/>
</CardContent>
</Card>
{/* Kontakt */}
<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 ?? '—')} />
</CardContent>
</Card>
{/* Adresse */}
<Card>
<CardHeader>
<CardTitle>Adresse</CardTitle>
</CardHeader>
<CardContent>
<DetailRow label="Adresse" value={address || '—'} />
<DetailRow label="PLZ" value={String(member.postal_code ?? '—')} />
<DetailRow label="Ort" value={String(member.city ?? '—')} />
<DetailRow label="Land" value={String(member.country ?? 'DE')} />
</CardContent>
</Card>
{/* Mitgliedschaft */}
<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={formatDate(member.entry_date as string)}
/>
<DetailRow
label="Mitgliedsjahre"
value={membershipYears > 0 ? `${membershipYears} Jahre` : '—'}
/>
<DetailRow label="IBAN" value={iban} />
<DetailRow label="BIC" value={String(member.bic ?? '—')} />
<DetailRow
label="Kontoinhaber"
value={String(member.account_holder ?? '—')}
/>
<DetailRow label="Notizen" value={String(member.notes ?? '—')} />
</CardContent>
</Card>
</div>
{/* Roles Section */}
<RolesSection roles={roles} memberId={memberId} accountId={accountId} />
{/* Honors Section */}
<HonorsSection
honors={honors}
memberId={memberId}
accountId={accountId}
/>
{/* Mandates Section */}
<MandatesSection
mandates={mandates}
memberId={memberId}
accountId={accountId}
/>
{/* Back */}
<div>
<Button variant="ghost" onClick={() => router.back()}>
Zurück zur Übersicht
</Button>
</div>
</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, isPending: isDeletingRole } =
useActionWithToast(deleteMemberRole, {
successMessage: 'Funktion gelöscht',
onSuccess: () => router.refresh(),
});
const handleCreate = useCallback(() => {
if (!roleName.trim()) return;
executeCreate({
memberId,
accountId,
roleName: roleName.trim(),
fromDate: fromDate || undefined,
untilDate: untilDate || undefined,
});
}, [executeCreate, memberId, accountId, roleName, fromDate, untilDate]);
const handleDeleteRole = useCallback(
(roleId: string) => {
if (!window.confirm('Funktion wirklich löschen?')) return;
executeDeleteRole({ roleId });
},
[executeDeleteRole],
);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Funktionen ({roles.length})</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm((v) => !v)}
data-test="add-role-btn"
>
Neue Funktion
</Button>
</div>
</CardHeader>
<CardContent>
{showForm && (
<div className="mb-4 space-y-3 rounded-md border p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium">
Bezeichnung *
</label>
<input
type="text"
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="z.B. Kassier"
data-test="role-name-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">Von</label>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
data-test="role-from-date"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">Bis</label>
<input
type="date"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
data-test="role-until-date"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreate}
disabled={!roleName.trim() || isCreating}
data-test="role-save-btn"
>
{isCreating ? 'Speichere...' : 'Speichern'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(false)}
>
Abbrechen
</Button>
</div>
</div>
)}
{roles.length === 0 && !showForm ? (
<p className="text-muted-foreground text-sm">
Keine Funktionen vorhanden.
</p>
) : (
roles.length > 0 && (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-2 text-left font-medium">Bezeichnung</th>
<th className="p-2 text-left font-medium">Von</th>
<th className="p-2 text-left font-medium">Bis</th>
<th className="p-2 text-left font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{roles.map((role) => (
<tr key={role.id} className="border-b">
<td className="p-2">{role.role_name}</td>
<td className="p-2 text-xs">
{role.from_date ? formatDate(role.from_date) : '—'}
</td>
<td className="p-2 text-xs">
{role.until_date ? formatDate(role.until_date) : '—'}
</td>
<td className="p-2">
<Button
variant="ghost"
size="sm"
disabled={isDeletingRole}
onClick={() => handleDeleteRole(role.id)}
data-test={`delete-role-${role.id}`}
>
Löschen
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</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, isPending: isDeletingHonor } =
useActionWithToast(deleteMemberHonor, {
successMessage: 'Ehrung gelöscht',
onSuccess: () => router.refresh(),
});
const handleCreate = useCallback(() => {
if (!honorName.trim()) return;
executeCreate({
memberId,
accountId,
honorName: honorName.trim(),
honorDate: honorDate || undefined,
description: description || undefined,
});
}, [executeCreate, memberId, accountId, honorName, honorDate, description]);
const handleDeleteHonor = useCallback(
(honorId: string) => {
if (!window.confirm('Ehrung wirklich löschen?')) return;
executeDeleteHonor({ honorId });
},
[executeDeleteHonor],
);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Ehrungen ({honors.length})</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm((v) => !v)}
data-test="add-honor-btn"
>
Neue Ehrung
</Button>
</div>
</CardHeader>
<CardContent>
{showForm && (
<div className="mb-4 space-y-3 rounded-md border p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium">
Bezeichnung *
</label>
<input
type="text"
value={honorName}
onChange={(e) => setHonorName(e.target.value)}
placeholder="z.B. Ehrennadel in Gold"
data-test="honor-name-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">Datum</label>
<input
type="date"
value={honorDate}
onChange={(e) => setHonorDate(e.target.value)}
data-test="honor-date-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">
Beschreibung
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
data-test="honor-description-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreate}
disabled={!honorName.trim() || isCreating}
data-test="honor-save-btn"
>
{isCreating ? 'Speichere...' : 'Speichern'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(false)}
>
Abbrechen
</Button>
</div>
</div>
)}
{honors.length === 0 && !showForm ? (
<p className="text-muted-foreground text-sm">
Keine Ehrungen vorhanden.
</p>
) : (
honors.length > 0 && (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-2 text-left font-medium">Bezeichnung</th>
<th className="p-2 text-left font-medium">Datum</th>
<th className="p-2 text-left font-medium">Beschreibung</th>
<th className="p-2 text-left font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{honors.map((honor) => (
<tr key={honor.id} className="border-b">
<td className="p-2">{honor.honor_name}</td>
<td className="p-2 text-xs">
{honor.honor_date ? formatDate(honor.honor_date) : '—'}
</td>
<td className="p-2 text-xs">
{honor.description ?? '—'}
</td>
<td className="p-2">
<Button
variant="ghost"
size="sm"
disabled={isDeletingHonor}
onClick={() => handleDeleteHonor(honor.id)}
data-test={`delete-honor-${honor.id}`}
>
Löschen
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</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 [mandateIban, setMandateIban] = useState('');
const [mandateBic, setMandateBic] = useState('');
const [mandateHolder, setMandateHolder] = useState('');
const [mandateDate, setMandateDate] = useState('');
const [mandateSequence, setMandateSequence] = useState<
'FRST' | 'RCUR' | 'FNAL' | 'OOFF'
>('RCUR');
const MANDATE_STATUS_LABELS: Record<string, string> = {
active: 'Aktiv',
revoked: 'Widerrufen',
expired: 'Abgelaufen',
};
const MANDATE_STATUS_COLORS: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
active: 'default',
revoked: 'destructive',
expired: 'outline',
};
const { execute: executeCreate, isPending: isCreating } = useActionWithToast(
createMandate,
{
successMessage: 'Mandat erstellt',
onSuccess: () => {
setShowForm(false);
setMandateRef('');
setMandateIban('');
setMandateBic('');
setMandateHolder('');
setMandateDate('');
setMandateSequence('RCUR');
router.refresh();
},
},
);
const { execute: executeRevoke, isPending: isRevoking } = useActionWithToast(
revokeMandate,
{
successMessage: 'Mandat widerrufen',
onSuccess: () => router.refresh(),
},
);
const handleCreate = useCallback(() => {
if (
!mandateRef.trim() ||
!mandateIban.trim() ||
!mandateHolder.trim() ||
!mandateDate
)
return;
executeCreate({
memberId,
accountId,
mandateReference: mandateRef.trim(),
iban: mandateIban.trim(),
bic: mandateBic.trim() || undefined,
accountHolder: mandateHolder.trim(),
mandateDate,
sequence: mandateSequence,
});
}, [
executeCreate,
memberId,
accountId,
mandateRef,
mandateIban,
mandateBic,
mandateHolder,
mandateDate,
mandateSequence,
]);
const handleRevoke = useCallback(
(mandateId: string) => {
if (!window.confirm('Mandat wirklich widerrufen?')) return;
executeRevoke({ mandateId });
},
[executeRevoke],
);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>SEPA-Mandate ({mandates.length})</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm((v) => !v)}
data-test="add-mandate-btn"
>
Neues Mandat
</Button>
</div>
</CardHeader>
<CardContent>
{showForm && (
<div className="mb-4 space-y-3 rounded-md border p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium">
Mandatsreferenz *
</label>
<input
type="text"
value={mandateRef}
onChange={(e) => setMandateRef(e.target.value)}
placeholder="z.B. MAND-001"
data-test="mandate-ref-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">IBAN *</label>
<input
type="text"
value={mandateIban}
onChange={(e) => setMandateIban(e.target.value)}
placeholder="DE..."
data-test="mandate-iban-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">BIC</label>
<input
type="text"
value={mandateBic}
onChange={(e) => setMandateBic(e.target.value)}
placeholder="Optional"
data-test="mandate-bic-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">
Kontoinhaber *
</label>
<input
type="text"
value={mandateHolder}
onChange={(e) => setMandateHolder(e.target.value)}
placeholder="Name des Kontoinhabers"
data-test="mandate-holder-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">
Mandatsdatum *
</label>
<input
type="date"
value={mandateDate}
onChange={(e) => setMandateDate(e.target.value)}
data-test="mandate-date-input"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium">
Sequenz
</label>
<select
value={mandateSequence}
onChange={(e) =>
setMandateSequence(
e.target.value as 'FRST' | 'RCUR' | 'FNAL' | 'OOFF',
)
}
data-test="mandate-sequence-select"
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
>
<option value="RCUR">RCUR (wiederkehrend)</option>
<option value="FRST">FRST (erstmalig)</option>
<option value="FNAL">FNAL (letztmalig)</option>
<option value="OOFF">OOFF (einmalig)</option>
</select>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreate}
disabled={
!mandateRef.trim() ||
!mandateIban.trim() ||
!mandateHolder.trim() ||
!mandateDate ||
isCreating
}
data-test="mandate-save-btn"
>
{isCreating ? 'Speichere...' : 'Speichern'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(false)}
>
Abbrechen
</Button>
</div>
</div>
)}
{mandates.length === 0 && !showForm ? (
<p className="text-muted-foreground text-sm">
Keine SEPA-Mandate vorhanden.
</p>
) : (
mandates.length > 0 && (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="p-2 text-left font-medium">Referenz</th>
<th className="p-2 text-left font-medium">IBAN</th>
<th className="p-2 text-left font-medium">Kontoinhaber</th>
<th className="p-2 text-left font-medium">Datum</th>
<th className="p-2 text-left font-medium">Status</th>
<th className="p-2 text-left font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{mandates.map((mandate) => (
<tr key={mandate.id} className="border-b">
<td className="p-2 font-mono text-xs">
{mandate.mandate_reference}
{mandate.is_primary && (
<Badge variant="secondary" className="ml-2">
Primär
</Badge>
)}
</td>
<td className="p-2 font-mono text-xs">
{formatIban(mandate.iban)}
</td>
<td className="p-2 text-xs">{mandate.account_holder}</td>
<td className="p-2 text-xs">
{formatDate(mandate.mandate_date)}
</td>
<td className="p-2">
<Badge
variant={
MANDATE_STATUS_COLORS[mandate.status] ?? 'secondary'
}
>
{MANDATE_STATUS_LABELS[mandate.status] ??
mandate.status}
</Badge>
</td>
<td className="p-2">
{mandate.status === 'active' && (
<Button
variant="ghost"
size="sm"
disabled={isRevoking}
onClick={() => handleRevoke(mandate.id)}
data-test={`revoke-mandate-${mandate.id}`}
>
Widerrufen
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</CardContent>
</Card>
);
}