Commits all remaining uncommitted local work: - apps/web: fischerei, verband, modules, members-cms, documents, newsletter, meetings, site-builder, courses, bookings, events, finance pages and components - apps/web: marketing page updates, layout, paths config, next.config.mjs, styles/makerkit.css - apps/web/i18n: documents, fischerei, marketing, verband (de+en) - packages/features: finance, fischerei, member-management, module-builder, newsletter, sitzungsprotokolle, verbandsverwaltung server APIs and components - packages/ui: button.tsx updates - pnpm-lock.yaml
1005 lines
31 KiB
TypeScript
1005 lines
31 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 scope="col" className="p-2 text-left font-medium">
|
|
Bezeichnung
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Von
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Bis
|
|
</th>
|
|
<th scope="col" 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 scope="col" className="p-2 text-left font-medium">
|
|
Bezeichnung
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Datum
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Beschreibung
|
|
</th>
|
|
<th scope="col" 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 scope="col" className="p-2 text-left font-medium">
|
|
Referenz
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
IBAN
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Kontoinhaber
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Datum
|
|
</th>
|
|
<th scope="col" className="p-2 text-left font-medium">
|
|
Status
|
|
</th>
|
|
<th scope="col" 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>
|
|
);
|
|
}
|