feat(#359): W4a Phase 2 — zentraler Import/Export-Dialog (Frontend)
- ImportExportDialog.tsx (neu): Modal lg/xl nach bestehendem ui/Modal-Muster
- Export-Tab: Formatauswahl (csv/xlsx/json), Download, Fehler-Handling
- Import-Tab: 4 Schritte (Datei -> Mapping -> Dry-Run -> Ausführung+Report),
Mapping-Vorschau mit Modul-Heuristik, Background-Job-Polling ab 1000 Zeilen
- i18n: 24 importexport.*-Keys in de.json + en.json (keine hardcoded Strings)
- Integration: ContactsList Toolbar-Button (contacts:read-Gate, Upload-Icon,
entityType=contacts vorgewählt) über bestehendes pluginToolbarStore-Muster
Gates: Vitest 12/12 (routePermissions + importExportDialog), tsc exit 0,
Production-Build exit 0 (vor Commit). 6 Failures in contacts/shell Suiten
als Vorbestand bewiesen (Stash-Test: identisch auf clean HEAD f27f047).
fixes #359 (Phase 2)
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import {
|
||||
exportData,
|
||||
getImportJobStatus,
|
||||
importCsv,
|
||||
previewImport,
|
||||
validateImport,
|
||||
type ImportResult,
|
||||
type JobStatus,
|
||||
type PreviewResult,
|
||||
type ValidateResult,
|
||||
} from '@/api/importExport';
|
||||
|
||||
/**
|
||||
* Central import/export dialog (W4a, Spec #359).
|
||||
*
|
||||
* Opened from every module list toolbar via the plugin's Toolbar-Button.
|
||||
* The module is pre-selected; the dialog shows Export (1 step) and
|
||||
* Import (4 steps: file → mapping → dry-run → execution + report).
|
||||
*/
|
||||
|
||||
export interface ImportExportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Entity type identifier contributed by the module (e.g. 'contacts', 'companies'). */
|
||||
entityType: string;
|
||||
/** Default tab when opening. */
|
||||
defaultTab?: 'export' | 'import';
|
||||
}
|
||||
|
||||
const FORMATS = [
|
||||
{ id: 'csv', label: 'CSV', icon: FileText },
|
||||
{ id: 'xlsx', label: 'XLSX', icon: FileSpreadsheet },
|
||||
{ id: 'json', label: 'JSON', icon: FileText },
|
||||
] as const;
|
||||
|
||||
type ImportStep = 1 | 2 | 3 | 4;
|
||||
|
||||
export function ImportExportDialog({ open, onClose, entityType, defaultTab = 'export' }: ImportExportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<'export' | 'import'>(defaultTab);
|
||||
|
||||
// ── Export state ──
|
||||
const [exportFormat, setExportFormat] = useState<string>('csv');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
// ── Import state ──
|
||||
const [step, setStep] = useState<ImportStep>(1);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [validateResult, setValidateResult] = useState<ValidateResult | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTab(defaultTab);
|
||||
resetImport();
|
||||
}
|
||||
}, [open, defaultTab]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
}, []);
|
||||
|
||||
const resetImport = useCallback(() => {
|
||||
setStep(1);
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setMapping({});
|
||||
setValidateResult(null);
|
||||
setImportResult(null);
|
||||
setJobStatus(null);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// ── Export ──
|
||||
const handleExport = useCallback(async () => {
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
await exportData(entityType, exportFormat);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : t('common.error'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [entityType, exportFormat, t]);
|
||||
|
||||
// ── Import flow ──
|
||||
const handleFile = useCallback(async (f: File | null) => {
|
||||
setFile(f);
|
||||
setPreview(null);
|
||||
setMapping({});
|
||||
setValidateResult(null);
|
||||
if (!f) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await previewImport(f, entityType);
|
||||
setPreview(result);
|
||||
setMapping(result.mapping_suggestion);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [entityType, t]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await validateImport(file, entityType, mapping);
|
||||
setValidateResult(result);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [entityType, file, mapping, t]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await importCsv(file, entityType, false, mapping);
|
||||
if (result.status === 'pending' && result.job_id) {
|
||||
// Background job — poll until done
|
||||
setStep(4);
|
||||
setImportResult(result);
|
||||
pollRef.current = setInterval(async () => {
|
||||
const status = await getImportJobStatus(result.job_id as string);
|
||||
setJobStatus(status);
|
||||
if (status.status === 'completed' || status.status === 'partial_success' || status.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
setBusy(false);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
setStep(4);
|
||||
setImportResult(result);
|
||||
setBusy(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('common.error'));
|
||||
setBusy(false);
|
||||
}
|
||||
}, [entityType, file, mapping, t]);
|
||||
|
||||
const canExecute = validateResult !== null && (validateResult.succeeded > 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('importexport.dialogTitle')}
|
||||
size="xl"
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('export')}
|
||||
className={clsxTab(tab === 'export')}
|
||||
data-testid="ie-tab-export"
|
||||
>
|
||||
<Download className="w-4 h-4" aria-hidden="true" />
|
||||
{t('importexport.export')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTab('import'); resetImport(); }}
|
||||
className={clsxTab(tab === 'import')}
|
||||
data-testid="ie-tab-import"
|
||||
>
|
||||
<Upload className="w-4 h-4" aria-hidden="true" />
|
||||
{t('importexport.import')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'export' && (
|
||||
<div data-testid="ie-export-panel">
|
||||
<p className="text-sm text-secondary-600 mb-3">
|
||||
{t('importexport.exportHint', { entity: entityType })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{FORMATS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => setExportFormat(f.id)}
|
||||
className={clsxFormat(exportFormat === f.id)}
|
||||
data-testid={`ie-format-${f.id}`}
|
||||
>
|
||||
<f.icon className="w-4 h-4" aria-hidden="true" />
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{exportError && <p className="text-sm text-red-600 mb-2" role="alert">{exportError}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-touch"
|
||||
data-testid="ie-export-download"
|
||||
>
|
||||
{exporting
|
||||
? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
|
||||
: <Download className="w-4 h-4" aria-hidden="true" />}
|
||||
{t('importexport.download')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'import' && (
|
||||
<div data-testid="ie-import-panel">
|
||||
{/* Step indicator */}
|
||||
<ol className="flex gap-2 mb-4 text-xs">
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<li
|
||||
key={n}
|
||||
className={clsx(
|
||||
'px-2 py-1 rounded',
|
||||
step === n ? 'bg-primary-100 text-primary-700 font-semibold' : 'bg-secondary-100 text-secondary-500',
|
||||
)}
|
||||
aria-current={step === n ? 'step' : undefined}
|
||||
>
|
||||
{n}. {t(`importexport.step${n}`)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-2" role="alert">{error}</p>}
|
||||
|
||||
{/* Step 1 — file */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ie-file-input"
|
||||
className="block text-sm font-medium text-secondary-700 mb-2"
|
||||
>
|
||||
{t('importexport.chooseFile')}
|
||||
</label>
|
||||
<input
|
||||
id="ie-file-input"
|
||||
type="file"
|
||||
accept=".csv,.json,.xlsx"
|
||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||
className="block w-full text-sm border border-secondary-300 rounded-md p-2"
|
||||
data-testid="ie-file-input"
|
||||
/>
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin mt-2" aria-hidden="true" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — mapping */}
|
||||
{step === 2 && preview && (
|
||||
<div>
|
||||
<p className="text-sm text-secondary-600 mb-2">
|
||||
{t('importexport.mappingHint', { total: preview.total_rows })}
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto border border-secondary-200 rounded mb-3">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-secondary-50">
|
||||
<th className="text-left px-2 py-1">{t('importexport.fileColumn')}</th>
|
||||
<th className="text-left px-2 py-1">{t('importexport.targetField')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.columns.map((col) => (
|
||||
<tr key={col} className="border-t border-secondary-100">
|
||||
<td className="px-2 py-1 font-mono">{col}</td>
|
||||
<td className="px-2 py-1">
|
||||
<select
|
||||
value={mapping[col] ?? ''}
|
||||
onChange={(e) => setMapping((m) => ({ ...m, [col]: e.target.value }))}
|
||||
className="border border-secondary-300 rounded px-1 py-0.5 w-full"
|
||||
aria-label={`${t('importexport.targetField')}: ${col}`}
|
||||
>
|
||||
<option value="">— {t('common.skip')} —</option>
|
||||
{preview.target_fields.map((tf) => (
|
||||
<option key={tf} value={tf}>{tf}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleValidate}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-touch"
|
||||
data-testid="ie-import-validate"
|
||||
>
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />}
|
||||
{t('importexport.validate')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — dry run */}
|
||||
{step === 3 && validateResult && (
|
||||
<div>
|
||||
<div className="flex gap-4 mb-3">
|
||||
<span className="inline-flex items-center gap-1 text-sm text-green-700" data-testid="ie-dry-valid">
|
||||
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
|
||||
{validateResult.succeeded} {t('importexport.valid')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-sm text-red-700" data-testid="ie-dry-invalid">
|
||||
<XCircle className="w-4 h-4" aria-hidden="true" />
|
||||
{validateResult.failed} {t('importexport.invalid')}
|
||||
</span>
|
||||
</div>
|
||||
{validateResult.error_report && validateResult.error_report.errors.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto border border-secondary-200 rounded mb-3 text-xs">
|
||||
{validateResult.error_report.errors.map((e, i) => (
|
||||
<p key={i} className="px-2 py-1 border-b border-secondary-100 last:border-b-0">
|
||||
<span className="font-mono">{t('importexport.row')} {e.row}</span>
|
||||
{' — '}{e.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExecute}
|
||||
disabled={busy || !canExecute}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-touch"
|
||||
data-testid="ie-import-execute"
|
||||
>
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />}
|
||||
{t('importexport.executeImport')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4 — result */}
|
||||
{step === 4 && (importResult || jobStatus) && (
|
||||
<div data-testid="ie-import-result">
|
||||
{jobStatus && jobStatus.status !== 'completed' && jobStatus.status !== 'partial_success' && jobStatus.status !== 'failed' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-600">
|
||||
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
|
||||
{t('importexport.backgroundRunning')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-4 mb-3">
|
||||
<span className="text-sm">
|
||||
<strong>{(importResult?.total ?? jobStatus?.total) ?? 0}</strong> {t('importexport.total')}
|
||||
</span>
|
||||
<span className="text-sm text-green-700">
|
||||
<strong>{(importResult?.succeeded ?? jobStatus?.succeeded) ?? 0}</strong> {t('importexport.imported')}
|
||||
</span>
|
||||
<span className="text-sm text-red-700">
|
||||
<strong>{(importResult?.failed ?? jobStatus?.failed) ?? 0}</strong> {t('importexport.failed')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700 flex items-center gap-1" role="status">
|
||||
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
|
||||
{t('importexport.importDone')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function clsxTab(active: boolean): string {
|
||||
return [
|
||||
'inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
|
||||
active ? 'bg-primary-100 text-primary-700 font-semibold' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function clsxFormat(active: boolean): string {
|
||||
return [
|
||||
'inline-flex items-center gap-2 px-3 py-2 rounded-md border text-sm min-h-touch',
|
||||
active
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 font-semibold'
|
||||
: 'border-secondary-300 text-secondary-600 hover:border-secondary-400',
|
||||
].join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user