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:
dejanandClaude Opus 4.8 committed 2026-06-05 17:31:14 +02:00
1 parent 8933169ba9
commit d72aa9fbf3
9 files changed
+4291

No files matched your search

+192
View File
@@ -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;
}),
});
+54
View File
@@ -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<string, string> = {
entscheider: 'Entscheider',
influencer: 'Influencer',
gatekeeper: 'Gatekeeper',
end_user: 'End-User',
champion: 'Champion',
};
export const TOUCHPOINT_KANAL_LABEL: Record<string, string> = {
call: 'Call',
mail: 'Mail',
linkedin: 'LinkedIn',
termin: 'Termin',
event: 'Event',
sonstiges: 'Sonstiges',
};
+4
View File
@@ -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(<Bereich />) },
{ path: 'bereich/:slug', element: withSuspense(<Bereich />) },
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
{ path: 'leads', element: withSuspense(<Leads />) },
{ path: 'lead/:id', element: withSuspense(<Lead />) },
{ path: 'archiv', element: withSuspense(<Archiv />) },
{ path: '*', element: withSuspense(<NotFound />) },
],
+3
View File
@@ -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.',
+990
View File
@@ -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<AppRouter>['lead']['byId'];
type LeadDossier = LeadByIdOutput['lead'];
type Contact = LeadByIdOutput['contacts'][number];
type Touchpoint = LeadByIdOutput['touchpoints'][number];
type LeadUpdateInput = inferRouterInputs<AppRouter>['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 <SkeletonCard lines={8} />;
if (!q.data) return null;
const { lead, contacts, touchpoints } = q.data;
function goBack() {
if (window.history.length > 1) navigate(-1);
else navigate('/leads');
}
return (
<div className="space-y-6">
<Header lead={lead} onBack={goBack} />
<EditableSection
leadId={lead.id}
lead={lead}
title="Stammdaten"
fields={[
{ key: 'branche', label: 'Branche' },
{ key: 'subSegment', label: 'Nische / Sub-Segment' },
{ key: 'standorte', label: 'Standort(e)' },
{ key: 'mitarbeiterzahl', label: 'Mitarbeiterzahl' },
{ key: 'umsatz', label: 'Umsatz (geschätzt)' },
{ key: 'website', label: 'Website' },
{ key: 'quelle', label: 'Quelle (wie gefunden)' },
]}
/>
<ContactsSection leadId={lead.id} contacts={contacts} />
<EditableSection
leadId={lead.id}
lead={lead}
title="Pain & Bedarf"
fields={[
{ key: 'painPoint', label: 'Akuter Pain-Point (in seinen Worten)', multiline: true },
{ key: 'statusQuoKosten', label: 'Was kostet der Status quo?', multiline: true },
{ key: 'bestehendeLoesung', label: 'Bestehende Lösung im Einsatz' },
{ key: 'wechselgrund', label: 'Warum unzufrieden / wechselbereit?', multiline: true },
{
key: 'persoenlicherErfolg',
label: 'Was wäre für die Person persönlich ein Erfolg?',
multiline: true,
},
]}
/>
<EditableSection
leadId={lead.id}
lead={lead}
title="Geschäftliches"
fields={[
{ key: 'budgetIndikation', label: 'Budget-Indikation' },
{ key: 'entscheidungshorizont', label: 'Entscheidungshorizont' },
{ key: 'compellingEvent', label: 'Auslöser / Compelling Event', multiline: true },
{ key: 'mitbewerber', label: 'Mitbewerber im Rennen' },
{ key: 'buyingCenter', label: 'Buying-Center (alle Beteiligten)', multiline: true },
{ key: 'beschaffungsweg', label: 'Beschaffungsweg' },
]}
/>
<EditableSection
leadId={lead.id}
lead={lead}
title="Soft Factors"
note="🔒 Intern. Beobachtungen, keine Bewertungen — nicht an den Lead weitergeben."
extra={<VertrauenslevelControl leadId={lead.id} value={lead.vertrauenslevel} />}
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 },
]}
/>
<EditableSection
leadId={lead.id}
lead={lead}
title="Strategie"
fields={[
{ key: 'nutzenHypothese', label: 'Nutzen-Hypothese für DIESEN Lead', multiline: true },
{ key: 'differenzierer', label: 'Unser Differenzierer hier', multiline: true },
{ key: 'naechsterSchritt', label: 'Nächster konkreter Schritt' },
{ key: 'naechsterSchrittDatum', label: 'Nächster Schritt bis', date: true },
{ key: 'risiken', label: 'Risiken / Blocker', multiline: true },
{ key: 'planB', label: 'Plan B falls Hauptkontakt wegfällt', multiline: true },
]}
/>
<TouchpointsSection leadId={lead.id} touchpoints={touchpoints} />
<OffeneFragenSection leadId={lead.id} fragen={lead.offeneFragen} />
<AnhaengeSection leadId={lead.id} anhaenge={lead.anhaenge} />
</div>
);
}
// --- 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 (
<header>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
aria-label="Zurück"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
>
<ArrowLeft size={16} />
</button>
<p className="text-sm text-[var(--color-ink-muted)]">
<Link to="/bereich/vertrieb" className="hover:underline">
Vertrieb
</Link>
{' › '}
<Link to="/leads" className="hover:underline">
Leads
</Link>
</p>
</div>
<div className="mt-2 flex flex-wrap items-start justify-between gap-4">
{editFirma ? (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const v = firmaDraft.trim();
if (v) update.mutate({ id: lead.id, firma: v });
setEditFirma(false);
}}
>
<input
value={firmaDraft}
onChange={(e) => setFirmaDraft(e.target.value)}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-1 text-2xl font-bold"
/>
<Button type="submit" size="sm">
OK
</Button>
</form>
) : (
<h1 className="flex items-center gap-2 text-3xl font-bold">
{lead.firma}
<button
type="button"
onClick={() => {
setFirmaDraft(lead.firma);
setEditFirma(true);
}}
aria-label="Firmenname bearbeiten"
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
>
<Pencil size={16} />
</button>
</h1>
)}
<div className="flex flex-wrap items-center gap-2">
{lead.promotedDealId ? (
<span className="rounded-full bg-[var(--color-ampel-green-bg)] px-2.5 py-1 text-xs text-[var(--color-ampel-green-fg)]">
In Pipeline als Deal
</span>
) : (
<Button variant="secondary" size="sm" onClick={() => setPromoteOpen(true)}>
Zu Deal machen
</Button>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-ink-muted)]">Status</span>
<span className={clsx('rounded-full px-2 py-0.5 text-xs', meta.badge)}>{meta.label}</span>
<select
value={lead.status}
onChange={(e) => update.mutate({ id: lead.id, status: e.target.value as LeadStatus })}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
{LEAD_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{LEAD_STATUS_META[s].label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-ink-muted)]">Score</span>
<select
value={lead.score}
onChange={(e) => update.mutate({ id: lead.id, score: Number(e.target.value) })}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
{Array.from({ length: 11 }, (_, i) => i).map((n) => (
<option key={n} value={n}>
{n}/10
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-ink-muted)]">Owner</span>
<select
value={lead.ownerUserId ?? ''}
onChange={(e) =>
update.mutate({ id: lead.id, ownerUserId: e.target.value || null })
}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
<option value="">— niemand —</option>
{(users.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
</div>
{promoteOpen && <PromoteDialog leadId={lead.id} onClose={() => setPromoteOpen(false)} />}
</header>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="w-full max-w-sm rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Zu Deal machen</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Legt einen Pipeline-Deal in Stufe „Lead“ an und verknüpft ihn.
</p>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
promote.mutate({ leadId, valueEur: Math.round(euro * 100), probability });
}}
>
<label className="block">
<span className="text-sm font-medium">Wert (EUR)</span>
<input
type="number"
min={0}
value={euro}
onChange={(e) => setEuro(Number(e.target.value))}
className={clsx(inputCls, 'mt-1')}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Wahrscheinlichkeit: {probability}%</span>
<input
type="range"
min={0}
max={100}
value={probability}
onChange={(e) => setProbability(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={promote.isPending}>
Anlegen
</Button>
</div>
</form>
</div>
</div>
);
}
// --- 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<Record<string, string>>({});
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<string, string> = {};
for (const f of fields) d[f.key] = lead[f.key] ?? '';
setDraft(d);
setEditing(true);
}
function save() {
const patch: Partial<LeadUpdateInput> = { 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 (
<Card.Root>
<Card.Header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
{!editing && (
<button
type="button"
onClick={startEdit}
className="inline-flex items-center gap-1 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
>
<Pencil size={13} /> Bearbeiten
</button>
)}
</Card.Header>
<Card.Body className="space-y-3">
{note && <p className="text-xs text-[var(--color-ink-muted)]">{note}</p>}
{extra}
{editing ? (
<div className="space-y-3">
{fields.map((f) => (
<label key={f.key} className="block">
<span className="text-sm font-medium">{f.label}</span>
{f.multiline ? (
<textarea
rows={3}
value={draft[f.key] ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, [f.key]: e.target.value }))}
className={clsx(inputCls, 'mt-1')}
/>
) : (
<input
type={f.date ? 'date' : 'text'}
value={draft[f.key] ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, [f.key]: e.target.value }))}
className={clsx(inputCls, 'mt-1')}
/>
)}
</label>
))}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
Abbrechen
</Button>
<Button type="button" size="sm" onClick={save} disabled={update.isPending}>
Speichern
</Button>
</div>
</div>
) : (
<dl className="space-y-2 text-sm">
{fields.map((f) => (
<div key={f.key} className="grid grid-cols-3 gap-3">
<dt className="text-[var(--color-ink-muted)]">{f.label}</dt>
<dd className="col-span-2 whitespace-pre-wrap">
{lead[f.key] ? (
f.key === 'website' ? (
<ExternalLink url={lead[f.key] as string} />
) : (
lead[f.key]
)
) : (
<span className="text-[var(--color-ink-faint)]">—</span>
)}
</dd>
</div>
))}
</dl>
)}
</Card.Body>
</Card.Root>
);
}
function VertrauenslevelControl({
leadId,
value,
}: {
leadId: string;
value: number | null;
}) {
const utils = trpc.useUtils();
const update = trpc.lead.update.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
},
});
return (
<label className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-ink-muted)]">Vertrauenslevel zu uns</span>
<select
value={value ?? ''}
onChange={(e) =>
update.mutate({ id: leadId, vertrauenslevel: e.target.value ? Number(e.target.value) : null })
}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
<option value="">—</option>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>
{n}/5
</option>
))}
</select>
</label>
);
}
// --- Kontaktpersonen ---------------------------------------------------------
function ContactsSection({ leadId, contacts }: { leadId: string; contacts: Contact[] }) {
const [adding, setAdding] = useState(false);
return (
<Card.Root>
<Card.Header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Kontaktpersonen</h2>
{!adding && (
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex items-center gap-1 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
>
<Plus size={13} /> Kontakt
</button>
)}
</Card.Header>
<Card.Body className="space-y-3">
{contacts.length === 0 && !adding && (
<p className="text-sm text-[var(--color-ink-muted)]">Noch keine Kontakte.</p>
)}
{contacts.map((c) => (
<ContactRow key={c.id} leadId={leadId} contact={c} />
))}
{adding && <AddContactForm leadId={leadId} onDone={() => setAdding(false)} />}
</Card.Body>
</Card.Root>
);
}
function ContactRow({ leadId, contact }: { leadId: string; contact: Contact }) {
const utils = trpc.useUtils();
const remove = trpc.lead.removeContact.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
},
});
const details = [
contact.email,
contact.telefon,
contact.sprache,
contact.imBuyingCenterSeit ? `seit ${contact.imBuyingCenterSeit}` : null,
].filter(Boolean);
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="flex items-center gap-2 font-medium">
{contact.isPrimary && (
<Star size={13} className="text-[var(--color-ampel-amber-fg)]" fill="currentColor" />
)}
{contact.name}
{contact.rolle && (
<span className="text-sm font-normal text-[var(--color-ink-muted)]">
· {contact.rolle}
</span>
)}
</p>
{contact.funktion && (
<span className="mt-1 inline-block rounded-full bg-black/5 px-2 py-0.5 text-xs text-[var(--color-ink-muted)]">
{CONTACT_FUNKTION_LABEL[contact.funktion] ?? contact.funktion}
</span>
)}
{details.length > 0 && (
<p className="mt-1 text-xs text-[var(--color-ink-muted)]">{details.join(' · ')}</p>
)}
{contact.linkedin && (
<p className="mt-1 text-xs">
<ExternalLink url={contact.linkedin} label="LinkedIn" />
</p>
)}
</div>
<button
type="button"
onClick={() => remove.mutate({ contactId: contact.id })}
aria-label="Kontakt entfernen"
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
>
<Trash2 size={15} />
</button>
</div>
</div>
);
}
function AddContactForm({ leadId, onDone }: { leadId: string; onDone: () => void }) {
const utils = trpc.useUtils();
const [name, setName] = useState('');
const [rolle, setRolle] = useState('');
const [funktion, setFunktion] = useState('');
const [email, setEmail] = useState('');
const [telefon, setTelefon] = useState('');
const [linkedin, setLinkedin] = useState('');
const [sprache, setSprache] = useState('');
const [seit, setSeit] = useState('');
const [isPrimary, setIsPrimary] = useState(false);
const add = trpc.lead.addContact.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
onDone();
},
});
return (
<form
className="space-y-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] p-3"
onSubmit={(e) => {
e.preventDefault();
if (!name.trim()) return;
add.mutate({
leadId,
name: name.trim(),
rolle: rolle.trim() || undefined,
funktion: (funktion || undefined) as
| 'entscheider'
| 'influencer'
| 'gatekeeper'
| 'end_user'
| 'champion'
| undefined,
email: email.trim() || undefined,
telefon: telefon.trim() || undefined,
linkedin: linkedin.trim() || undefined,
sprache: sprache.trim() || undefined,
imBuyingCenterSeit: seit.trim() || undefined,
isPrimary,
});
}}
>
<div className="grid grid-cols-2 gap-2">
<input placeholder="Name *" value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
<input placeholder="Rolle / Titel" value={rolle} onChange={(e) => setRolle(e.target.value)} className={inputCls} />
<select value={funktion} onChange={(e) => setFunktion(e.target.value)} className={inputCls}>
<option value="">Funktion im Kaufprozess</option>
{Object.entries(CONTACT_FUNKTION_LABEL).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
<input placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} className={inputCls} />
<input placeholder="Telefon" value={telefon} onChange={(e) => setTelefon(e.target.value)} className={inputCls} />
<input placeholder="LinkedIn-URL" value={linkedin} onChange={(e) => setLinkedin(e.target.value)} className={inputCls} />
<input placeholder="Sprache / Anrede" value={sprache} onChange={(e) => setSprache(e.target.value)} className={inputCls} />
<input placeholder="Im Buying-Center seit" value={seit} onChange={(e) => setSeit(e.target.value)} className={inputCls} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isPrimary} onChange={(e) => setIsPrimary(e.target.checked)} />
Hauptkontakt
</label>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onDone}>
Abbrechen
</Button>
<Button type="submit" size="sm" disabled={add.isPending}>
Hinzufügen
</Button>
</div>
</form>
);
}
// --- Austausch-Log -----------------------------------------------------------
function TouchpointsSection({
leadId,
touchpoints,
}: {
leadId: string;
touchpoints: Touchpoint[];
}) {
const [adding, setAdding] = useState(false);
return (
<Card.Root>
<Card.Header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Austausch-Log</h2>
{!adding && (
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex items-center gap-1 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
>
<Plus size={13} /> Touchpoint
</button>
)}
</Card.Header>
<Card.Body className="space-y-3">
{adding && <AddTouchpointForm leadId={leadId} onDone={() => setAdding(false)} />}
{touchpoints.length === 0 && !adding ? (
<p className="text-sm text-[var(--color-ink-muted)]">
Noch kein Kontakt protokolliert. Faustregel: kein Touchpoint ohne Eintrag.
</p>
) : (
<div className="space-y-2">
{touchpoints.map((t) => (
<TouchpointRow key={t.id} leadId={leadId} touchpoint={t} />
))}
</div>
)}
</Card.Body>
</Card.Root>
);
}
function TouchpointRow({ leadId, touchpoint }: { leadId: string; touchpoint: Touchpoint }) {
const utils = trpc.useUtils();
const remove = trpc.lead.removeTouchpoint.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
},
});
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-ink-muted)]">
<span className="font-mono tabular-nums">{touchpoint.datum}</span>
<span className="rounded-full bg-black/5 px-2 py-0.5">
{TOUCHPOINT_KANAL_LABEL[touchpoint.kanal] ?? touchpoint.kanal}
</span>
{touchpoint.bearbeiterName && <span>{touchpoint.bearbeiterName}</span>}
{touchpoint.followUpBis && (
<span className="rounded-full bg-[var(--color-ampel-amber-bg)] px-2 py-0.5 text-[var(--color-ampel-amber-fg)]">
Follow-up bis {touchpoint.followUpBis}
</span>
)}
</div>
<button
type="button"
onClick={() => remove.mutate({ touchpointId: touchpoint.id })}
aria-label="Eintrag entfernen"
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
>
<Trash2 size={15} />
</button>
</div>
<p className="mt-1 whitespace-pre-wrap">{touchpoint.inhalt}</p>
{touchpoint.outcome && (
<p className="mt-1 text-xs text-[var(--color-ink-muted)]">Outcome: {touchpoint.outcome}</p>
)}
</div>
);
}
function AddTouchpointForm({ leadId, onDone }: { leadId: string; onDone: () => void }) {
const utils = trpc.useUtils();
const [datum, setDatum] = useState(today());
const [kanal, setKanal] = useState('call');
const [inhalt, setInhalt] = useState('');
const [outcome, setOutcome] = useState('');
const [followUp, setFollowUp] = useState('');
const add = trpc.lead.addTouchpoint.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
await utils.lead.list.invalidate();
onDone();
},
});
return (
<form
className="space-y-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] p-3"
onSubmit={(e) => {
e.preventDefault();
if (!inhalt.trim()) return;
add.mutate({
leadId,
datum,
kanal: kanal as 'call' | 'mail' | 'linkedin' | 'termin' | 'event' | 'sonstiges',
inhalt: inhalt.trim(),
outcome: outcome.trim() || undefined,
followUpBis: followUp || undefined,
});
}}
>
<div className="grid grid-cols-2 gap-2">
<input type="date" value={datum} onChange={(e) => setDatum(e.target.value)} className={inputCls} />
<select value={kanal} onChange={(e) => setKanal(e.target.value)} className={inputCls}>
{Object.entries(TOUCHPOINT_KANAL_LABEL).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</div>
<textarea
placeholder="Worum ging es? 1–2 Sätze."
rows={2}
value={inhalt}
onChange={(e) => setInhalt(e.target.value)}
className={inputCls}
/>
<div className="grid grid-cols-2 gap-2">
<input placeholder="Outcome" value={outcome} onChange={(e) => setOutcome(e.target.value)} className={inputCls} />
<label className="flex flex-col text-xs text-[var(--color-ink-muted)]">
Follow-up bis
<input type="date" value={followUp} onChange={(e) => setFollowUp(e.target.value)} className={inputCls} />
</label>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onDone}>
Abbrechen
</Button>
<Button type="submit" size="sm" disabled={add.isPending}>
Erfassen
</Button>
</div>
</form>
);
}
// --- Offene Fragen (JSONB) ---------------------------------------------------
function OffeneFragenSection({
leadId,
fragen,
}: {
leadId: string;
fragen: Array<{ text: string; done: boolean }>;
}) {
const utils = trpc.useUtils();
const [neu, setNeu] = useState('');
const update = trpc.lead.update.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
},
});
function persist(next: Array<{ text: string; done: boolean }>) {
update.mutate({ id: leadId, offeneFragen: next });
}
return (
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Offene Fragen / Hypothesen</h2>
</Card.Header>
<Card.Body className="space-y-2">
{fragen.length === 0 && (
<p className="text-sm text-[var(--color-ink-muted)]">Keine offenen Fragen.</p>
)}
<ul className="space-y-1.5 text-sm">
{fragen.map((f, i) => (
<li key={i} className="flex items-center gap-2">
<input
type="checkbox"
checked={f.done}
onChange={() =>
persist(fragen.map((x, j) => (j === i ? { ...x, done: !x.done } : x)))
}
/>
<span className={clsx('flex-1', f.done && 'text-[var(--color-ink-muted)] line-through')}>
{f.text}
</span>
<button
type="button"
onClick={() => persist(fragen.filter((_, j) => j !== i))}
aria-label="Frage entfernen"
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const v = neu.trim();
if (!v) return;
persist([...fragen, { text: v, done: false }]);
setNeu('');
}}
>
<input
placeholder="Neue offene Frage…"
value={neu}
onChange={(e) => setNeu(e.target.value)}
className={inputCls}
/>
<Button type="submit" size="sm" variant="secondary">
<Plus size={14} />
</Button>
</form>
</Card.Body>
</Card.Root>
);
}
// --- Anhänge (JSONB) ---------------------------------------------------------
function AnhaengeSection({
leadId,
anhaenge,
}: {
leadId: string;
anhaenge: Array<{ label: string; url: string }>;
}) {
const utils = trpc.useUtils();
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
const update = trpc.lead.update.useMutation({
onSuccess: async () => {
await utils.lead.byId.invalidate({ id: leadId });
},
});
function persist(next: Array<{ label: string; url: string }>) {
update.mutate({ id: leadId, anhaenge: next });
}
return (
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Anhänge / verlinkte Dokumente</h2>
</Card.Header>
<Card.Body className="space-y-2">
{anhaenge.length === 0 && (
<p className="text-sm text-[var(--color-ink-muted)]">Noch keine Anhänge.</p>
)}
<ul className="space-y-1.5 text-sm">
{anhaenge.map((a, i) => (
<li key={i} className="flex items-center justify-between gap-2">
<ExternalLink url={a.url} label={a.label} />
<button
type="button"
onClick={() => persist(anhaenge.filter((_, j) => j !== i))}
aria-label="Anhang entfernen"
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
<form
className="grid grid-cols-[1fr_1fr_auto] gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!label.trim() || !url.trim()) return;
persist([...anhaenge, { label: label.trim(), url: url.trim() }]);
setLabel('');
setUrl('');
}}
>
<input placeholder="Bezeichnung" value={label} onChange={(e) => setLabel(e.target.value)} className={inputCls} />
<input placeholder="URL" value={url} onChange={(e) => setUrl(e.target.value)} className={inputCls} />
<Button type="submit" size="sm" variant="secondary">
<Plus size={14} />
</Button>
</form>
</Card.Body>
</Card.Root>
);
}
function ExternalLink({ url, label }: { url: string; label?: string }) {
const href = url.startsWith('http') ? url : `https://${url}`;
return (
<a href={href} target="_blank" rel="noreferrer" className="truncate text-[var(--color-ink)] hover:underline">
{label ?? url}
</a>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { clsx } from 'clsx';
import { toast } from 'sonner';
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,
type LeadStatus,
} from '../lib/lead-meta';
export function LeadsRoute() {
const q = trpc.lead.list.useQuery();
const [open, setOpen] = useState(false);
if (q.isPending) return <SkeletonCard lines={6} />;
const leads = q.data ?? [];
return (
<div className="space-y-6">
<header className="flex items-end justify-between gap-4">
<div>
<p className="text-sm text-[var(--color-ink-muted)]">
<Link to="/bereich/vertrieb" className="hover:underline">
Vertrieb
</Link>
{' › Leads'}
</p>
<h1 className="text-3xl font-bold">Leads</h1>
<p className="text-[var(--color-ink-muted)]">
Qualitative Lead-Dossiers — Stammdaten, Pain, Strategie, Austausch-Log.
</p>
</div>
<Button onClick={() => setOpen(true)}>{voice.cta.newLead}</Button>
</header>
{leads.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Noch keine Leads. Leg einen an.
</p>
</Card.Body>
</Card.Root>
) : (
LEAD_STATUS_ORDER.map((status) => {
const group = leads.filter((l) => l.status === status);
if (group.length === 0) return null;
return (
<section key={status}>
<h2 className="mb-2 text-lg font-semibold">
{LEAD_STATUS_META[status].label}{' '}
<span className="text-sm font-normal text-[var(--color-ink-muted)]">
({group.length})
</span>
</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{group.map((l) => (
<LeadCard key={l.id} lead={l} />
))}
</div>
</section>
);
})
)}
{open && <LeadCreateDialog onClose={() => setOpen(false)} />}
</div>
);
}
function LeadCard({
lead,
}: {
lead: {
id: string;
firma: string;
status: LeadStatus;
score: number;
ownerName: string | null;
branche: string | null;
subSegment: string | null;
naechsterSchrittDatum: string | null;
};
}) {
const meta = LEAD_STATUS_META[lead.status];
const sub = [lead.branche, lead.subSegment].filter(Boolean).join(' · ');
return (
<Link to={`/lead/${lead.id}`} className="contents">
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
<Card.Body className="space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="font-semibold">{lead.firma}</p>
<span className={clsx('shrink-0 rounded-full px-2 py-0.5 text-xs', meta.badge)}>
{meta.label}
</span>
</div>
{sub && <p className="text-xs text-[var(--color-ink-muted)]">{sub}</p>}
<div className="flex items-center justify-between text-xs text-[var(--color-ink-muted)]">
<span>{lead.ownerName ?? 'ohne Owner'}</span>
<span className="font-mono tabular-nums">Score {lead.score}/10</span>
</div>
{lead.naechsterSchrittDatum && (
<p className="text-xs text-[var(--color-ink-muted)]">
Nächster Schritt: {lead.naechsterSchrittDatum}
</p>
)}
</Card.Body>
</Card.Root>
</Link>
);
}
function LeadCreateDialog({ onClose }: { onClose: () => void }) {
const users = trpc.user.list.useQuery();
const [firma, setFirma] = useState('');
const [branche, setBranche] = useState('');
const [subSegment, setSubSegment] = useState('');
const [website, setWebsite] = useState('');
const [quelle, setQuelle] = useState('');
const [ownerUserId, setOwnerUserId] = useState('');
const [status, setStatus] = useState<LeadStatus>('cold');
const [score, setScore] = useState(0);
const utils = trpc.useUtils();
const create = trpc.lead.create.useMutation({
onSuccess: async () => {
toast(voice.toast.leadCreated);
await utils.lead.list.invalidate();
onClose();
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
if (!firma.trim()) return;
create.mutate({
firma: firma.trim(),
branche: branche.trim() || undefined,
subSegment: subSegment.trim() || undefined,
website: website.trim() || undefined,
quelle: quelle.trim() || undefined,
ownerUserId: ownerUserId || undefined,
status,
score,
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neuer Lead</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Nur die Firma ist Pflicht — alles Weitere füllst du im Dossier.
</p>
<form onSubmit={submit} className="mt-4 space-y-3">
<Field label="Firma">
<input
required
value={firma}
onChange={(e) => setFirma(e.target.value)}
className={inputCls}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Branche">
<input value={branche} onChange={(e) => setBranche(e.target.value)} className={inputCls} />
</Field>
<Field label="Nische / Sub-Segment">
<input
value={subSegment}
onChange={(e) => setSubSegment(e.target.value)}
className={inputCls}
/>
</Field>
</div>
<Field label="Website">
<input value={website} onChange={(e) => setWebsite(e.target.value)} className={inputCls} />
</Field>
<Field label="Quelle (wie gefunden)">
<input value={quelle} onChange={(e) => setQuelle(e.target.value)} className={inputCls} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Owner">
<select
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
className={inputCls}
>
<option value="">— niemand —</option>
{(users.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<Field label="Status">
<select
value={status}
onChange={(e) => setStatus(e.target.value as LeadStatus)}
className={inputCls}
>
{LEAD_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{LEAD_STATUS_META[s].label}
</option>
))}
</select>
</Field>
</div>
<Field label={`Score: ${score}/10`}>
<input
type="range"
min={0}
max={10}
value={score}
onChange={(e) => setScore(Number(e.target.value))}
className="w-full"
/>
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={create.isPending}>
Anlegen
</Button>
</div>
</form>
</div>
</div>
);
}
const inputCls =
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm';
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<div className="mt-1">{children}</div>
</label>
);
}
+86
View File
@@ -0,0 +1,86 @@
CREATE TABLE "lead" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"area_id" uuid NOT NULL,
"firma" text NOT NULL,
"status" text DEFAULT 'cold' NOT NULL,
"score" integer DEFAULT 0 NOT NULL,
"owner_user_id" uuid,
"branche" text,
"sub_segment" text,
"standorte" text,
"mitarbeiterzahl" text,
"umsatz" text,
"website" text,
"quelle" text,
"pain_point" text,
"status_quo_kosten" text,
"bestehende_loesung" text,
"wechselgrund" text,
"persoenlicher_erfolg" text,
"budget_indikation" text,
"entscheidungshorizont" text,
"compelling_event" text,
"mitbewerber" text,
"buying_center" text,
"beschaffungsweg" text,
"kommunikationsstil" text,
"vorlieben" text,
"hot_buttons" text,
"no_gos" text,
"druckpunkte" text,
"anker" text,
"vertrauenslevel" integer,
"nutzen_hypothese" text,
"differenzierer" text,
"naechster_schritt" text,
"naechster_schritt_datum" date,
"risiken" text,
"plan_b" text,
"offene_fragen" jsonb DEFAULT '[]'::jsonb NOT NULL,
"anhaenge" jsonb DEFAULT '[]'::jsonb NOT NULL,
"promoted_deal_id" uuid,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lead_contact" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"name" text NOT NULL,
"rolle" text,
"funktion" text,
"email" text,
"telefon" text,
"linkedin" text,
"im_buying_center_seit" text,
"sprache" text,
"is_primary" boolean DEFAULT false NOT NULL,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lead_touchpoint" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"datum" date NOT NULL,
"kanal" text NOT NULL,
"bearbeiter_user_id" uuid,
"inhalt" text NOT NULL,
"outcome" text,
"follow_up_bis" date,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "lead" ADD CONSTRAINT "lead_area_id_area_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."area"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead" ADD CONSTRAINT "lead_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead" ADD CONSTRAINT "lead_promoted_deal_id_deal_id_fk" FOREIGN KEY ("promoted_deal_id") REFERENCES "public"."deal"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead" ADD CONSTRAINT "lead_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_contact" ADD CONSTRAINT "lead_contact_lead_id_lead_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."lead"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_touchpoint" ADD CONSTRAINT "lead_touchpoint_lead_id_lead_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."lead"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_touchpoint" ADD CONSTRAINT "lead_touchpoint_bearbeiter_user_id_user_id_fk" FOREIGN KEY ("bearbeiter_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_lead_status" ON "lead" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_lead_owner" ON "lead" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "idx_lead_contact_lead" ON "lead_contact" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_lead_touchpoint_lead" ON "lead_touchpoint" USING btree ("lead_id");--> statement-breakpoint
CREATE INDEX "idx_lead_touchpoint_followup" ON "lead_touchpoint" USING btree ("follow_up_bis");
File diff suppressed because it is too large. Load diff
+137
View File
@@ -0,0 +1,137 @@
import { z } from 'zod';
// Status & Enums --------------------------------------------------------------
export const leadStatusSchema = z.enum(['cold', 'warm', 'hot', 'won', 'lost']);
export const LEAD_STATUSES = leadStatusSchema.options;
export const contactFunktionSchema = z.enum([
'entscheider',
'influencer',
'gatekeeper',
'end_user',
'champion',
]);
export const CONTACT_FUNKTIONEN = contactFunktionSchema.options;
export const touchpointKanalSchema = z.enum([
'call',
'mail',
'linkedin',
'termin',
'event',
'sonstiges',
]);
export const TOUCHPOINT_KANAELE = touchpointKanalSchema.options;
// JSONB-Substrukturen ---------------------------------------------------------
export const offeneFrageSchema = z.object({
text: z.string().min(1).max(500),
done: z.boolean(),
});
export const anhangSchema = z.object({
label: z.string().min(1).max(200),
url: z.string().min(1).max(2000),
});
// Lead-Kernfelder (alle optional außer firma). Wird für create + update genutzt.
const leadCoreFields = {
firma: z.string().min(1).max(200),
status: leadStatusSchema,
score: z.number().int().min(0).max(10),
ownerUserId: z.string().uuid().nullable(),
branche: z.string().max(200).nullable(),
subSegment: z.string().max(200).nullable(),
standorte: z.string().max(500).nullable(),
mitarbeiterzahl: z.string().max(100).nullable(),
umsatz: z.string().max(100).nullable(),
website: z.string().max(500).nullable(),
quelle: z.string().max(500).nullable(),
painPoint: z.string().max(2000).nullable(),
statusQuoKosten: z.string().max(2000).nullable(),
bestehendeLoesung: z.string().max(1000).nullable(),
wechselgrund: z.string().max(2000).nullable(),
persoenlicherErfolg: z.string().max(2000).nullable(),
budgetIndikation: z.string().max(500).nullable(),
entscheidungshorizont: z.string().max(500).nullable(),
compellingEvent: z.string().max(1000).nullable(),
mitbewerber: z.string().max(1000).nullable(),
buyingCenter: z.string().max(1000).nullable(),
beschaffungsweg: z.string().max(500).nullable(),
kommunikationsstil: z.string().max(1000).nullable(),
vorlieben: z.string().max(1000).nullable(),
hotButtons: z.string().max(1000).nullable(),
noGos: z.string().max(1000).nullable(),
druckpunkte: z.string().max(1000).nullable(),
anker: z.string().max(1000).nullable(),
vertrauenslevel: z.number().int().min(1).max(5).nullable(),
nutzenHypothese: z.string().max(1000).nullable(),
differenzierer: z.string().max(1000).nullable(),
naechsterSchritt: z.string().max(1000).nullable(),
naechsterSchrittDatum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(),
risiken: z.string().max(1000).nullable(),
planB: z.string().max(1000).nullable(),
offeneFragen: z.array(offeneFrageSchema).max(100),
anhaenge: z.array(anhangSchema).max(50),
};
// Create: nur firma ist Pflicht, Rest optional mit sinnvollen Defaults.
export const createLeadInput = z.object({
firma: leadCoreFields.firma,
status: leadStatusSchema.default('cold'),
score: z.number().int().min(0).max(10).default(0),
ownerUserId: z.string().uuid().optional(),
branche: z.string().max(200).optional(),
subSegment: z.string().max(200).optional(),
website: z.string().max(500).optional(),
quelle: z.string().max(500).optional(),
});
export type CreateLeadInput = z.infer<typeof createLeadInput>;
// Update: id + beliebige Teilmenge der Kernfelder (partial patch).
export const updateLeadInput = z
.object({ id: z.string().uuid() })
.merge(z.object(leadCoreFields).partial());
export type UpdateLeadInput = z.infer<typeof updateLeadInput>;
export const leadIdInput = z.object({ id: z.string().uuid() });
// Kontakte --------------------------------------------------------------------
export const addContactInput = z.object({
leadId: z.string().uuid(),
name: z.string().min(1).max(200),
rolle: z.string().max(200).optional(),
funktion: contactFunktionSchema.optional(),
email: z.string().max(320).optional(),
telefon: z.string().max(100).optional(),
linkedin: z.string().max(500).optional(),
imBuyingCenterSeit: z.string().max(100).optional(),
sprache: z.string().max(100).optional(),
isPrimary: z.boolean().default(false),
});
export type AddContactInput = z.infer<typeof addContactInput>;
export const removeContactInput = z.object({ contactId: z.string().uuid() });
// Touchpoints (Austausch-Log) -------------------------------------------------
export const addTouchpointInput = z.object({
leadId: z.string().uuid(),
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
kanal: touchpointKanalSchema,
inhalt: z.string().min(1).max(2000),
outcome: z.string().max(1000).optional(),
followUpBis: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
export type AddTouchpointInput = z.infer<typeof addTouchpointInput>;
export const removeTouchpointInput = z.object({ touchpointId: z.string().uuid() });
// Promotion → Pipeline-Deal ---------------------------------------------------
export const promoteToDealInput = z.object({
leadId: z.string().uuid(),
valueEur: z.number().int().min(0).default(0),
probability: z.number().int().min(0).max(100).default(50),
});
export type PromoteToDealInput = z.infer<typeof promoteToDealInput>;