312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useCallback } from 'react';
|
||
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
import { useAction } from 'next-safe-action/hooks';
|
||
import { useForm } from 'react-hook-form';
|
||
|
||
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 { toast } from '@kit/ui/sonner';
|
||
|
||
import { formatIban } from '../lib/member-utils';
|
||
import { createMandate, revokeMandate } from '../server/actions/member-actions';
|
||
|
||
interface MandateManagerProps {
|
||
mandates: Array<Record<string, unknown>>;
|
||
memberId: string;
|
||
accountId: string;
|
||
}
|
||
|
||
const SEQUENCE_LABELS: Record<string, string> = {
|
||
FRST: 'Erstlastschrift',
|
||
RCUR: 'Wiederkehrend',
|
||
FNAL: 'Letzte',
|
||
OOFF: 'Einmalig',
|
||
};
|
||
|
||
function getMandateStatusColor(
|
||
status: string,
|
||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||
switch (status) {
|
||
case 'active':
|
||
return 'default';
|
||
case 'pending':
|
||
return 'outline';
|
||
case 'revoked':
|
||
case 'expired':
|
||
return 'destructive';
|
||
default:
|
||
return 'secondary';
|
||
}
|
||
}
|
||
|
||
const MANDATE_STATUS_LABELS: Record<string, string> = {
|
||
active: 'Aktiv',
|
||
pending: 'Ausstehend',
|
||
revoked: 'Widerrufen',
|
||
expired: 'Abgelaufen',
|
||
};
|
||
|
||
interface MandateFormValues {
|
||
mandateReference: string;
|
||
iban: string;
|
||
bic: string;
|
||
accountHolder: string;
|
||
mandateDate: string;
|
||
sequence: string;
|
||
}
|
||
|
||
export function MandateManager({
|
||
mandates,
|
||
memberId,
|
||
accountId,
|
||
}: MandateManagerProps) {
|
||
const router = useRouter();
|
||
const [showForm, setShowForm] = useState(false);
|
||
|
||
const form = useForm<MandateFormValues>({
|
||
defaultValues: {
|
||
mandateReference: '',
|
||
iban: '',
|
||
bic: '',
|
||
accountHolder: '',
|
||
mandateDate: new Date().toISOString().split('T')[0]!,
|
||
sequence: 'FRST',
|
||
},
|
||
});
|
||
|
||
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||
createMandate,
|
||
{
|
||
onSuccess: ({ data }) => {
|
||
if (data?.success) {
|
||
toast.success('SEPA-Mandat erstellt');
|
||
form.reset();
|
||
setShowForm(false);
|
||
router.refresh();
|
||
}
|
||
},
|
||
onError: ({ error }) => {
|
||
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||
},
|
||
},
|
||
);
|
||
|
||
const { execute: executeRevoke, isPending: isRevoking } = useAction(
|
||
revokeMandate,
|
||
{
|
||
onSuccess: ({ data }) => {
|
||
if (data?.success) {
|
||
toast.success('Mandat widerrufen');
|
||
router.refresh();
|
||
}
|
||
},
|
||
onError: ({ error }) => {
|
||
toast.error(error.serverError ?? 'Fehler beim Widerrufen');
|
||
},
|
||
},
|
||
);
|
||
|
||
const handleSubmit = useCallback(
|
||
(values: MandateFormValues) => {
|
||
executeCreate({
|
||
memberId,
|
||
accountId,
|
||
mandateReference: values.mandateReference,
|
||
iban: values.iban,
|
||
bic: values.bic,
|
||
accountHolder: values.accountHolder,
|
||
mandateDate: values.mandateDate,
|
||
sequence: values.sequence as 'FRST' | 'RCUR' | 'FNAL' | 'OOFF',
|
||
});
|
||
},
|
||
[executeCreate, memberId, accountId],
|
||
);
|
||
|
||
const handleRevoke = useCallback(
|
||
(mandateId: string, reference: string) => {
|
||
if (!window.confirm(`Mandat "${reference}" wirklich widerrufen?`)) {
|
||
return;
|
||
}
|
||
executeRevoke({ mandateId });
|
||
},
|
||
[executeRevoke],
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">SEPA-Mandate</h2>
|
||
<Button
|
||
size="sm"
|
||
variant={showForm ? 'outline' : 'default'}
|
||
onClick={() => setShowForm(!showForm)}
|
||
>
|
||
{showForm ? 'Abbrechen' : 'Neues Mandat'}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Inline Create Form */}
|
||
{showForm && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Neues SEPA-Mandat</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form
|
||
onSubmit={form.handleSubmit(handleSubmit)}
|
||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||
>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">Mandatsreferenz *</label>
|
||
<Input
|
||
placeholder="MANDATE-001"
|
||
{...form.register('mandateReference', { required: true })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">IBAN *</label>
|
||
<Input
|
||
placeholder="DE89 3704 0044 0532 0130 00"
|
||
data-test="mandate-iban-input"
|
||
{...form.register('iban', { required: true })}
|
||
onChange={(e) => {
|
||
const value = e.target.value
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9]/g, '');
|
||
form.setValue('iban', value);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">BIC</label>
|
||
<Input
|
||
placeholder="COBADEFFXXX"
|
||
data-test="mandate-bic-input"
|
||
{...form.register('bic')}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">Kontoinhaber *</label>
|
||
<Input
|
||
placeholder="Max Mustermann"
|
||
data-test="mandate-holder-input"
|
||
{...form.register('accountHolder', { required: true })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">Mandatsdatum *</label>
|
||
<Input
|
||
type="date"
|
||
{...form.register('mandateDate', { required: true })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">Sequenz</label>
|
||
<select
|
||
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
|
||
{...form.register('sequence')}
|
||
>
|
||
<option value="FRST">FRST – Erstlastschrift</option>
|
||
<option value="RCUR">RCUR – Wiederkehrend</option>
|
||
<option value="FNAL">FNAL – Letzte</option>
|
||
<option value="OOFF">OOFF – Einmalig</option>
|
||
</select>
|
||
</div>
|
||
<div className="sm:col-span-2 lg:col-span-3">
|
||
<Button
|
||
type="submit"
|
||
disabled={isCreating}
|
||
data-test="mandate-create-btn"
|
||
>
|
||
{isCreating ? 'Erstelle...' : 'Mandat erstellen'}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Table */}
|
||
<div className="rounded-md border">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-muted/50 border-b">
|
||
<th className="px-4 py-3 text-left font-medium">Referenz</th>
|
||
<th className="px-4 py-3 text-left font-medium">IBAN</th>
|
||
<th className="px-4 py-3 text-left font-medium">Kontoinhaber</th>
|
||
<th className="px-4 py-3 text-left font-medium">Datum</th>
|
||
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||
<th className="px-4 py-3 text-center font-medium">Primär</th>
|
||
<th className="px-4 py-3 text-right font-medium">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{mandates.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={7}
|
||
className="text-muted-foreground px-4 py-8 text-center"
|
||
>
|
||
Keine SEPA-Mandate vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
mandates.map((mandate) => {
|
||
const mandateId = String(mandate.id ?? '');
|
||
const reference = String(mandate.mandate_reference ?? '—');
|
||
const mandateStatus = String(mandate.status ?? 'pending');
|
||
const isPrimary = Boolean(mandate.is_primary);
|
||
const canRevoke =
|
||
mandateStatus === 'active' || mandateStatus === 'pending';
|
||
|
||
return (
|
||
<tr key={mandateId} className="border-b">
|
||
<td className="px-4 py-3 font-mono text-xs">{reference}</td>
|
||
<td className="px-4 py-3 font-mono text-xs">
|
||
{formatIban(mandate.iban as string | null | undefined)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{String(mandate.account_holder ?? '—')}
|
||
</td>
|
||
<td className="text-muted-foreground px-4 py-3">
|
||
{formatDate(mandate.mandate_date as string)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Badge variant={getMandateStatusColor(mandateStatus)}>
|
||
{MANDATE_STATUS_LABELS[mandateStatus] ?? mandateStatus}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 py-3 text-center">
|
||
{isPrimary ? '✓' : '✗'}
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
{canRevoke && (
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
disabled={isRevoking}
|
||
data-test="mandate-revoke-btn"
|
||
onClick={() => handleRevoke(mandateId, reference)}
|
||
>
|
||
Widerrufen
|
||
</Button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|