Files
myeasycms-v2/packages/features/member-management/src/components/member-import-wizard.tsx
Zaid Marzguioui b26e5aaafa
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m20s
Workflow / ⚫️ Test (push) Has been skipped
feat: pre-existing local changes — fischerei, verband, modules, members, packages
Commits all remaining uncommitted local work:

- apps/web: fischerei, verband, modules, members-cms, documents,
  newsletter, meetings, site-builder, courses, bookings, events,
  finance pages and components
- apps/web: marketing page updates, layout, paths config,
  next.config.mjs, styles/makerkit.css
- apps/web/i18n: documents, fischerei, marketing, verband (de+en)
- packages/features: finance, fischerei, member-management,
  module-builder, newsletter, sitzungsprotokolle, verbandsverwaltung
  server APIs and components
- packages/ui: button.tsx updates
- pnpm-lock.yaml
2026-04-02 01:19:54 +02:00

396 lines
14 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Upload,
ArrowRight,
ArrowLeft,
CheckCircle,
AlertTriangle,
} from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import Papa from 'papaparse';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { createMember } from '../server/actions/member-actions';
const MEMBER_FIELDS = [
{ key: 'memberNumber', label: 'Mitgliedsnr.' },
{ key: 'salutation', label: 'Anrede' },
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{ key: 'dateOfBirth', label: 'Geburtsdatum' },
{ key: 'email', label: 'E-Mail' },
{ key: 'phone', label: 'Telefon' },
{ key: 'mobile', label: 'Mobil' },
{ key: 'street', label: 'Straße' },
{ key: 'houseNumber', label: 'Hausnummer' },
{ key: 'postalCode', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'entryDate', label: 'Eintrittsdatum' },
{ key: 'iban', label: 'IBAN' },
{ key: 'bic', label: 'BIC' },
{ key: 'accountHolder', label: 'Kontoinhaber' },
{ key: 'notes', label: 'Notizen' },
] as const;
interface Props {
accountId: string;
account: string;
}
type Step = 'upload' | 'mapping' | 'preview' | 'importing' | 'done';
export function MemberImportWizard({ accountId, account }: Props) {
const router = useRouter();
const [step, setStep] = useState<Step>('upload');
const [rawData, setRawData] = useState<string[][]>([]);
const [headers, setHeaders] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [importResults, setImportResults] = useState<{
success: number;
errors: string[];
}>({ success: 0, errors: [] });
const { execute: executeCreate } = useAction(createMember);
// Step 1: Parse file
const handleFileUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
Papa.parse(file, {
delimiter: ';',
encoding: 'UTF-8',
complete: (result) => {
const data = result.data as string[][];
if (data.length < 2) {
toast.error('Datei enthält keine Daten');
return;
}
setHeaders(data[0]!);
setRawData(
data.slice(1).filter((row) => row.some((cell) => cell?.trim())),
);
// Auto-map by header name similarity
const autoMap: Record<string, string> = {};
for (const field of MEMBER_FIELDS) {
const match = data[0]!.findIndex(
(h) =>
h
.toLowerCase()
.includes(field.label.toLowerCase().replace('.', '')) ||
h.toLowerCase().includes(field.key.toLowerCase()),
);
if (match >= 0) autoMap[field.key] = String(match);
}
setMapping(autoMap);
setStep('mapping');
toast.success(`${data.length - 1} Zeilen erkannt`);
},
error: (err) => {
toast.error(`Fehler beim Lesen: ${err.message}`);
},
});
},
[],
);
// Step 3: Execute import
const executeImport = useCallback(async () => {
setStep('importing');
let success = 0;
const errors: string[] = [];
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i]!;
try {
const memberData: Record<string, string> = { accountId };
for (const field of MEMBER_FIELDS) {
const colIdx = mapping[field.key];
if (colIdx !== undefined && row[Number(colIdx)]) {
memberData[field.key] = row[Number(colIdx)]!.trim();
}
}
if (!memberData.firstName || !memberData.lastName) {
errors.push(`Zeile ${i + 2}: Vor-/Nachname fehlt`);
continue;
}
await executeCreate(memberData as any);
success++;
} catch (err) {
errors.push(
`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`,
);
}
}
setImportResults({ success, errors });
setStep('done');
toast.success(`${success} Mitglieder importiert`);
}, [rawData, mapping, accountId, executeCreate]);
const getMappedValue = (rowIdx: number, fieldKey: string): string => {
const colIdx = mapping[fieldKey];
if (colIdx === undefined) return '';
return rawData[rowIdx]?.[Number(colIdx)]?.trim() ?? '';
};
return (
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{(['upload', 'mapping', 'preview', 'done'] as const).map((s, i) => {
const labels = [
'Datei hochladen',
'Spalten zuordnen',
'Vorschau & Import',
'Fertig',
];
const isActive =
['upload', 'mapping', 'preview', 'importing', 'done'].indexOf(
step,
) >= i;
return (
<div key={s} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
isActive
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
<span
className={`text-sm ${isActive ? 'font-semibold' : 'text-muted-foreground'}`}
>
{labels[i]}
</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">
Semikolon-getrennt (;), UTF-8
</p>
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
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>
</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">
{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den
Mitgliedsfeldern zu.
</p>
<div className="space-y-2">
{MEMBER_FIELDS.map((field) => (
<div key={field.key} className="flex items-center gap-4">
<span className="w-40 text-sm font-medium">
{field.label}
</span>
<span className="text-muted-foreground"></span>
<select
value={mapping[field.key] ?? ''}
onChange={(e) =>
setMapping((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
className="border-input bg-background flex h-9 w-64 rounded-md border px-3 py-1 text-sm"
>
<option value=""> Nicht zuordnen </option>
{headers.map((h, i) => (
<option key={i} value={String(i)}>
{h}
</option>
))}
</select>
{mapping[field.key] !== undefined && rawData[0] && (
<span className="text-muted-foreground text-xs">
z.B. &quot;{rawData[0][Number(mapping[field.key])]}&quot;
</span>
)}
</div>
))}
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('upload')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<Button onClick={() => setStep('preview')}>
Vorschau <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview + execute */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-96 overflow-auto rounded-md border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-2 text-left">
#
</th>
{MEMBER_FIELDS.filter(
(f) => mapping[f.key] !== undefined,
).map((f) => (
<th key={f.key} className="p-2 text-left">
{f.label}
</th>
))}
</tr>
</thead>
<tbody>
{rawData.slice(0, 20).map((_, i) => {
const hasName =
getMappedValue(i, 'firstName') &&
getMappedValue(i, 'lastName');
return (
<tr
key={i}
className={`border-b ${hasName ? '' : 'bg-red-50 dark:bg-red-950'}`}
>
<td className="p-2">
{i + 1}{' '}
{!hasName && (
<AlertTriangle className="text-destructive inline h-3 w-3" />
)}
</td>
{MEMBER_FIELDS.filter(
(f) => mapping[f.key] !== undefined,
).map((f) => (
<td key={f.key} className="max-w-32 truncate p-2">
{getMappedValue(i, f.key) || '—'}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{rawData.length > 20 && (
<p className="text-muted-foreground mt-2 text-xs">
... und {rawData.length - 20} weitere Einträge
</p>
)}
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={() => setStep('mapping')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Zurück
</Button>
<Button onClick={executeImport}>
<CheckCircle className="mr-2 h-4 w-4" />
{rawData.length} Mitglieder importieren
</Button>
</div>
</CardContent>
</Card>
)}
{/* 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 Mitglieder...
</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 flex justify-center gap-4">
<Badge variant="default">
{importResults.success} erfolgreich
</Badge>
{importResults.errors.length > 0 && (
<Badge variant="destructive">
{importResults.errors.length} Fehler
</Badge>
)}
</div>
{importResults.errors.length > 0 && (
<div className="mt-4 max-h-40 overflow-auto rounded-md border p-3 text-left text-xs">
{importResults.errors.map((err, i) => (
<p key={i} className="text-destructive">
{err}
</p>
))}
</div>
)}
<div className="mt-6">
<Button
onClick={() => router.push(`/home/${account}/members-cms`)}
>
Zur Mitgliederliste
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}