From aad247dd4a797ef7320c200a7e1d58cba94dbfe6 Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Sun, 7 Jun 2026 12:27:54 +0200 Subject: [PATCH] Feature: Deal-Pipeline-UI (totes Deal-Backend reaktiviert) Das vollstaendige Deal-Backend (list/kpis/updateStage) lag ohne UI brach; Lead->Deal-Promotion war eine Sackgasse. Neue Route /pipeline: KPI-Header (gewichtete Pipeline, offene Deals, gewonnen/Quartal) + Spalten je Stufe (lead/erstgespraech/demo/angebot/abschluss), Stufenwechsel per Select (deal.updateStage). Verlinkt von /leads. Kein Drag-Kanban (CONVENTIONS). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/router.tsx | 4 + apps/web/src/routes/leads.tsx | 6 ++ apps/web/src/routes/pipeline.tsx | 154 +++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 apps/web/src/routes/pipeline.tsx diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 8f53461..498936c 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -68,6 +68,9 @@ const Historie = lazyWithReload(() => const Archiv = lazyWithReload(() => import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })), ); +const Pipeline = lazyWithReload(() => + import('../routes/pipeline').then((m) => ({ default: m.PipelineRoute })), +); const NotFound = lazyWithReload(() => import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })), ); @@ -114,6 +117,7 @@ const router = createBrowserRouter([ { path: 'bereich/:slug', element: withSuspense() }, { path: 'projekt/:id', element: withSuspense() }, { path: 'leads', element: withSuspense() }, + { path: 'pipeline', element: withSuspense() }, { path: 'lead/:id', element: withSuspense() }, { path: 'team', element: withSuspense() }, { path: 'historie', element: withSuspense() }, diff --git a/apps/web/src/routes/leads.tsx b/apps/web/src/routes/leads.tsx index 289dae4..df7f49e 100644 --- a/apps/web/src/routes/leads.tsx +++ b/apps/web/src/routes/leads.tsx @@ -35,6 +35,12 @@ export function LeadsRoute() {

+ + Pipeline + diff --git a/apps/web/src/routes/pipeline.tsx b/apps/web/src/routes/pipeline.tsx new file mode 100644 index 0000000..1b57f44 --- /dev/null +++ b/apps/web/src/routes/pipeline.tsx @@ -0,0 +1,154 @@ +import { Link } from 'react-router'; +import type { inferRouterOutputs } from '@trpc/server'; +import type { AppRouter } from '@cockpit/api/router'; +import { trpc } from '../lib/trpc'; +import { Card } from '../components/ui/Card'; +import { SkeletonCard } from '../components/ui/SkeletonCard'; +import { ErrorState } from '../components/ui/ErrorState'; + +type Deal = inferRouterOutputs['deal']['list'][number]; + +const STAGES = [ + { key: 'lead', label: 'Lead' }, + { key: 'erstgespraech', label: 'Erstgespräch' }, + { key: 'demo', label: 'Demo' }, + { key: 'angebot', label: 'Angebot' }, + { key: 'abschluss', label: 'Abschluss' }, +] as const; + +type Stage = (typeof STAGES)[number]['key']; + +// valueEur wird in Cent gespeichert. +const euro = (cents: number) => + new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + }).format(cents / 100); + +export function PipelineRoute() { + const dealsQ = trpc.deal.list.useQuery(); + const kpisQ = trpc.deal.kpis.useQuery(); + + if (dealsQ.isPending) return ; + if (dealsQ.isError || !dealsQ.data) + return ( + void dealsQ.refetch()} + /> + ); + + const deals = dealsQ.data; + const k = kpisQ.data; + + return ( +
+
+

+ + Vertrieb + + {' › Pipeline'} +

+

Pipeline

+

+ Deals nach Stufe. Stufe ändern verschiebt den Deal; „Abschluss“ zählt als gewonnen. +

+
+ + {k && ( +
+ + + +
+ )} + + {deals.length === 0 ? ( + + +

+ Noch keine Deals. Promote einen Lead über das Lead-Dossier („Zu Deal machen“). +

+
+
+ ) : ( +
+ {STAGES.map((stage) => { + const stageDeals = deals.filter((d) => d.stage === stage.key); + const sum = stageDeals.reduce((acc, d) => acc + d.valueEur, 0); + return ( +
+
+

+ {stage.label}{' '} + + ({stageDeals.length}) + +

+ {sum > 0 && ( + {euro(sum)} + )} +
+
+ {stageDeals.map((d) => ( + + ))} +
+
+ ); + })} +
+ )} +
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string }) { + return ( + + +

{label}

+

{value}

+
+
+ ); +} + +function DealCard({ deal }: { deal: Deal }) { + const utils = trpc.useUtils(); + const move = trpc.deal.updateStage.useMutation({ + onSuccess: async () => { + await utils.deal.list.invalidate(); + await utils.deal.kpis.invalidate(); + }, + }); + return ( +
+

{deal.name}

+

+ {euro(deal.valueEur)} · {deal.probability}%{deal.wonAt ? ' · gewonnen' : ''} +

+ + +
+ ); +}