feat: add invitations management and import wizard; enhance audit logging and member detail fetching
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-01 19:02:55 +02:00
parent 080ec1cb47
commit db4e19c3af
10 changed files with 1847 additions and 104 deletions

View File

@@ -1,19 +1,214 @@
export default async function AdminAuditPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold">Protokoll</h1>
<p className="text-muted-foreground">
Mandantenübergreifendes Änderungsprotokoll
</p>
</div>
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';
<div className="rounded-lg border p-6">
<p className="text-muted-foreground text-sm">
Alle Datenänderungen (Erstellen, Ändern, Löschen, Sperren) über alle
Mandanten hinweg. Filtert nach Zeitraum, Benutzer, Tabelle und Aktion.
</p>
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 (
<PageBody>
<PageHeader
title="Protokoll"
description="Mandantenübergreifendes Änderungsprotokoll"
/>
<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>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
Seite {page} von {totalPages} ({result.total} Einträge)
</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>
</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);

View File

@@ -23,12 +23,26 @@ export default async function MemberDetailPage({ params }: Props) {
const member = await api.getMember(memberId);
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 (
<CmsPageShell
account={account}
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>
);
}

View File

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

View File

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

View File

@@ -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">&#8594;</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. &quot;{rows[0][mapping[field.name]!]}&quot;
</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>
);
}

View File

@@ -1,12 +1,10 @@
import { Upload, ArrowRight, CheckCircle } from 'lucide-react';
import { createModuleBuilderApi } from '@kit/module-builder/api';
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 { ImportWizard } from './import-wizard';
interface ImportPageProps {
params: Promise<{ account: string; moduleId: string }>;
}
@@ -19,92 +17,30 @@ export default async function ImportPage({ params }: ImportPageProps) {
const moduleWithFields = await api.modules.getModuleWithFields(moduleId);
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 (
<CmsPageShell
account={account}
title={`${String(moduleWithFields.display_name)} — Import`}
>
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{[
'Datei hochladen',
'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>
<ImportWizard
moduleId={moduleId}
accountId={acct.id}
fields={fields}
accountSlug={account}
/>
</CmsPageShell>
);
}

View File

@@ -73,6 +73,7 @@
"next-safe-action": "catalog:",
"next-sitemap": "catalog:",
"next-themes": "catalog:",
"papaparse": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
@@ -91,6 +92,7 @@
"@kit/verbandsverwaltung": "workspace:*",
"@next/bundle-analyzer": "catalog:",
"@tailwindcss/postcss": "catalog:",
"@types/papaparse": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"babel-plugin-react-compiler": "catalog:",