feat: add invitations management and import wizard; enhance audit logging and member detail fetching
This commit is contained in:
@@ -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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user