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;
|
||||
}),
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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 />) },
|
||||
],
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
Reference in new issue
Block a user