Files
myeasycms-v2/packages/features/member-management/src/components/mandate-manager.tsx

312 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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