feat(leads): Leads universell mit Projekten verknüpfen
ci / quality (push) Failing after 44s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 54s

Ein Projekt lebt in genau einem Heimat-Bereich und wird einmal angelegt;
Leads bekommen eine optionale Verknüpfung dorthin (lead.projectId), egal in
welchem Bereich das Projekt wohnt — kein redundantes Anlegen mehr.

- Migration 0011: lead.project_id (FK → project, ON DELETE SET NULL).
- Lead anlegen / CSV-Import / Lead-Detail: Projekt zuordnen; CSV ordnet alle
  importierten Leads einem Projekt zu.
- Projektseite: neue "Leads"-Sektion (Vertriebs-Sicht des Projekts).
- Vertrieb: "Projekte mit Vertriebsaktivität" — bereichsübergreifend alle
  Projekte mit verknüpften Leads (so erscheint ein Entwicklungsprojekt hier,
  WEIL es Leads hat). Rein interne Projekte ohne Leads tauchen nicht auf.
- Lead-Liste/Detail zeigen das verknüpfte Projekt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 16:08:59 +02:00
1 parent 68480c1eb1
commit 19db774c4c
10 files changed
+3522 -3

No files matched your search

+27 -2
View File
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { asc, desc, eq, sql } from 'drizzle-orm';
import { lead, leadContact, leadTouchpoint, deal, area, user } from '@cockpit/db';
import { lead, leadContact, leadTouchpoint, deal, area, user, project } from '@cockpit/db';
import type { Db } from '@cockpit/db';
import { LeadSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
@@ -21,13 +21,28 @@ export const leadRouter = router({
.select({
lead: lead,
ownerName: user.name,
projectName: project.name,
})
.from(lead)
.leftJoin(user, eq(user.id, lead.ownerUserId))
.leftJoin(project, eq(project.id, lead.projectId))
.orderBy(desc(lead.updatedAt));
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName }));
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName, projectName: r.projectName }));
}),
// Leads, die einem Projekt zugeordnet sind (Vertriebs-Sicht im Projekt).
forProject: protectedProcedure
.input(LeadSchemas.leadsForProjectInput)
.query(async ({ ctx, input }) => {
const rows = await ctx.db
.select({ lead: lead, ownerName: user.name })
.from(lead)
.leftJoin(user, eq(user.id, lead.ownerUserId))
.where(eq(lead.projectId, input.projectId))
.orderBy(desc(lead.score), desc(lead.updatedAt));
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName }));
}),
byId: protectedProcedure.input(LeadSchemas.leadIdInput).query(async ({ ctx, input }) => {
const l = (await ctx.db.select().from(lead).where(eq(lead.id, input.id)).limit(1))[0];
if (!l) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' });
@@ -45,9 +60,17 @@ export const leadRouter = router({
.leftJoin(user, eq(user.id, leadTouchpoint.bearbeiterUserId))
.where(eq(leadTouchpoint.leadId, l.id))
.orderBy(desc(leadTouchpoint.datum), desc(leadTouchpoint.createdAt));
const linkedProject = l.projectId
? (await ctx.db
.select({ id: project.id, name: project.name })
.from(project)
.where(eq(project.id, l.projectId))
.limit(1))[0] ?? null
: null;
return {
lead: l,
contacts,
project: linkedProject,
touchpoints: touchpoints.map((t) => ({ ...t.touchpoint, bearbeiterName: t.bearbeiterName })),
};
}),
@@ -64,6 +87,7 @@ export const leadRouter = router({
status: input.status,
score: input.score,
ownerUserId: input.ownerUserId ?? null,
projectId: input.projectId ?? null,
branche: input.branche ?? null,
subSegment: input.subSegment ?? null,
website: input.website ?? null,
@@ -259,6 +283,7 @@ export const leadRouter = router({
firma,
status: 'cold',
score: 0,
projectId: input.projectId ?? null,
branche: get(row, cells.branche) || null,
website: get(row, cells.website) || null,
quelle: get(row, cells.quelle) || null,
+39
View File
@@ -327,11 +327,50 @@ function VertriebDetail() {
return (
<div className="space-y-6">
<LeadsTeaser />
<ProjekteMitLeads />
<ProjektListeFuerBereich slug="vertrieb" />
</div>
);
}
// Bereichsübergreifend: Projekte (egal aus welchem Bereich), die verknüpfte
// Leads haben — so taucht z. B. ein Entwicklungsprojekt hier auf, WEIL es
// Vertriebsaktivität hat, ohne redundant angelegt werden zu müssen.
function ProjekteMitLeads() {
const q = trpc.lead.list.useQuery();
const leads = q.data ?? [];
const map = new Map<string, { id: string; name: string; total: number; open: number }>();
for (const l of leads) {
if (!l.projectId || !l.projectName) continue;
const e = map.get(l.projectId) ?? { id: l.projectId, name: l.projectName, total: 0, open: 0 };
e.total++;
if (l.status !== 'won' && l.status !== 'lost') e.open++;
map.set(l.projectId, e);
}
const projects = Array.from(map.values()).sort((a, b) => b.open - a.open);
if (projects.length === 0) return null;
return (
<section>
<h2 className="mb-2 text-lg font-semibold">Projekte mit Vertriebsaktivität</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<Link key={p.id} to={`/projekt/${p.id}`} className="contents">
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
<Card.Body className="flex items-center justify-between gap-2">
<p className="font-medium">{p.name}</p>
<span className="shrink-0 text-xs text-[var(--color-ink-muted)]">
{p.total} {p.total === 1 ? 'Lead' : 'Leads'}
{p.open > 0 && ` · ${p.open} offen`}
</span>
</Card.Body>
</Card.Root>
</Link>
))}
</div>
</section>
);
}
function LeadsTeaser() {
const q = trpc.lead.list.useQuery();
const leads = q.data ?? [];
+26
View File
@@ -184,6 +184,8 @@ function Header({ lead, onBack }: { lead: LeadDossier; onBack: () => void }) {
});
const users = trpc.user.list.useQuery();
const projectsQ = trpc.project.list.useQuery();
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
const meta = LEAD_STATUS_META[lead.status as LeadStatus];
return (
@@ -307,6 +309,30 @@ function Header({ lead, onBack }: { lead: LeadDossier; onBack: () => void }) {
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<span className="text-[var(--color-ink-muted)]">Projekt</span>
<select
value={lead.projectId ?? ''}
onChange={(e) => update.mutate({ id: lead.id, projectId: e.target.value || null })}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
<option value="">— keins —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{lead.projectId && (
<Link
to={`/projekt/${lead.projectId}`}
className="text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
>
öffnen ↗
</Link>
)}
</label>
</div>
{promoteOpen && <PromoteDialog leadId={lead.id} onClose={() => setPromoteOpen(false)} />}
+40 -1
View File
@@ -81,6 +81,9 @@ export function LeadsRoute() {
}
function LeadImportDialog({ onClose }: { onClose: () => void }) {
const projectsQ = trpc.project.list.useQuery();
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
const [projectId, setProjectId] = useState('');
const [fileName, setFileName] = useState('');
const [csv, setCsv] = useState('');
const [result, setResult] = useState<{
@@ -124,6 +127,22 @@ function LeadImportDialog({ onClose }: { onClose: () => void }) {
</p>
<div className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Projekt zuordnen (optional)</span>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={!!result}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="">— kein Projekt —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium">CSV-Datei</span>
<input
@@ -160,7 +179,7 @@ function LeadImportDialog({ onClose }: { onClose: () => void }) {
{!result && (
<Button
type="button"
onClick={() => importMut.mutate({ csv })}
onClick={() => importMut.mutate({ csv, projectId: projectId || undefined })}
disabled={!csv || importMut.isPending}
>
{importMut.isPending ? 'Importiere…' : 'Importieren'}
@@ -182,6 +201,7 @@ function LeadCard({
status: LeadStatus;
score: number;
ownerName: string | null;
projectName: string | null;
branche: string | null;
subSegment: string | null;
naechsterSchrittDatum: string | null;
@@ -200,6 +220,11 @@ function LeadCard({
</span>
</div>
{sub && <p className="text-xs text-[var(--color-ink-muted)]">{sub}</p>}
{lead.projectName && (
<p className="text-xs text-[var(--color-ink-muted)]">
<span className="rounded-full bg-black/5 px-2 py-0.5">↳ {lead.projectName}</span>
</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>
@@ -217,12 +242,15 @@ function LeadCard({
function LeadCreateDialog({ onClose }: { onClose: () => void }) {
const users = trpc.user.list.useQuery();
const projectsQ = trpc.project.list.useQuery();
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
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 [projectId, setProjectId] = useState('');
const [status, setStatus] = useState<LeadStatus>('cold');
const [score, setScore] = useState(0);
@@ -245,6 +273,7 @@ function LeadCreateDialog({ onClose }: { onClose: () => void }) {
website: website.trim() || undefined,
quelle: quelle.trim() || undefined,
ownerUserId: ownerUserId || undefined,
projectId: projectId || undefined,
status,
score,
});
@@ -287,6 +316,16 @@ function LeadCreateDialog({ onClose }: { onClose: () => void }) {
<Field label="Quelle (wie gefunden)">
<input value={quelle} onChange={(e) => setQuelle(e.target.value)} className={inputCls} />
</Field>
<Field label="Projekt (optional)">
<select value={projectId} onChange={(e) => setProjectId(e.target.value)} className={inputCls}>
<option value="">— kein Projekt —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Owner">
<select
+97
View File
@@ -11,6 +11,7 @@ import { useCurrentUser } from '../hooks/useCurrentUser';
import { voice } from '../lib/voice';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta';
import {
MODUS_META,
MODI,
@@ -176,6 +177,8 @@ export function ProjektRoute() {
<StakeholdersSection projectId={p.id} />
<LeadsSection projectId={p.id} />
<RisksSection projectId={p.id} />
<ChangeLogSection projectId={p.id} />
@@ -1106,6 +1109,100 @@ function LevelSelect({
);
}
// --- Leads (Vertriebs-Sicht des Projekts) -----------------------------------
function LeadsSection({ projectId }: { projectId: string }) {
const utils = trpc.useUtils();
const q = trpc.lead.forProject.useQuery({ projectId });
const [adding, setAdding] = useState(false);
const [firma, setFirma] = useState('');
const create = trpc.lead.create.useMutation({
onSuccess: async () => {
await utils.lead.forProject.invalidate({ projectId });
await utils.lead.list.invalidate();
setFirma('');
setAdding(false);
},
});
const list = q.data ?? [];
return (
<Card.Root>
<Card.Header className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
Leads{list.length > 0 && <span className="text-sm font-normal text-[var(--color-ink-muted)]"> ({list.length})</span>}
</h2>
<div className="flex items-center gap-3">
<Link
to="/leads"
className="text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
>
Alle Leads →
</Link>
{!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} /> Lead
</button>
)}
</div>
</Card.Header>
<Card.Body className="space-y-2">
{list.length === 0 && !adding && (
<p className="text-sm text-[var(--color-ink-muted)]">
Noch keine Leads mit diesem Projekt verknüpft.
</p>
)}
{list.map((l) => (
<Link
key={l.id}
to={`/lead/${l.id}`}
className="flex items-center justify-between gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm hover:border-[var(--color-ink)]"
>
<span className="flex items-center gap-2">
<span
className={clsx('h-2 w-2 rounded-full', LEAD_STATUS_META[l.status as LeadStatus].dot)}
/>
<span className="font-medium">{l.firma}</span>
{l.branche && <span className="text-xs text-[var(--color-ink-muted)]">{l.branche}</span>}
</span>
<span className="flex items-center gap-2 text-xs text-[var(--color-ink-muted)]">
<span className={clsx('rounded-full px-2 py-0.5', LEAD_STATUS_META[l.status as LeadStatus].badge)}>
{LEAD_STATUS_META[l.status as LeadStatus].label}
</span>
<span className="font-mono tabular-nums">{l.score}/10</span>
</span>
</Link>
))}
{adding && (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!firma.trim()) return;
create.mutate({ firma: firma.trim(), projectId, status: 'cold', score: 0 });
}}
>
<input
value={firma}
onChange={(e) => setFirma(e.target.value)}
placeholder="Firma / Lead-Name"
className={inputCls}
/>
<Button type="submit" size="sm" disabled={create.isPending}>
Anlegen
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setAdding(false)}>
Abbrechen
</Button>
</form>
)}
</Card.Body>
</Card.Root>
);
}
// --- Risiken & Chancen ------------------------------------------------------
function RisksSection({ projectId }: { projectId: string }) {
const q = trpc.risk.forProject.useQuery({ projectId });
@@ -0,0 +1,3 @@
ALTER TABLE "lead" ADD COLUMN "project_id" uuid;--> statement-breakpoint
ALTER TABLE "lead" ADD CONSTRAINT "lead_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_lead_project" ON "lead" USING btree ("project_id");
File diff suppressed because it is too large. Load diff
@@ -78,6 +78,13 @@
"when": 1780672129310,
"tag": "0010_leitfaden_phases",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780754557002,
"tag": "0011_lead_project_link",
"breakpoints": true
}
]
}
+5
View File
@@ -465,6 +465,10 @@ export const lead = pgTable(
areaId: uuid('area_id')
.notNull()
.references(() => area.id, { onDelete: 'restrict' }),
// Optionale Verknüpfung zum (universellen) Projekt — egal in welchem Bereich
// dieses wohnt. So sind „HiveGrid-Leads" mit dem HiveGrid-Projekt verbunden,
// ohne dass das Projekt im Vertrieb redundant angelegt werden muss.
projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }),
// 1. Stammdaten
firma: text('firma').notNull(),
status: text('status', { enum: ['cold', 'warm', 'hot', 'won', 'lost'] })
@@ -525,6 +529,7 @@ export const lead = pgTable(
(t) => ({
idxStatus: index('idx_lead_status').on(t.status),
idxOwner: index('idx_lead_owner').on(t.ownerUserId),
idxProject: index('idx_lead_project').on(t.projectId),
}),
);
+5
View File
@@ -39,6 +39,7 @@ const leadCoreFields = {
status: leadStatusSchema,
score: z.number().int().min(0).max(10),
ownerUserId: z.string().uuid().nullable(),
projectId: z.string().uuid().nullable(),
branche: z.string().max(200).nullable(),
subSegment: z.string().max(200).nullable(),
standorte: z.string().max(500).nullable(),
@@ -80,6 +81,7 @@ export const createLeadInput = z.object({
status: leadStatusSchema.default('cold'),
score: z.number().int().min(0).max(10).default(0),
ownerUserId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
branche: z.string().max(200).optional(),
subSegment: z.string().max(200).optional(),
website: z.string().max(500).optional(),
@@ -87,6 +89,8 @@ export const createLeadInput = z.object({
});
export type CreateLeadInput = z.infer<typeof createLeadInput>;
export const leadsForProjectInput = z.object({ projectId: z.string().uuid() });
// Update: id + beliebige Teilmenge der Kernfelder (partial patch).
export const updateLeadInput = z
.object({ id: z.string().uuid() })
@@ -139,5 +143,6 @@ export type PromoteToDealInput = z.infer<typeof promoteToDealInput>;
// CSV-Import: rohe CSV-Datei wird serverseitig geparst (header-basiert).
export const importLeadsCsvInput = z.object({
csv: z.string().min(1).max(2_000_000),
projectId: z.string().uuid().optional(), // alle importierten Leads diesem Projekt zuordnen
});
export type ImportLeadsCsvInput = z.infer<typeof importLeadsCsvInput>;