feat(leads): CSV-Import für Leads
ci / quality (push) Failing after 1m11s
ci / build (push) Has been skipped

"CSV importieren"-Dialog auf /leads. Serverseitiger lead.importCsv:
eigener CSV-Parser (Quotes/Kommata/Zeilenumbrüche, mit Tests),
header-basiertes Mapping (umlaut-tolerant). Pro Zeile entsteht ein Lead
(Adresse → standorte) + primärer Kontakt; Notizen werden als erster
Austausch-Log-Eintrag gespeichert. Dedup über Firmenname.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-05 18:17:50 +02:00
1 parent 864a1cef6a
commit 9f3ee13094
5 files changed
+329 -1

No files matched your search

+101 -1
View File
@@ -16,6 +16,7 @@ import {
export function LeadsRoute() {
const q = trpc.lead.list.useQuery();
const [open, setOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
if (q.isPending) return <SkeletonCard lines={6} />;
const leads = q.data ?? [];
@@ -35,7 +36,12 @@ export function LeadsRoute() {
Qualitative Lead-Dossiers — Stammdaten, Pain, Strategie, Austausch-Log.
</p>
</div>
<Button onClick={() => setOpen(true)}>{voice.cta.newLead}</Button>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setImportOpen(true)}>
CSV importieren
</Button>
<Button onClick={() => setOpen(true)}>{voice.cta.newLead}</Button>
</div>
</header>
{leads.length === 0 ? (
@@ -69,6 +75,100 @@ export function LeadsRoute() {
)}
{open && <LeadCreateDialog onClose={() => setOpen(false)} />}
{importOpen && <LeadImportDialog onClose={() => setImportOpen(false)} />}
</div>
);
}
function LeadImportDialog({ onClose }: { onClose: () => void }) {
const [fileName, setFileName] = useState('');
const [csv, setCsv] = useState('');
const [result, setResult] = useState<{
created: number;
skippedEmpty: number;
skippedDuplicate: number;
contacts: number;
touchpoints: number;
} | null>(null);
const utils = trpc.useUtils();
const importMut = trpc.lead.importCsv.useMutation({
onSuccess: async (res) => {
setResult(res);
await utils.lead.list.invalidate();
toast(`${res.created} Leads importiert.`);
},
});
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
setResult(null);
const reader = new FileReader();
reader.onload = () => setCsv(typeof reader.result === 'string' ? reader.result : '');
reader.readAsText(file);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Leads aus CSV importieren</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Spalten: Quelle, Vorname, Nachname, Position, Firma, Straße, PLZ, Ort, Telefon, Mobil,
Fax, E-Mail, Website, Branche, Notizen. Je Zeile entsteht ein Lead mit primärem Kontakt;
Notizen werden als erster Austausch-Log-Eintrag gespeichert. Duplikate (gleicher
Firmenname) werden übersprungen.
</p>
<div className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">CSV-Datei</span>
<input
type="file"
accept=".csv,text/csv"
onChange={onFile}
className="mt-1 block w-full text-sm file:mr-3 file:rounded-[var(--radius-md)] file:border file:border-[var(--color-border)] file:bg-white file:px-3 file:py-1.5 file:text-sm"
/>
</label>
{fileName && !result && (
<p className="text-xs text-[var(--color-ink-muted)]">
Bereit: {fileName} ({csv.split('\n').filter((l) => l.trim()).length - 1} Datenzeilen)
</p>
)}
{result && (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-sm">
<p className="font-medium text-[var(--color-ampel-green-fg)]">
{result.created} Leads importiert
</p>
<ul className="mt-1 space-y-0.5 text-xs text-[var(--color-ink-muted)]">
<li>{result.contacts} Kontakte angelegt</li>
<li>{result.touchpoints} Notizen als Log-Eintrag</li>
{result.skippedDuplicate > 0 && <li>{result.skippedDuplicate} Duplikate übersprungen</li>}
{result.skippedEmpty > 0 && <li>{result.skippedEmpty} leere Zeilen übersprungen</li>}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
{result ? 'Schließen' : 'Abbrechen'}
</Button>
{!result && (
<Button
type="button"
onClick={() => importMut.mutate({ csv })}
disabled={!csv || importMut.isPending}
>
{importMut.isPending ? 'Importiere…' : 'Importieren'}
</Button>
)}
</div>
</div>
</div>
</div>
);
}