feat: add invitations management and import wizard; enhance audit logging and member detail fetching
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } 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 { toast } from '@kit/ui/sonner';
|
||||
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||
|
||||
import {
|
||||
STATUS_LABELS,
|
||||
@@ -21,12 +21,51 @@ import {
|
||||
computeAge,
|
||||
computeMembershipYears,
|
||||
} from '../lib/member-utils';
|
||||
import { deleteMember, updateMember } from '../server/actions/member-actions';
|
||||
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({
|
||||
@@ -48,6 +87,9 @@ export function MemberDetailView({
|
||||
member,
|
||||
account,
|
||||
accountId,
|
||||
roles = [],
|
||||
honors = [],
|
||||
mandates = [],
|
||||
}: MemberDetailViewProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -57,8 +99,6 @@ export function MemberDetailView({
|
||||
const lastName = String(member.last_name ?? '');
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { execute: executeDelete, isPending: isDeleting } = useAction(
|
||||
deleteMember,
|
||||
{
|
||||
@@ -252,6 +292,23 @@ export function MemberDetailView({
|
||||
</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()}>
|
||||
@@ -261,3 +318,659 @@ export function MemberDetailView({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,3 +172,102 @@ export const lockRecord = authActionClient
|
||||
|
||||
return { success: true, data: record };
|
||||
});
|
||||
|
||||
export const bulkImportRecords = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
moduleId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
records: z.array(z.record(z.string(), z.unknown())).min(1).max(1000),
|
||||
dryRun: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createModuleBuilderApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const moduleWithFields = await api.modules.getModuleWithFields(
|
||||
input.moduleId,
|
||||
);
|
||||
|
||||
if (!moduleWithFields) {
|
||||
throw new Error('Module not found');
|
||||
}
|
||||
|
||||
const { fields } = moduleWithFields;
|
||||
const errors: Array<{ row: number; field: string; message: string }> = [];
|
||||
const validRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let i = 0; i < input.records.length; i++) {
|
||||
const row = input.records[i]!;
|
||||
const validation = validateRecordData(
|
||||
row,
|
||||
fields as Parameters<typeof validateRecordData>[1],
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
for (const err of validation.errors) {
|
||||
errors.push({ row: i + 1, field: err.field, message: err.message });
|
||||
}
|
||||
} else {
|
||||
validRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.dryRun) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalRows: input.records.length,
|
||||
validRows: validRows.length,
|
||||
errorCount: errors.length,
|
||||
errors: errors.slice(0, 50),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${errors.length} Validierungsfehler in ${new Set(errors.map((e) => e.row)).size} Zeilen`,
|
||||
validationErrors: errors.slice(0, 50).map((e) => ({
|
||||
field: `Zeile ${e.row}: ${e.field}`,
|
||||
message: e.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'records.bulkImport',
|
||||
moduleId: input.moduleId,
|
||||
count: validRows.length,
|
||||
},
|
||||
'Bulk importing records...',
|
||||
);
|
||||
|
||||
const insertData = validRows.map((row) => ({
|
||||
module_id: input.moduleId,
|
||||
account_id: input.accountId,
|
||||
data: row as any,
|
||||
status: 'active' as const,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
}));
|
||||
|
||||
const { error } = await client.from('module_records').insert(insertData);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
logger.info(
|
||||
{ name: 'records.bulkImport', count: validRows.length },
|
||||
'Bulk import complete',
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { imported: validRows.length },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,5 +34,36 @@ export function createAuditService(client: SupabaseClient<Database>) {
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async query(opts?: {
|
||||
accountId?: string;
|
||||
userId?: string;
|
||||
tableName?: string;
|
||||
action?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
let q = client
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (opts?.accountId) q = q.eq('account_id', opts.accountId);
|
||||
if (opts?.userId) q = q.eq('user_id', opts.userId);
|
||||
if (opts?.tableName) q = q.eq('table_name', opts.tableName);
|
||||
if (opts?.action)
|
||||
q = q.eq(
|
||||
'action',
|
||||
opts.action as 'insert' | 'update' | 'delete' | 'lock',
|
||||
);
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 50;
|
||||
q = q.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await q;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user