Files
leocrm/frontend/src/components/shared/CsvImportDialog.tsx
T
leocrm-bot 700b7a71ad T07b: frontend feature pages — companies + contacts + settings + audit + dashboard + search
- 11 new feature pages (CompaniesList/Detail/Form, ContactsList/Detail/Form,
  SettingsProfile/Roles/Users, AuditLog, GlobalSearchResults)
- 3 page updates (Dashboard with StatCard+ActivityFeed, Settings with tree nav+Outlet,
  TopBar with SearchDropdown)
- 13 new routes in routes/index.tsx
- i18n updates (de.json + en.json) with companies/contacts/settings/audit/search keys
- 12 new test files + 2 existing test fixes (TopBar, AppShell)
- 7 shared components (DataGrid, Tabs, SearchDropdown, CsvImportDialog, StatCard,
  ActivityFeed, UnsavedChangesGuard)
- 16 new API hooks in hooks.ts
- Verification: 141 tests pass, build succeeds, tsc --noEmit clean
2026-06-29 11:01:39 +02:00

148 lines
5.3 KiB
TypeScript

import React, { useState, useRef, useCallback } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { useCompanyImport } from '@/api/hooks';
import { useTranslation } from 'react-i18next';
export interface CsvImportDialogProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
interface ParsedRow {
[key: string]: string;
}
function parseCSV(text: string): { headers: string[]; rows: ParsedRow[] } {
const lines = text.trim().split(/\n/);
if (lines.length === 0) return { headers: [], rows: [] };
const headers = lines[0].split(',').map((h) => h.trim());
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(',').map((v) => v.trim());
const row: ParsedRow = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
rows.push(row);
}
return { headers, rows };
}
export function CsvImportDialog({ open, onClose, onSuccess }: CsvImportDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const importMutation = useCompanyImport();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<{ headers: string[]; rows: ParsedRow[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
setError('Bitte wählen Sie eine CSV-Datei aus.');
return;
}
setError(null);
setSelectedFile(file);
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
const parsed = parseCSV(text);
setPreviewData(parsed);
};
reader.readAsText(file);
}, []);
const handleImport = async () => {
if (!selectedFile) return;
try {
await importMutation.mutateAsync(selectedFile);
toast.success('Import erfolgreich abgeschlossen.');
setSelectedFile(null);
setPreviewData(null);
setError(null);
onSuccess?.();
onClose();
} catch (err: any) {
toast.error(err.message || 'Import fehlgeschlagen.');
}
};
const handleClose = () => {
setSelectedFile(null);
setPreviewData(null);
setError(null);
onClose();
};
return (
<Modal open={open} onClose={handleClose} title="CSV Import" size="lg" >
<div className="space-y-4" data-testid="csv-import-dialog">
<div>
<p className="text-sm text-secondary-600 mb-3">
Wählen Sie eine CSV-Datei mit Firmendaten. Erforderliche Spalte: name.
Optionale Spalten: account_number, industry, phone, email, website, description.
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="block w-full text-sm text-secondary-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 min-h-touch"
aria-label="CSV-Datei auswählen"
data-testid="csv-file-input"
/>
{error && <p className="mt-2 text-sm text-danger-600" role="alert">{error}</p>}
</div>
{previewData && previewData.rows.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-secondary-900 mb-2">Vorschau ({previewData.rows.length} Datensätze)</h4>
<div className="overflow-x-auto border border-secondary-200 rounded-md max-h-60">
<table className="min-w-full text-sm">
<thead className="bg-secondary-50 sticky top-0">
<tr>
{previewData.headers.map((header) => (
<th key={header} className="px-3 py-2 text-left font-semibold text-secondary-600">{header}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{previewData.rows.slice(0, 10).map((row, idx) => (
<tr key={idx}>
{previewData.headers.map((header) => (
<td key={header} className="px-3 py-2 text-secondary-900">{row[header]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{previewData.rows.length > 10 && (
<p className="text-xs text-secondary-500 mt-1">Zeige 10 von {previewData.rows.length} Datensätzen.</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={handleClose}>{t('common.cancel')}</Button>
<Button
onClick={handleImport}
disabled={!selectedFile || importMutation.isPending}
isLoading={importMutation.isPending}
data-testid="csv-import-button"
>
{t('common.save')}
</Button>
</div>
</div>
</Modal>
);
}