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' : ''}
+
+
+
+
+ );
+}