feat(leads): CSV-Import für Leads
"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:
1 parent
864a1cef6a
commit
9f3ee13094
5 files changed
+329
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user