feat(leads): Leads universell mit Projekten verknüpfen
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:
1 parent
68480c1eb1
commit
19db774c4c
10 files changed
+3522
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user