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
+ 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. +
+ ++ Bereit: {fileName} ({csv.split('\n').filter((l) => l.trim()).length - 1} Datenzeilen) +
+ )} + + {result && ( ++ {result.created} Leads importiert +
+