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.

- +
+ + +
{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. +

+ +
+ + {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 && ( + + )} +
+
+
); } 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;