From 9f3ee13094817bb286de39194fe1de62475c4d0b Mon Sep 17 00:00:00 2001
From: DeeCee03
Date: Fri, 5 Jun 2026 18:17:50 +0200
Subject: [PATCH] =?UTF-8?q?feat(leads):=20CSV-Import=20f=C3=BCr=20Leads?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"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)
---
apps/api/src/lib/csv.test.ts | 54 +++++++++++++
apps/api/src/lib/csv.ts | 54 +++++++++++++
apps/api/src/routers/lead.ts | 114 ++++++++++++++++++++++++++++
apps/web/src/routes/leads.tsx | 102 ++++++++++++++++++++++++-
packages/shared/src/schemas/lead.ts | 6 ++
5 files changed, 329 insertions(+), 1 deletion(-)
create mode 100644 apps/api/src/lib/csv.test.ts
create mode 100644 apps/api/src/lib/csv.ts
diff --git a/apps/api/src/lib/csv.test.ts b/apps/api/src/lib/csv.test.ts
new file mode 100644
index 0000000..d14f688
--- /dev/null
+++ b/apps/api/src/lib/csv.test.ts
@@ -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');
+ });
+});
diff --git a/apps/api/src/lib/csv.ts b/apps/api/src/lib/csv.ts
new file mode 100644
index 0000000..5c0adf2
--- /dev/null
+++ b/apps/api/src/lib/csv.ts
@@ -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, '');
+}
diff --git a/apps/api/src/routers/lead.ts b/apps/api/src/routers/lead.ts
index 36cd3c5..b652c76 100644
--- a/apps/api/src/routers/lead.ts
+++ b/apps/api/src/routers/lead.ts
@@ -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;
+ }),
});
diff --git a/apps/web/src/routes/leads.tsx b/apps/web/src/routes/leads.tsx
index 1a91798..5900a1f 100644
--- a/apps/web/src/routes/leads.tsx
+++ b/apps/web/src/routes/leads.tsx
@@ -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 ;
const leads = q.data ?? [];
@@ -35,7 +36,12 @@ export function LeadsRoute() {
Qualitative Lead-Dossiers — Stammdaten, Pain, Strategie, Austausch-Log.
- setOpen(true)}>{voice.cta.newLead}
+
+ setImportOpen(true)}>
+ CSV importieren
+
+ setOpen(true)}>{voice.cta.newLead}
+
{leads.length === 0 ? (
@@ -69,6 +75,100 @@ export function LeadsRoute() {
)}
{open && setOpen(false)} />}
+ {importOpen && setImportOpen(false)} />}
+
+ );
+}
+
+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) {
+ 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 (
+
+
e.stopPropagation()}
+ >
+
Leads aus CSV importieren
+
+ 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.
+
+
+
+
+ CSV-Datei
+
+
+ {fileName && !result && (
+
+ Bereit: {fileName} ({csv.split('\n').filter((l) => l.trim()).length - 1} Datenzeilen)
+
+ )}
+
+ {result && (
+
+
+ {result.created} Leads importiert
+
+
+ {result.contacts} Kontakte angelegt
+ {result.touchpoints} Notizen als Log-Eintrag
+ {result.skippedDuplicate > 0 && {result.skippedDuplicate} Duplikate übersprungen }
+ {result.skippedEmpty > 0 && {result.skippedEmpty} leere Zeilen übersprungen }
+
+
+ )}
+
+
+
+ {result ? 'Schließen' : 'Abbrechen'}
+
+ {!result && (
+ importMut.mutate({ csv })}
+ disabled={!csv || importMut.isPending}
+ >
+ {importMut.isPending ? 'Importiere…' : 'Importieren'}
+
+ )}
+
+
+
);
}
diff --git a/packages/shared/src/schemas/lead.ts b/packages/shared/src/schemas/lead.ts
index b4743e6..136c868 100644
--- a/packages/shared/src/schemas/lead.ts
+++ b/packages/shared/src/schemas/lead.ts
@@ -135,3 +135,9 @@ export const promoteToDealInput = z.object({
probability: z.number().int().min(0).max(100).default(50),
});
export type PromoteToDealInput = z.infer;
+
+// 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;