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:
1 parent
6a25383cbe
commit
aad247dd4a
3 files changed
+164
No files matched your search
@@ -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 />) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user