diff --git a/apps/api/src/routers/lead.ts b/apps/api/src/routers/lead.ts new file mode 100644 index 0000000..36cd3c5 --- /dev/null +++ b/apps/api/src/routers/lead.ts @@ -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 { + 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`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; + }), +}); diff --git a/apps/web/src/lib/lead-meta.ts b/apps/web/src/lib/lead-meta.ts new file mode 100644 index 0000000..9a62eeb --- /dev/null +++ b/apps/web/src/lib/lead-meta.ts @@ -0,0 +1,54 @@ +// Anzeige-Metadaten für Leads — zentral, von /leads und /lead/:id genutzt. + +export type LeadStatus = 'cold' | 'warm' | 'hot' | 'won' | 'lost'; + +export const LEAD_STATUS_META: Record< + LeadStatus, + { label: string; badge: string; dot: string } +> = { + cold: { + label: 'Cold', + badge: 'bg-black/5 text-[var(--color-ink-muted)]', + dot: 'bg-[var(--color-border-strong)]', + }, + warm: { + label: 'Warm', + badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]', + dot: 'bg-[var(--color-ampel-amber-fg)]', + }, + hot: { + label: 'Hot', + badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]', + dot: 'bg-[var(--color-ampel-red-fg)]', + }, + won: { + label: 'Won', + badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]', + dot: 'bg-[var(--color-ampel-green-fg)]', + }, + lost: { + label: 'Lost', + badge: 'bg-black/5 text-[var(--color-ink-faint)]', + dot: 'bg-[var(--color-ink-faint)]', + }, +}; + +// Reihenfolge für Gruppierung/Sortierung in der Liste. +export const LEAD_STATUS_ORDER: LeadStatus[] = ['hot', 'warm', 'cold', 'won', 'lost']; + +export const CONTACT_FUNKTION_LABEL: Record = { + entscheider: 'Entscheider', + influencer: 'Influencer', + gatekeeper: 'Gatekeeper', + end_user: 'End-User', + champion: 'Champion', +}; + +export const TOUCHPOINT_KANAL_LABEL: Record = { + call: 'Call', + mail: 'Mail', + linkedin: 'LinkedIn', + termin: 'Termin', + event: 'Event', + sonstiges: 'Sonstiges', +}; diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 351f695..7f2778e 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -12,6 +12,8 @@ const Reflexion = lazy(() => ); const Bereich = lazy(() => import('../routes/bereich').then((m) => ({ default: m.BereichRoute }))); const Projekt = lazy(() => import('../routes/projekt').then((m) => ({ default: m.ProjektRoute }))); +const Leads = lazy(() => import('../routes/leads').then((m) => ({ default: m.LeadsRoute }))); +const Lead = lazy(() => import('../routes/lead').then((m) => ({ default: m.LeadRoute }))); const Archiv = lazy(() => import('../routes/archiv').then((m) => ({ default: m.ArchivRoute }))); const NotFound = lazy(() => import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })), @@ -40,6 +42,8 @@ const router = createBrowserRouter([ { path: 'bereich', element: withSuspense() }, { path: 'bereich/:slug', element: withSuspense() }, { path: 'projekt/:id', element: withSuspense() }, + { path: 'leads', element: withSuspense() }, + { path: 'lead/:id', element: withSuspense() }, { path: 'archiv', element: withSuspense() }, { path: '*', element: withSuspense() }, ], diff --git a/apps/web/src/lib/voice.ts b/apps/web/src/lib/voice.ts index fe53dc7..5efdab1 100644 --- a/apps/web/src/lib/voice.ts +++ b/apps/web/src/lib/voice.ts @@ -11,6 +11,8 @@ export const voice = { blockerResolved: 'Blocker erledigt.', projectCreated: 'Projekt angelegt.', projectArchived: 'Projekt archiviert.', + leadCreated: 'Lead angelegt.', + leadPromoted: 'Lead ist jetzt ein Deal.', saved: 'Gespeichert.', strong: 'Stark.', }, @@ -19,6 +21,7 @@ export const voice = { reflect: 'Reflektieren', newBlocker: 'Neuer Blocker', newProject: 'Neues Projekt', + newLead: 'Neuer Lead', }, hint: { notCheckedInToday: 'Du hast heute noch nicht gepingt.', diff --git a/apps/web/src/routes/lead.tsx b/apps/web/src/routes/lead.tsx new file mode 100644 index 0000000..c896618 --- /dev/null +++ b/apps/web/src/routes/lead.tsx @@ -0,0 +1,990 @@ +import { useState } from 'react'; +import { useParams, Link, useNavigate } from 'react-router'; +import { ArrowLeft, Pencil, Plus, Trash2, Star } from 'lucide-react'; +import { clsx } from 'clsx'; +import { toast } from 'sonner'; +import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; +import type { AppRouter } from '@cockpit/api/router'; +import { trpc } from '../lib/trpc'; +import { voice } from '../lib/voice'; +import { Card } from '../components/ui/Card'; +import { Button } from '../components/ui/Button'; +import { SkeletonCard } from '../components/ui/SkeletonCard'; +import { + LEAD_STATUS_META, + LEAD_STATUS_ORDER, + CONTACT_FUNKTION_LABEL, + TOUCHPOINT_KANAL_LABEL, + type LeadStatus, +} from '../lib/lead-meta'; + +type LeadByIdOutput = inferRouterOutputs['lead']['byId']; +type LeadDossier = LeadByIdOutput['lead']; +type Contact = LeadByIdOutput['contacts'][number]; +type Touchpoint = LeadByIdOutput['touchpoints'][number]; +type LeadUpdateInput = inferRouterInputs['lead']['update']; + +// Nur nullable Text-/Datums-Felder — werden über die generische EditableSection bearbeitet. +type LeadTextKey = + | 'branche' + | 'subSegment' + | 'standorte' + | 'mitarbeiterzahl' + | 'umsatz' + | 'website' + | 'quelle' + | 'painPoint' + | 'statusQuoKosten' + | 'bestehendeLoesung' + | 'wechselgrund' + | 'persoenlicherErfolg' + | 'budgetIndikation' + | 'entscheidungshorizont' + | 'compellingEvent' + | 'mitbewerber' + | 'buyingCenter' + | 'beschaffungsweg' + | 'kommunikationsstil' + | 'vorlieben' + | 'hotButtons' + | 'noGos' + | 'druckpunkte' + | 'anker' + | 'nutzenHypothese' + | 'differenzierer' + | 'naechsterSchritt' + | 'naechsterSchrittDatum' + | 'risiken' + | 'planB'; + +const inputCls = + 'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm'; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +export function LeadRoute() { + const { id = '' } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const q = trpc.lead.byId.useQuery({ id }); + + if (q.isPending) return ; + if (!q.data) return null; + const { lead, contacts, touchpoints } = q.data; + + function goBack() { + if (window.history.length > 1) navigate(-1); + else navigate('/leads'); + } + + return ( +
+
+ + + + + + + + + + } + fields={[ + { key: 'kommunikationsstil', label: 'Kommunikationsstil' }, + { key: 'vorlieben', label: 'Vorlieben & Interessen', multiline: true }, + { key: 'hotButtons', label: 'Hot Buttons (positiv)', multiline: true }, + { key: 'noGos', label: 'No-Gos / heikle Themen', multiline: true }, + { key: 'druckpunkte', label: 'Schwächen / Druckpunkte', multiline: true }, + { key: 'anker', label: 'Persönliche Anker / Anekdoten', multiline: true }, + ]} + /> + + + + + + + + +
+ ); +} + +// --- Header mit Inline-Schnellbearbeitung ----------------------------------- +function Header({ lead, onBack }: { lead: LeadDossier; onBack: () => void }) { + const utils = trpc.useUtils(); + const [editFirma, setEditFirma] = useState(false); + const [firmaDraft, setFirmaDraft] = useState(lead.firma); + const [promoteOpen, setPromoteOpen] = useState(false); + + const update = trpc.lead.update.useMutation({ + onSuccess: async () => { + await utils.lead.byId.invalidate({ id: lead.id }); + await utils.lead.list.invalidate(); + }, + }); + + const users = trpc.user.list.useQuery(); + const meta = LEAD_STATUS_META[lead.status as LeadStatus]; + + return ( +
+
+ +

+ + Vertrieb + + {' › '} + + Leads + +

+
+ +
+ {editFirma ? ( +
{ + e.preventDefault(); + const v = firmaDraft.trim(); + if (v) update.mutate({ id: lead.id, firma: v }); + setEditFirma(false); + }} + > + setFirmaDraft(e.target.value)} + className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-1 text-2xl font-bold" + /> + +
+ ) : ( +

+ {lead.firma} + +

+ )} + +
+ {lead.promotedDealId ? ( + + In Pipeline als Deal + + ) : ( + + )} +
+
+ +
+ + + + + +
+ + {promoteOpen && setPromoteOpen(false)} />} +
+ ); +} + +function PromoteDialog({ leadId, onClose }: { leadId: string; onClose: () => void }) { + const [euro, setEuro] = useState(0); + const [probability, setProbability] = useState(50); + const utils = trpc.useUtils(); + const promote = trpc.lead.promoteToDeal.useMutation({ + onSuccess: async () => { + toast(voice.toast.leadPromoted); + await utils.lead.byId.invalidate({ id: leadId }); + await utils.lead.list.invalidate(); + onClose(); + }, + }); + return ( +
+
e.stopPropagation()} + > +

Zu Deal machen

+

+ Legt einen Pipeline-Deal in Stufe „Lead“ an und verknüpft ihn. +

+
{ + e.preventDefault(); + promote.mutate({ leadId, valueEur: Math.round(euro * 100), probability }); + }} + > + + +
+ + +
+
+
+
+ ); +} + +// --- Generische editierbare Text-Sektion ------------------------------------ +function EditableSection({ + leadId, + lead, + title, + note, + extra, + fields, +}: { + leadId: string; + lead: LeadDossier; + title: string; + note?: string; + extra?: React.ReactNode; + fields: Array<{ key: LeadTextKey; label: string; multiline?: boolean; date?: boolean }>; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState>({}); + const utils = trpc.useUtils(); + const update = trpc.lead.update.useMutation({ + onSuccess: async () => { + toast(voice.toast.saved); + await utils.lead.byId.invalidate({ id: leadId }); + await utils.lead.list.invalidate(); + setEditing(false); + }, + }); + + function startEdit() { + const d: Record = {}; + for (const f of fields) d[f.key] = lead[f.key] ?? ''; + setDraft(d); + setEditing(true); + } + + function save() { + const patch: Partial = { id: leadId }; + for (const f of fields) { + const v = (draft[f.key] ?? '').trim(); + patch[f.key] = v === '' ? null : v; + } + update.mutate(patch as LeadUpdateInput); + } + + return ( + + +

{title}

+ {!editing && ( + + )} +
+ + {note &&

{note}

} + {extra} + {editing ? ( +
+ {fields.map((f) => ( +