feat(vertrieb): Lead-CRM mit Dossiers, Kontakten & Austausch-Log
Neues Lead-Modul im Vertriebsbereich: qualitative Dossiers (Stammdaten, Pain & Bedarf, Soft Factors, Strategie), Kontaktpersonen, Austausch-Log mit Follow-up-Daten und Lead→Deal-Promotion. Routen /leads (Liste, nach Status gruppiert) und /lead/:id (Dossier), Teaser im Bereich Vertrieb. Migration 0008_lead_crm (lead, lead_contact, lead_touchpoint). Datenmodell-Tabellen liegen in der gemeinsamen schema.ts (Folge-Commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8933169ba9
commit
d72aa9fbf3
9 files changed
+4291
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asc, desc, eq, sql } from 'drizzle-orm';
|
||||
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';
|
||||
|
||||
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,
|
||||
})
|
||||
.from(lead)
|
||||
.leftJoin(user, eq(user.id, lead.ownerUserId))
|
||||
.orderBy(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));
|
||||
return {
|
||||
lead: l,
|
||||
contacts,
|
||||
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,
|
||||
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;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user