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) <noreply@anthropic.com>
This commit is contained in:
Raffael SheikhandClaude Opus 4.8 committed 2026-06-07 12:27:54 +02:00
1 parent 6a25383cbe
commit aad247dd4a
3 files changed
+164

No files matched your search

+4
View File
@@ -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(<Bereich />) },
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
{ path: 'leads', element: withSuspense(<Leads />) },
{ path: 'pipeline', element: withSuspense(<Pipeline />) },
{ path: 'lead/:id', element: withSuspense(<Lead />) },
{ path: 'team', element: withSuspense(<TeamView />) },
{ path: 'historie', element: withSuspense(<Historie />) },
+6
View File
@@ -35,6 +35,12 @@ export function LeadsRoute() {
</p>
</div>
<div className="flex gap-2">
<Link
to="/pipeline"
className="inline-flex h-9 items-center rounded-md px-4 text-sm font-medium text-[var(--color-ink)] transition-colors hover:bg-black/5"
>
Pipeline
</Link>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
CSV importieren
</Button>
+154
View File
@@ -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<AppRouter>['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 <SkeletonCard lines={6} />;
if (dealsQ.isError || !dealsQ.data)
return (
<ErrorState
message="Pipeline konnte nicht geladen werden."
onRetry={() => void dealsQ.refetch()}
/>
);
const deals = dealsQ.data;
const k = kpisQ.data;
return (
<div className="space-y-6">
<header>
<p className="text-sm text-[var(--color-ink-muted)]">
<Link to="/bereich/vertrieb" className="hover:underline">
Vertrieb
</Link>
{' › Pipeline'}
</p>
<h1 className="text-3xl font-bold">Pipeline</h1>
<p className="text-[var(--color-ink-muted)]">
Deals nach Stufe. Stufe ändern verschiebt den Deal; „Abschluss“ zählt als gewonnen.
</p>
</header>
{k && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="Gewichtete Pipeline" value={euro(k.weightedPipelineEur)} />
<KpiCard label="Offene Deals" value={String(k.openDealCount)} />
<KpiCard
label="Gewonnen (Quartal)"
value={`${euro(k.wonValueQuarterEur)} · ${k.wonCountQuarter}`}
/>
</div>
)}
{deals.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Noch keine Deals. Promote einen Lead über das Lead-Dossier („Zu Deal machen“).
</p>
</Card.Body>
</Card.Root>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{STAGES.map((stage) => {
const stageDeals = deals.filter((d) => d.stage === stage.key);
const sum = stageDeals.reduce((acc, d) => acc + d.valueEur, 0);
return (
<div key={stage.key} className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold">
{stage.label}{' '}
<span className="font-normal text-[var(--color-ink-muted)]">
({stageDeals.length})
</span>
</h2>
{sum > 0 && (
<span className="text-xs text-[var(--color-ink-muted)]">{euro(sum)}</span>
)}
</div>
<div className="space-y-2">
{stageDeals.map((d) => (
<DealCard key={d.id} deal={d} />
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function KpiCard({ label, value }: { label: string; value: string }) {
return (
<Card.Root>
<Card.Body>
<p className="text-xs text-[var(--color-ink-muted)]">{label}</p>
<p className="mt-0.5 text-lg font-semibold">{value}</p>
</Card.Body>
</Card.Root>
);
}
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 (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white p-2.5 text-sm">
<p className="font-medium">{deal.name}</p>
<p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">
{euro(deal.valueEur)} · {deal.probability}%{deal.wonAt ? ' · gewonnen' : ''}
</p>
<label className="sr-only" htmlFor={`stage-${deal.id}`}>
Stufe von {deal.name}
</label>
<select
id={`stage-${deal.id}`}
value={deal.stage}
onChange={(e) => move.mutate({ dealId: deal.id, stage: e.target.value as Stage })}
disabled={move.isPending}
className="mt-1.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-1.5 py-1 text-xs"
>
{STAGES.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</div>
);
}