feat: add invitations management and import wizard; enhance audit logging and member detail fetching
This commit is contained in:
@@ -1,19 +1,214 @@
|
|||||||
export default async function AdminAuditPage() {
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
|
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||||
|
import { formatDateTime } from '@kit/shared/dates';
|
||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
interface SearchParams {
|
||||||
|
action?: string;
|
||||||
|
table?: string;
|
||||||
|
page?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminAuditPageProps {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
insert: 'Erstellen',
|
||||||
|
update: 'Ändern',
|
||||||
|
delete: 'Löschen',
|
||||||
|
lock: 'Sperren',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<
|
||||||
|
string,
|
||||||
|
'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
|
> = {
|
||||||
|
insert: 'default',
|
||||||
|
update: 'secondary',
|
||||||
|
delete: 'destructive',
|
||||||
|
lock: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function AuditPage(props: AdminAuditPageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const client = getSupabaseServerAdminClient();
|
||||||
|
const api = createModuleBuilderApi(client);
|
||||||
|
|
||||||
|
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
||||||
|
|
||||||
|
const result = await api.audit.query({
|
||||||
|
action: searchParams.action || undefined,
|
||||||
|
tableName: searchParams.table || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(result.total / result.pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<PageBody>
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-2xl font-bold">Protokoll</h1>
|
title="Protokoll"
|
||||||
<p className="text-muted-foreground">
|
description="Mandantenübergreifendes Änderungsprotokoll"
|
||||||
Mandantenübergreifendes Änderungsprotokoll
|
/>
|
||||||
</p>
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<AuditFilters
|
||||||
|
currentAction={searchParams.action}
|
||||||
|
currentTable={searchParams.table}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Results table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th className="p-3 text-left font-medium">Zeitpunkt</th>
|
||||||
|
<th className="p-3 text-left font-medium">Aktion</th>
|
||||||
|
<th className="p-3 text-left font-medium">Tabelle</th>
|
||||||
|
<th className="p-3 text-left font-medium">Datensatz-ID</th>
|
||||||
|
<th className="p-3 text-left font-medium">Benutzer-ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="text-muted-foreground p-8 text-center"
|
||||||
|
>
|
||||||
|
Keine Einträge gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
result.data.map((entry) => (
|
||||||
|
<tr key={entry.id} className="border-b">
|
||||||
|
<td className="p-3 text-xs">
|
||||||
|
{formatDateTime(entry.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
ACTION_COLORS[entry.action as string] ?? 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[entry.action as string] ??
|
||||||
|
String(entry.action)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{String(entry.table_name)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{String(entry.record_id).slice(0, 8)}...
|
||||||
|
</td>
|
||||||
|
<td className="p-3 font-mono text-xs">
|
||||||
|
{String(entry.user_id).slice(0, 8)}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border p-6">
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) über alle
|
Seite {page} von {totalPages} ({result.total} Einträge)
|
||||||
Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Tabelle und Aktion.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{page > 1 && (
|
||||||
|
<PaginationLink
|
||||||
|
page={page - 1}
|
||||||
|
action={searchParams.action}
|
||||||
|
table={searchParams.table}
|
||||||
|
label="Zurück"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page < totalPages && (
|
||||||
|
<PaginationLink
|
||||||
|
page={page + 1}
|
||||||
|
action={searchParams.action}
|
||||||
|
table={searchParams.table}
|
||||||
|
label="Weiter"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditFilters({
|
||||||
|
currentAction,
|
||||||
|
currentTable,
|
||||||
|
}: {
|
||||||
|
currentAction?: string;
|
||||||
|
currentTable?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<form className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
name="action"
|
||||||
|
defaultValue={currentAction ?? ''}
|
||||||
|
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
<option value="insert">Erstellen</option>
|
||||||
|
<option value="update">Ändern</option>
|
||||||
|
<option value="delete">Löschen</option>
|
||||||
|
<option value="lock">Sperren</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
name="table"
|
||||||
|
type="text"
|
||||||
|
placeholder="Tabelle filtern..."
|
||||||
|
defaultValue={currentTable ?? ''}
|
||||||
|
className="border-input bg-background flex h-9 w-48 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
page,
|
||||||
|
action,
|
||||||
|
table,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
action?: string;
|
||||||
|
table?: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', String(page));
|
||||||
|
if (action) params.set('action', action);
|
||||||
|
if (table) params.set('table', table);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={`?${params.toString()}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminGuard(AuditPage);
|
||||||
|
|||||||
@@ -23,12 +23,26 @@ export default async function MemberDetailPage({ params }: Props) {
|
|||||||
const member = await api.getMember(memberId);
|
const member = await api.getMember(memberId);
|
||||||
if (!member) return <div>Mitglied nicht gefunden</div>;
|
if (!member) return <div>Mitglied nicht gefunden</div>;
|
||||||
|
|
||||||
|
// Fetch sub-entities in parallel
|
||||||
|
const [roles, honors, mandates] = await Promise.all([
|
||||||
|
api.listMemberRoles(memberId),
|
||||||
|
api.listMemberHonors(memberId),
|
||||||
|
api.listMandates(memberId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<CmsPageShell
|
||||||
account={account}
|
account={account}
|
||||||
title={`${String(member.first_name)} ${String(member.last_name)}`}
|
title={`${String(member.first_name)} ${String(member.last_name)}`}
|
||||||
>
|
>
|
||||||
<MemberDetailView member={member} account={account} accountId={acct.id} />
|
<MemberDetailView
|
||||||
|
member={member}
|
||||||
|
account={account}
|
||||||
|
accountId={acct.id}
|
||||||
|
roles={roles}
|
||||||
|
honors={honors}
|
||||||
|
mandates={mandates}
|
||||||
|
/>
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Mail, XCircle, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
inviteMemberToPortal,
|
||||||
|
revokePortalInvitation,
|
||||||
|
} from '@kit/member-management/actions/member-actions';
|
||||||
|
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 { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: string;
|
||||||
|
member_id: string;
|
||||||
|
email: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberOption {
|
||||||
|
id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvitationsViewProps {
|
||||||
|
invitations: Invitation[];
|
||||||
|
members: MemberOption[];
|
||||||
|
accountId: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
accepted: 'Angenommen',
|
||||||
|
revoked: 'Widerrufen',
|
||||||
|
expired: 'Abgelaufen',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<
|
||||||
|
string,
|
||||||
|
'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
|
> = {
|
||||||
|
pending: 'default',
|
||||||
|
accepted: 'secondary',
|
||||||
|
revoked: 'destructive',
|
||||||
|
expired: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvitationsView({
|
||||||
|
invitations,
|
||||||
|
members,
|
||||||
|
accountId,
|
||||||
|
account,
|
||||||
|
}: InvitationsViewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [selectedMemberId, setSelectedMemberId] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
const { execute: executeInvite, isPending: isInviting } = useActionWithToast(
|
||||||
|
inviteMemberToPortal,
|
||||||
|
{
|
||||||
|
successMessage: 'Einladung gesendet',
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
setSelectedMemberId('');
|
||||||
|
setEmail('');
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { execute: executeRevoke, isPending: isRevoking } = useActionWithToast(
|
||||||
|
revokePortalInvitation,
|
||||||
|
{
|
||||||
|
successMessage: 'Einladung widerrufen',
|
||||||
|
onSuccess: () => router.refresh(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInvite = useCallback(() => {
|
||||||
|
if (!selectedMemberId || !email) return;
|
||||||
|
executeInvite({
|
||||||
|
memberId: selectedMemberId,
|
||||||
|
accountId,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
}, [executeInvite, selectedMemberId, accountId, email]);
|
||||||
|
|
||||||
|
const handleRevoke = useCallback(
|
||||||
|
(invitationId: string) => {
|
||||||
|
if (!window.confirm('Einladung wirklich widerrufen?')) return;
|
||||||
|
executeRevoke({ invitationId });
|
||||||
|
},
|
||||||
|
[executeRevoke],
|
||||||
|
);
|
||||||
|
|
||||||
|
// When a member is selected, pre-fill email
|
||||||
|
const handleMemberChange = useCallback(
|
||||||
|
(memberId: string) => {
|
||||||
|
setSelectedMemberId(memberId);
|
||||||
|
const member = members.find((m) => m.id === memberId);
|
||||||
|
if (member?.email) {
|
||||||
|
setEmail(member.email);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
data-test="invite-member-btn"
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Einladung senden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Invitation Dialog */}
|
||||||
|
{showDialog && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Einladung senden
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">
|
||||||
|
Mitglied
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedMemberId}
|
||||||
|
onChange={(e) => handleMemberChange(e.target.value)}
|
||||||
|
data-test="invite-member-select"
|
||||||
|
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— Mitglied auswählen —</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.last_name}, {m.first_name}
|
||||||
|
{m.email ? ` (${m.email})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="E-Mail eingeben..."
|
||||||
|
data-test="invite-email-input"
|
||||||
|
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleInvite}
|
||||||
|
disabled={!selectedMemberId || !email || isInviting}
|
||||||
|
data-test="invite-submit-btn"
|
||||||
|
>
|
||||||
|
{isInviting ? 'Sende...' : 'Einladung senden'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowDialog(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invitations Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Einladungen ({invitations.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{invitations.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<Mail className="text-muted-foreground mb-4 h-10 w-10" />
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Keine Einladungen vorhanden
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Senden Sie die erste Einladung zum Mitgliederportal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||||
|
<th className="p-3 text-left font-medium">Status</th>
|
||||||
|
<th className="p-3 text-left font-medium">Erstellt</th>
|
||||||
|
<th className="p-3 text-left font-medium">Läuft ab</th>
|
||||||
|
<th className="p-3 text-left font-medium">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitations.map((inv) => (
|
||||||
|
<tr key={inv.id} className="border-b">
|
||||||
|
<td className="p-3">{inv.email}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge
|
||||||
|
variant={STATUS_COLORS[inv.status] ?? 'secondary'}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[inv.status] ?? inv.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs">
|
||||||
|
{formatDate(inv.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs">
|
||||||
|
{formatDate(inv.expires_at)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{inv.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isRevoking}
|
||||||
|
onClick={() => handleRevoke(inv.id)}
|
||||||
|
data-test={`revoke-invitation-${inv.id}`}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-1 h-4 w-4" />
|
||||||
|
Widerrufen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
import { InvitationsView } from './invitations-view';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function InvitationsPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
const api = createMemberManagementApi(client);
|
||||||
|
const invitations = await api.listPortalInvitations(acct.id);
|
||||||
|
|
||||||
|
// Fetch members for the "send invitation" dialog
|
||||||
|
const { data: members } = await client
|
||||||
|
.from('members')
|
||||||
|
.select('id, first_name, last_name, email')
|
||||||
|
.eq('account_id', acct.id)
|
||||||
|
.eq('status', 'active')
|
||||||
|
.order('last_name');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title="Portal-Einladungen"
|
||||||
|
description="Einladungen zum Mitgliederportal verwalten"
|
||||||
|
>
|
||||||
|
<InvitationsView
|
||||||
|
invitations={invitations}
|
||||||
|
members={members ?? []}
|
||||||
|
accountId={acct.id}
|
||||||
|
account={account}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
|
||||||
|
import { bulkImportRecords } from '@kit/module-builder/actions/record-actions';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
|
interface ImportWizardProps {
|
||||||
|
moduleId: string;
|
||||||
|
accountId: string;
|
||||||
|
fields: Array<{ name: string; display_name: string }>;
|
||||||
|
accountSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done';
|
||||||
|
|
||||||
|
interface DryRunResult {
|
||||||
|
totalRows: number;
|
||||||
|
validRows: number;
|
||||||
|
errorCount: number;
|
||||||
|
errors: Array<{ row: number; field: string; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportWizard({
|
||||||
|
moduleId,
|
||||||
|
accountId,
|
||||||
|
fields,
|
||||||
|
accountSlug,
|
||||||
|
}: ImportWizardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState<Step>('upload');
|
||||||
|
const [headers, setHeaders] = useState<string[]>([]);
|
||||||
|
const [rows, setRows] = useState<Array<Record<string, string>>>([]);
|
||||||
|
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||||
|
const [dryRunResult, setDryRunResult] = useState<DryRunResult | null>(null);
|
||||||
|
const [importedCount, setImportedCount] = useState(0);
|
||||||
|
|
||||||
|
const { execute: executeBulkImport, isPending } = useActionWithToast(
|
||||||
|
bulkImportRecords,
|
||||||
|
{
|
||||||
|
successMessage: 'Import erfolgreich',
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.data) {
|
||||||
|
if ('imported' in data.data) {
|
||||||
|
setImportedCount((data.data as { imported: number }).imported);
|
||||||
|
setStep('done');
|
||||||
|
} else {
|
||||||
|
// dry run result
|
||||||
|
setDryRunResult(data.data as unknown as DryRunResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 1: Parse CSV file
|
||||||
|
const handleFileUpload = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
Papa.parse(file, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
complete: (result) => {
|
||||||
|
const parsed = result.data as Array<Record<string, string>>;
|
||||||
|
if (parsed.length === 0) return;
|
||||||
|
|
||||||
|
const csvHeaders = result.meta.fields ?? [];
|
||||||
|
setHeaders(csvHeaders);
|
||||||
|
setRows(parsed);
|
||||||
|
|
||||||
|
// Auto-map by exact display_name match
|
||||||
|
const autoMap: Record<string, string> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const match = csvHeaders.find(
|
||||||
|
(h) =>
|
||||||
|
h.toLowerCase().trim() ===
|
||||||
|
field.display_name.toLowerCase().trim(),
|
||||||
|
);
|
||||||
|
if (match) autoMap[field.name] = match;
|
||||||
|
}
|
||||||
|
setMapping(autoMap);
|
||||||
|
setStep('mapping');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[fields],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build mapped records from rows + mapping
|
||||||
|
const buildMappedRecords = useCallback(() => {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const sourceCol = mapping[field.name];
|
||||||
|
if (sourceCol && row[sourceCol] !== undefined) {
|
||||||
|
record[field.name] = row[sourceCol];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
}, [rows, mapping, fields]);
|
||||||
|
|
||||||
|
// Step 3: Dry run
|
||||||
|
const handleDryRun = useCallback(() => {
|
||||||
|
const records = buildMappedRecords();
|
||||||
|
executeBulkImport({
|
||||||
|
moduleId,
|
||||||
|
accountId,
|
||||||
|
records,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
}, [buildMappedRecords, executeBulkImport, moduleId, accountId]);
|
||||||
|
|
||||||
|
// Step 4: Actual import
|
||||||
|
const handleImport = useCallback(() => {
|
||||||
|
setStep('importing');
|
||||||
|
const records = buildMappedRecords();
|
||||||
|
executeBulkImport({
|
||||||
|
moduleId,
|
||||||
|
accountId,
|
||||||
|
records,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
}, [buildMappedRecords, executeBulkImport, moduleId, accountId]);
|
||||||
|
|
||||||
|
// Preview: first 5 mapped rows
|
||||||
|
const previewRows = buildMappedRecords().slice(0, 5);
|
||||||
|
const mappedFields = fields.filter((f) => mapping[f.name]);
|
||||||
|
|
||||||
|
const stepIndex = [
|
||||||
|
'upload',
|
||||||
|
'mapping',
|
||||||
|
'preview',
|
||||||
|
'importing',
|
||||||
|
'done',
|
||||||
|
].indexOf(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{['Datei hochladen', 'Spalten zuordnen', 'Vorschau', 'Importieren'].map(
|
||||||
|
(label, i) => (
|
||||||
|
<div key={label} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
|
stepIndex >= i
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${stepIndex >= i ? 'font-semibold' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{i < 3 && (
|
||||||
|
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Upload */}
|
||||||
|
{step === 'upload' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Datei hochladen
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
|
||||||
|
<Upload className="text-muted-foreground mb-4 h-10 w-10" />
|
||||||
|
<p className="text-lg font-semibold">CSV-Datei auswählen</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Komma- oder Semikolon-getrennt, UTF-8
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
data-test="import-file-input"
|
||||||
|
className="file:bg-primary file:text-primary-foreground mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">
|
||||||
|
Verfügbare Zielfelder:
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Badge key={field.name} variant="secondary">
|
||||||
|
{field.display_name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Column Mapping */}
|
||||||
|
{step === 'mapping' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Spalten zuordnen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground mb-4 text-sm">
|
||||||
|
{rows.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den
|
||||||
|
Modulfeldern zu.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.name} className="flex items-center gap-4">
|
||||||
|
<span className="w-48 text-sm font-medium">
|
||||||
|
{field.display_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<select
|
||||||
|
value={mapping[field.name] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMapping((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (e.target.value) {
|
||||||
|
next[field.name] = e.target.value;
|
||||||
|
} else {
|
||||||
|
delete next[field.name];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-test={`mapping-select-${field.name}`}
|
||||||
|
className="border-input bg-background flex h-9 w-64 rounded-md border px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">-- Ignorieren --</option>
|
||||||
|
{headers.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{mapping[field.name] && rows[0] && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
z.B. "{rows[0][mapping[field.name]!]}"
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('upload')}
|
||||||
|
data-test="mapping-back-btn"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setDryRunResult(null);
|
||||||
|
setStep('preview');
|
||||||
|
}}
|
||||||
|
data-test="mapping-next-btn"
|
||||||
|
>
|
||||||
|
Vorschau <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Preview + Dry Run */}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vorschau ({rows.length} Einträge)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{mappedFields.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-yellow-300 bg-yellow-50 p-4 dark:border-yellow-700 dark:bg-yellow-950">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Keine Spalten zugeordnet. Bitte gehen Sie zurück und ordnen
|
||||||
|
Sie mindestens eine Spalte zu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-80 overflow-auto rounded-md border">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th className="p-2 text-left">#</th>
|
||||||
|
{mappedFields.map((f) => (
|
||||||
|
<th key={f.name} className="p-2 text-left">
|
||||||
|
{f.display_name}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewRows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-b">
|
||||||
|
<td className="p-2">{i + 1}</td>
|
||||||
|
{mappedFields.map((f) => (
|
||||||
|
<td key={f.name} className="max-w-32 truncate p-2">
|
||||||
|
{String(row[f.name] ?? '—')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{rows.length > 5 && (
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
... und {rows.length - 5} weitere Einträge
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dry Run */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDryRun}
|
||||||
|
disabled={isPending || mappedFields.length === 0}
|
||||||
|
data-test="dry-run-btn"
|
||||||
|
>
|
||||||
|
{isPending ? 'Prüfe...' : 'Validierung starten (Dry Run)'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dryRunResult && (
|
||||||
|
<div className="mt-4 space-y-2 rounded-md border p-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Badge variant="default">
|
||||||
|
{dryRunResult.validRows} gültig
|
||||||
|
</Badge>
|
||||||
|
{dryRunResult.errorCount > 0 && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{dryRunResult.errorCount} Fehler
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dryRunResult.errors.length > 0 && (
|
||||||
|
<div className="max-h-40 overflow-auto text-xs">
|
||||||
|
{dryRunResult.errors.map((err, i) => (
|
||||||
|
<p key={i} className="text-destructive">
|
||||||
|
Zeile {err.row}: {err.field} — {err.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('mapping')}
|
||||||
|
data-test="preview-back-btn"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isPending || mappedFields.length === 0}
|
||||||
|
data-test="import-btn"
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
{rows.length} Einträge importieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Importing */}
|
||||||
|
{step === 'importing' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||||
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
|
||||||
|
<p className="mt-4 text-lg font-semibold">Importiere Einträge...</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Bitte warten Sie, bis der Import abgeschlossen ist.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Done */}
|
||||||
|
{step === 'done' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
|
||||||
|
<h3 className="mt-4 text-xl font-semibold">Import abgeschlossen</h3>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Badge variant="default">{importedCount} importiert</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/home/${accountSlug}/modules/${moduleId}`)
|
||||||
|
}
|
||||||
|
data-test="import-done-btn"
|
||||||
|
>
|
||||||
|
Zur Modulübersicht
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
import { createModuleBuilderApi } from '@kit/module-builder/api';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
|
||||||
|
|
||||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
import { ImportWizard } from './import-wizard';
|
||||||
|
|
||||||
interface ImportPageProps {
|
interface ImportPageProps {
|
||||||
params: Promise<{ account: string; moduleId: string }>;
|
params: Promise<{ account: string; moduleId: string }>;
|
||||||
}
|
}
|
||||||
@@ -19,92 +17,30 @@ export default async function ImportPage({ params }: ImportPageProps) {
|
|||||||
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
|
||||||
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
if (!moduleWithFields) return <div>Modul nicht gefunden</div>;
|
||||||
|
|
||||||
const fields = moduleWithFields.fields ?? [];
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!acct) return <div>Account nicht gefunden</div>;
|
||||||
|
|
||||||
|
const fields = (moduleWithFields.fields ?? []).map((f) => ({
|
||||||
|
name: String(f.name),
|
||||||
|
display_name: String(f.display_name),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<CmsPageShell
|
||||||
account={account}
|
account={account}
|
||||||
title={`${String(moduleWithFields.display_name)} — Import`}
|
title={`${String(moduleWithFields.display_name)} — Import`}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<ImportWizard
|
||||||
{/* Step indicator */}
|
moduleId={moduleId}
|
||||||
<div className="flex items-center justify-center gap-2">
|
accountId={acct.id}
|
||||||
{[
|
fields={fields}
|
||||||
'Datei hochladen',
|
accountSlug={account}
|
||||||
'Spalten zuordnen',
|
|
||||||
'Vorschau',
|
|
||||||
'Importieren',
|
|
||||||
].map((step, i) => (
|
|
||||||
<div key={step} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
|
|
||||||
i === 0
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-sm ${i === 0 ? 'font-semibold' : 'text-muted-foreground'}`}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</span>
|
|
||||||
{i < 3 && (
|
|
||||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload Step */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
Datei hochladen
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12 text-center">
|
|
||||||
<Upload className="text-muted-foreground mb-4 h-10 w-10" />
|
|
||||||
<p className="text-lg font-semibold">
|
|
||||||
CSV oder Excel-Datei hierher ziehen
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
oder klicken zum Auswählen
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".csv,.xlsx,.xls"
|
|
||||||
className="text-muted-foreground file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-semibold"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="mb-2 text-sm font-semibold">
|
|
||||||
Verfügbare Zielfelder:
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{fields.map((field) => (
|
|
||||||
<span
|
|
||||||
key={field.name}
|
|
||||||
className="bg-muted rounded-md px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
{field.display_name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<Button disabled>
|
|
||||||
Weiter <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CmsPageShell>
|
</CmsPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"next-safe-action": "catalog:",
|
"next-safe-action": "catalog:",
|
||||||
"next-sitemap": "catalog:",
|
"next-sitemap": "catalog:",
|
||||||
"next-themes": "catalog:",
|
"next-themes": "catalog:",
|
||||||
|
"papaparse": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"@kit/verbandsverwaltung": "workspace:*",
|
"@kit/verbandsverwaltung": "workspace:*",
|
||||||
"@next/bundle-analyzer": "catalog:",
|
"@next/bundle-analyzer": "catalog:",
|
||||||
"@tailwindcss/postcss": "catalog:",
|
"@tailwindcss/postcss": "catalog:",
|
||||||
|
"@types/papaparse": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"babel-plugin-react-compiler": "catalog:",
|
"babel-plugin-react-compiler": "catalog:",
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { useAction } from 'next-safe-action/hooks';
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { formatDate } from '@kit/shared/dates';
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { Badge } from '@kit/ui/badge';
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
import { toast } from '@kit/ui/sonner';
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { useActionWithToast } from '@kit/ui/use-action-with-toast';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
@@ -21,12 +21,51 @@ import {
|
|||||||
computeAge,
|
computeAge,
|
||||||
computeMembershipYears,
|
computeMembershipYears,
|
||||||
} from '../lib/member-utils';
|
} 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 {
|
interface MemberDetailViewProps {
|
||||||
member: Record<string, unknown>;
|
member: Record<string, unknown>;
|
||||||
account: string;
|
account: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
roles?: MemberRole[];
|
||||||
|
honors?: MemberHonor[];
|
||||||
|
mandates?: SepaMandate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailRow({
|
function DetailRow({
|
||||||
@@ -48,6 +87,9 @@ export function MemberDetailView({
|
|||||||
member,
|
member,
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
|
roles = [],
|
||||||
|
honors = [],
|
||||||
|
mandates = [],
|
||||||
}: MemberDetailViewProps) {
|
}: MemberDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -57,8 +99,6 @@ export function MemberDetailView({
|
|||||||
const lastName = String(member.last_name ?? '');
|
const lastName = String(member.last_name ?? '');
|
||||||
const fullName = `${firstName} ${lastName}`.trim();
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
|
||||||
const form = useForm();
|
|
||||||
|
|
||||||
const { execute: executeDelete, isPending: isDeleting } = useAction(
|
const { execute: executeDelete, isPending: isDeleting } = useAction(
|
||||||
deleteMember,
|
deleteMember,
|
||||||
{
|
{
|
||||||
@@ -252,6 +292,23 @@ export function MemberDetailView({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Back */}
|
||||||
<div>
|
<div>
|
||||||
<Button variant="ghost" onClick={() => router.back()}>
|
<Button variant="ghost" onClick={() => router.back()}>
|
||||||
@@ -261,3 +318,659 @@ export function MemberDetailView({
|
|||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,3 +172,102 @@ export const lockRecord = authActionClient
|
|||||||
|
|
||||||
return { success: true, data: record };
|
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