Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
'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 { Badge } from '@kit/ui/badge';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { Upload, ArrowRight, ArrowLeft, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { createMember } from '../server/actions/member-actions';
|
||||
|
||||
@@ -46,45 +54,56 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
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 [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;
|
||||
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())
|
||||
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())),
|
||||
);
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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 () => {
|
||||
@@ -111,7 +130,9 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
await executeCreate(memberData as any);
|
||||
success++;
|
||||
} catch (err) {
|
||||
errors.push(`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`);
|
||||
errors.push(
|
||||
`Zeile ${i + 2}: ${err instanceof Error ? err.message : 'Unbekannter Fehler'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,15 +152,35 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
{/* 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;
|
||||
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="h-4 w-4 text-muted-foreground" />}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -148,14 +189,25 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
{/* Step 1: Upload */}
|
||||
{step === 'upload' && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" />Datei hochladen</CardTitle></CardHeader>
|
||||
<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="mb-4 h-10 w-10 text-muted-foreground" />
|
||||
<Upload className="text-muted-foreground mb-4 h-10 w-10" />
|
||||
<p className="text-lg font-semibold">CSV-Datei auswählen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Semikolon-getrennt (;), UTF-8</p>
|
||||
<input type="file" accept=".csv" onChange={handleFileUpload}
|
||||
className="mt-4 block w-full max-w-xs text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground" />
|
||||
<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>
|
||||
@@ -164,33 +216,54 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
{/* Step 2: Column mapping */}
|
||||
{step === 'mapping' && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Spalten zuordnen</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle>Spalten zuordnen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{rawData.length} Zeilen erkannt. Ordnen Sie die CSV-Spalten den Mitgliedsfeldern zu.</p>
|
||||
<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 => (
|
||||
{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="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="flex h-9 w-64 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
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>
|
||||
<option key={i} value={String(i)}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{mapping[field.key] !== undefined && rawData[0] && (
|
||||
<span className="text-xs text-muted-foreground">z.B. "{rawData[0][Number(mapping[field.key])]}"</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
z.B. "{rawData[0][Number(mapping[field.key])]}"
|
||||
</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>
|
||||
<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>
|
||||
@@ -199,26 +272,46 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
{/* Step 3: Preview + execute */}
|
||||
{step === 'preview' && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle>Vorschau ({rawData.length} Einträge)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-auto rounded-md border max-h-96">
|
||||
<div className="max-h-96 overflow-auto rounded-md border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th 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>
|
||||
{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');
|
||||
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="inline h-3 w-3 text-destructive" />}</td>
|
||||
{MEMBER_FIELDS.filter(f => mapping[f.key] !== undefined).map(f => (
|
||||
<td key={f.key} className="p-2 max-w-32 truncate">{getMappedValue(i, f.key) || '—'}</td>
|
||||
<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>
|
||||
);
|
||||
@@ -226,9 +319,16 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rawData.length > 20 && <p className="mt-2 text-xs text-muted-foreground">... und {rawData.length - 20} weitere Einträge</p>}
|
||||
{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 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
|
||||
@@ -242,9 +342,13 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
{step === 'importing' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="mt-4 text-lg font-semibold">Importiere Mitglieder...</p>
|
||||
<p className="text-sm text-muted-foreground">Bitte warten Sie, bis der Import abgeschlossen ist.</p>
|
||||
<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>
|
||||
)}
|
||||
@@ -256,20 +360,28 @@ export function MemberImportWizard({ accountId, account }: Props) {
|
||||
<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>
|
||||
<Badge variant="default">
|
||||
{importResults.success} erfolgreich
|
||||
</Badge>
|
||||
{importResults.errors.length > 0 && (
|
||||
<Badge variant="destructive">{importResults.errors.length} Fehler</Badge>
|
||||
<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>
|
||||
<p key={i} className="text-destructive">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<Button onClick={() => router.push(`/home/${account}/members-cms`)}>
|
||||
<Button
|
||||
onClick={() => router.push(`/home/${account}/members-cms`)}
|
||||
>
|
||||
Zur Mitgliederliste
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user