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

+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { parseCsv, normalizeHeader } from './csv.js';
describe('parseCsv', () => {
it('parst einfache Zeilen', () => {
expect(parseCsv('a,b,c\n1,2,3')).toEqual([
['a', 'b', 'c'],
['1', '2', '3'],
]);
});
it('ignoriert einen abschließenden Zeilenumbruch', () => {
expect(parseCsv('a,b\n1,2\n')).toEqual([
['a', 'b'],
['1', '2'],
]);
});
it('behandelt \\r\\n', () => {
expect(parseCsv('a,b\r\n1,2')).toEqual([
['a', 'b'],
['1', '2'],
]);
});
it('respektiert Anführungszeichen mit Komma und Quote-Escape', () => {
expect(parseCsv('a,b\n"x, y","sagt ""hi"""')).toEqual([
['a', 'b'],
['x, y', 'sagt "hi"'],
]);
});
it('erlaubt Zeilenumbruch innerhalb von Quotes', () => {
expect(parseCsv('a,b\n"erste\nzweite",2')).toEqual([
['a', 'b'],
['erste\nzweite', '2'],
]);
});
it('behält leere Felder', () => {
expect(parseCsv('a,b,c\n1,,3')).toEqual([
['a', 'b', 'c'],
['1', '', '3'],
]);
});
});
describe('normalizeHeader', () => {
it('normalisiert Umlaute und Sonderzeichen', () => {
expect(normalizeHeader('E-Mail')).toBe('email');
expect(normalizeHeader('Straße')).toBe('strasse');
expect(normalizeHeader(' Quelle ')).toBe('quelle');
});
});
+54
View File
@@ -0,0 +1,54 @@
// Minimaler, robuster CSV-Parser (RFC-4180-nah): unterstützt Anführungszeichen,
// eingebettete Kommata/Zeilenumbrüche und doppelte Quotes ("") als Escape.
export function parseCsv(input: string): string[][] {
const text = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += c;
}
} else if (c === '"') {
inQuotes = true;
} else if (c === ',') {
row.push(field);
field = '';
} else if (c === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else {
field += c;
}
}
// Letztes Feld / letzte Zeile (falls Datei nicht mit \n endet).
if (field !== '' || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
// Header-Zelle auf einen vergleichbaren Schlüssel normalisieren
// ("E-Mail" → "email", "Straße" → "strasse").
export function normalizeHeader(h: string): string {
return h
.trim()
.toLowerCase()
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]/g, '');
}
+114
View File
@@ -4,6 +4,7 @@ import { lead, leadContact, leadTouchpoint, deal, area, user } from '@cockpit/db
import type { Db } from '@cockpit/db';
import { LeadSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import { parseCsv, normalizeHeader } from '../lib/csv.js';
const SALES_AREA_SLUG = 'vertrieb';
@@ -189,4 +190,117 @@ export const leadRouter = router({
});
return result;
}),
// CSV-Import: erzeugt je Zeile einen Lead + primären Kontakt; Adresse →
// standorte, Notizen → erster Austausch-Log-Eintrag. Dedup über Firmenname.
importCsv: protectedProcedure
.input(LeadSchemas.importLeadsCsvInput)
.mutation(async ({ ctx, input }) => {
const areaId = await getSalesAreaId(ctx.db);
const rows = parseCsv(input.csv);
const summary = { created: 0, skippedEmpty: 0, skippedDuplicate: 0, contacts: 0, touchpoints: 0 };
if (rows.length < 2 || !rows[0]) return summary;
const header = rows[0].map(normalizeHeader);
const col = (key: string) => header.indexOf(key);
const cells = {
quelle: col('quelle'),
vorname: col('vorname'),
nachname: col('nachname'),
position: col('position'),
firma: col('firma'),
strasse: col('strasse'),
plz: col('plz'),
ort: col('ort'),
telefon: col('telefon'),
mobil: col('mobil'),
email: col('email'),
website: col('website'),
branche: col('branche'),
notizen: col('notizen'),
};
const get = (row: string[], i: number) => (i >= 0 && i < row.length ? (row[i] ?? '').trim() : '');
const existing = await ctx.db.select({ firma: lead.firma }).from(lead);
const seen = new Set(existing.map((e) => e.firma.trim().toLowerCase()));
const today = new Date().toISOString().slice(0, 10);
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
if (!row || row.every((c) => c.trim() === '')) continue; // Leerzeile
const vorname = get(row, cells.vorname);
const nachname = get(row, cells.nachname);
const personName = `${vorname} ${nachname}`.trim();
const firma = get(row, cells.firma) || personName;
if (!firma) {
summary.skippedEmpty++;
continue;
}
const key = firma.toLowerCase();
if (seen.has(key)) {
summary.skippedDuplicate++;
continue;
}
seen.add(key);
const standorte = [
get(row, cells.strasse),
[get(row, cells.plz), get(row, cells.ort)].filter(Boolean).join(' '),
]
.filter(Boolean)
.join(', ');
await ctx.db.transaction(async (tx) => {
const [createdLead] = await tx
.insert(lead)
.values({
areaId,
firma,
status: 'cold',
score: 0,
branche: get(row, cells.branche) || null,
website: get(row, cells.website) || null,
quelle: get(row, cells.quelle) || null,
standorte: standorte || null,
createdBy: ctx.user.id,
})
.returning();
if (!createdLead) throw new Error('Lead konnte nicht angelegt werden.');
summary.created++;
const email = get(row, cells.email);
const telefon = get(row, cells.telefon);
const mobil = get(row, cells.mobil);
const position = get(row, cells.position);
if (personName || email || telefon || mobil) {
const tel = [telefon, mobil ? `Mobil: ${mobil}` : ''].filter(Boolean).join(' · ');
await tx.insert(leadContact).values({
leadId: createdLead.id,
name: personName || firma,
rolle: position || null,
email: email || null,
telefon: tel || null,
isPrimary: true,
order: 0,
});
summary.contacts++;
}
const notizen = get(row, cells.notizen);
if (notizen) {
await tx.insert(leadTouchpoint).values({
leadId: createdLead.id,
datum: today,
kanal: 'sonstiges',
bearbeiterUserId: ctx.user.id,
inhalt: notizen.slice(0, 2000),
});
summary.touchpoints++;
}
});
}
return summary;
}),
});
+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>
);
}
+6
View File
@@ -135,3 +135,9 @@ export const promoteToDealInput = z.object({
probability: z.number().int().min(0).max(100).default(50),
});
export type PromoteToDealInput = z.infer<typeof promoteToDealInput>;
// CSV-Import: rohe CSV-Datei wird serverseitig geparst (header-basiert).
export const importLeadsCsvInput = z.object({
csv: z.string().min(1).max(2_000_000),
});
export type ImportLeadsCsvInput = z.infer<typeof importLeadsCsvInput>;