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

+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;
}),
});