Ein Projekt lebt in genau einem Heimat-Bereich und wird einmal angelegt; Leads bekommen eine optionale Verknüpfung dorthin (lead.projectId), egal in welchem Bereich das Projekt wohnt — kein redundantes Anlegen mehr. - Migration 0011: lead.project_id (FK → project, ON DELETE SET NULL). - Lead anlegen / CSV-Import / Lead-Detail: Projekt zuordnen; CSV ordnet alle importierten Leads einem Projekt zu. - Projektseite: neue "Leads"-Sektion (Vertriebs-Sicht des Projekts). - Vertrieb: "Projekte mit Vertriebsaktivität" — bereichsübergreifend alle Projekte mit verknüpften Leads (so erscheint ein Entwicklungsprojekt hier, WEIL es Leads hat). Rein interne Projekte ohne Leads tauchen nicht auf. - Lead-Liste/Detail zeigen das verknüpfte Projekt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { asc, desc, eq, sql } from 'drizzle-orm';
|
|
import { lead, leadContact, leadTouchpoint, deal, area, user, project } 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';
|
|
|
|
async function getSalesAreaId(db: Db): Promise<string> {
|
|
const a = (await db.select().from(area).where(eq(area.slug, SALES_AREA_SLUG)).limit(1))[0];
|
|
if (!a) throw new Error('Vertriebs-Bereich fehlt — bitte seed laufen lassen.');
|
|
return a.id;
|
|
}
|
|
|
|
export const leadRouter = router({
|
|
// Liste aller Leads, mit Owner-Namen. Sortiert: zuletzt aktualisiert zuerst.
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
const rows = await ctx.db
|
|
.select({
|
|
lead: lead,
|
|
ownerName: user.name,
|
|
projectName: project.name,
|
|
})
|
|
.from(lead)
|
|
.leftJoin(user, eq(user.id, lead.ownerUserId))
|
|
.leftJoin(project, eq(project.id, lead.projectId))
|
|
.orderBy(desc(lead.updatedAt));
|
|
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName, projectName: r.projectName }));
|
|
}),
|
|
|
|
// Leads, die einem Projekt zugeordnet sind (Vertriebs-Sicht im Projekt).
|
|
forProject: protectedProcedure
|
|
.input(LeadSchemas.leadsForProjectInput)
|
|
.query(async ({ ctx, input }) => {
|
|
const rows = await ctx.db
|
|
.select({ lead: lead, ownerName: user.name })
|
|
.from(lead)
|
|
.leftJoin(user, eq(user.id, lead.ownerUserId))
|
|
.where(eq(lead.projectId, input.projectId))
|
|
.orderBy(desc(lead.score), desc(lead.updatedAt));
|
|
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName }));
|
|
}),
|
|
|
|
byId: protectedProcedure.input(LeadSchemas.leadIdInput).query(async ({ ctx, input }) => {
|
|
const l = (await ctx.db.select().from(lead).where(eq(lead.id, input.id)).limit(1))[0];
|
|
if (!l) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' });
|
|
const contacts = await ctx.db
|
|
.select()
|
|
.from(leadContact)
|
|
.where(eq(leadContact.leadId, l.id))
|
|
.orderBy(desc(leadContact.isPrimary), asc(leadContact.order));
|
|
const touchpoints = await ctx.db
|
|
.select({
|
|
touchpoint: leadTouchpoint,
|
|
bearbeiterName: user.name,
|
|
})
|
|
.from(leadTouchpoint)
|
|
.leftJoin(user, eq(user.id, leadTouchpoint.bearbeiterUserId))
|
|
.where(eq(leadTouchpoint.leadId, l.id))
|
|
.orderBy(desc(leadTouchpoint.datum), desc(leadTouchpoint.createdAt));
|
|
const linkedProject = l.projectId
|
|
? (await ctx.db
|
|
.select({ id: project.id, name: project.name })
|
|
.from(project)
|
|
.where(eq(project.id, l.projectId))
|
|
.limit(1))[0] ?? null
|
|
: null;
|
|
return {
|
|
lead: l,
|
|
contacts,
|
|
project: linkedProject,
|
|
touchpoints: touchpoints.map((t) => ({ ...t.touchpoint, bearbeiterName: t.bearbeiterName })),
|
|
};
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(LeadSchemas.createLeadInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const areaId = await getSalesAreaId(ctx.db);
|
|
const [created] = await ctx.db
|
|
.insert(lead)
|
|
.values({
|
|
areaId,
|
|
firma: input.firma,
|
|
status: input.status,
|
|
score: input.score,
|
|
ownerUserId: input.ownerUserId ?? null,
|
|
projectId: input.projectId ?? null,
|
|
branche: input.branche ?? null,
|
|
subSegment: input.subSegment ?? null,
|
|
website: input.website ?? null,
|
|
quelle: input.quelle ?? null,
|
|
createdBy: ctx.user.id,
|
|
})
|
|
.returning();
|
|
if (!created) throw new Error('Lead konnte nicht angelegt werden.');
|
|
return created;
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(LeadSchemas.updateLeadInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, ...patch } = input;
|
|
if (Object.keys(patch).length === 0) {
|
|
const existing = (await ctx.db.select().from(lead).where(eq(lead.id, id)).limit(1))[0];
|
|
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' });
|
|
return existing;
|
|
}
|
|
const [updated] = await ctx.db
|
|
.update(lead)
|
|
.set({ ...patch, updatedAt: new Date() })
|
|
.where(eq(lead.id, id))
|
|
.returning();
|
|
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' });
|
|
return updated;
|
|
}),
|
|
|
|
// Kontaktpersonen -----------------------------------------------------------
|
|
addContact: protectedProcedure
|
|
.input(LeadSchemas.addContactInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const count = (
|
|
await ctx.db
|
|
.select({ c: sql<number>`count(*)::int` })
|
|
.from(leadContact)
|
|
.where(eq(leadContact.leadId, input.leadId))
|
|
)[0]?.c ?? 0;
|
|
const [created] = await ctx.db
|
|
.insert(leadContact)
|
|
.values({
|
|
leadId: input.leadId,
|
|
name: input.name,
|
|
rolle: input.rolle ?? null,
|
|
funktion: input.funktion ?? null,
|
|
email: input.email ?? null,
|
|
telefon: input.telefon ?? null,
|
|
linkedin: input.linkedin ?? null,
|
|
imBuyingCenterSeit: input.imBuyingCenterSeit ?? null,
|
|
sprache: input.sprache ?? null,
|
|
isPrimary: input.isPrimary,
|
|
order: count,
|
|
})
|
|
.returning();
|
|
return created;
|
|
}),
|
|
|
|
removeContact: protectedProcedure
|
|
.input(LeadSchemas.removeContactInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ctx.db.delete(leadContact).where(eq(leadContact.id, input.contactId));
|
|
return { ok: true as const };
|
|
}),
|
|
|
|
// Austausch-Log -------------------------------------------------------------
|
|
addTouchpoint: protectedProcedure
|
|
.input(LeadSchemas.addTouchpointInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const [created] = await ctx.db
|
|
.insert(leadTouchpoint)
|
|
.values({
|
|
leadId: input.leadId,
|
|
datum: input.datum,
|
|
kanal: input.kanal,
|
|
bearbeiterUserId: ctx.user.id,
|
|
inhalt: input.inhalt,
|
|
outcome: input.outcome ?? null,
|
|
followUpBis: input.followUpBis ?? null,
|
|
})
|
|
.returning();
|
|
// Touchpoint zählt als Aktivität → Lead als aktualisiert markieren.
|
|
await ctx.db.update(lead).set({ updatedAt: new Date() }).where(eq(lead.id, input.leadId));
|
|
return created;
|
|
}),
|
|
|
|
removeTouchpoint: protectedProcedure
|
|
.input(LeadSchemas.removeTouchpointInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ctx.db.delete(leadTouchpoint).where(eq(leadTouchpoint.id, input.touchpointId));
|
|
return { ok: true as const };
|
|
}),
|
|
|
|
// Lead → Pipeline-Deal promoten --------------------------------------------
|
|
promoteToDeal: protectedProcedure
|
|
.input(LeadSchemas.promoteToDealInput)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const l = (await ctx.db.select().from(lead).where(eq(lead.id, input.leadId)).limit(1))[0];
|
|
if (!l) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' });
|
|
if (l.promotedDealId) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Dieser Lead wurde bereits zu einem Deal gemacht.',
|
|
});
|
|
}
|
|
const result = await ctx.db.transaction(async (tx) => {
|
|
const [createdDeal] = await tx
|
|
.insert(deal)
|
|
.values({
|
|
areaId: l.areaId,
|
|
name: l.firma,
|
|
ownerUserId: l.ownerUserId,
|
|
valueEur: input.valueEur,
|
|
probability: input.probability,
|
|
stage: 'lead',
|
|
})
|
|
.returning();
|
|
if (!createdDeal) throw new Error('Deal konnte nicht angelegt werden.');
|
|
await tx
|
|
.update(lead)
|
|
.set({ promotedDealId: createdDeal.id, updatedAt: new Date() })
|
|
.where(eq(lead.id, l.id));
|
|
return createdDeal;
|
|
});
|
|
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,
|
|
projectId: input.projectId ?? null,
|
|
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;
|
|
}),
|
|
});
|