Files
pig/apps/web/src/pages/Pipeline.tsx
T
karti 2763531ce4
CI / verify (push) Successful in 2m53s
Align shadcn's accent token with what shadcn means by it
shadcn uses `bg-accent` for its SUBTLE surfaces — dropdown item hover, command
row selection, ghost and outline button hover, the dialog close affordance. The
brand colour in shadcn is `primary`.

PIG's Tailwind config mapped `accent` to `--accent`, which is the brand. That
inverted the meaning, so every shadcn hover and selection state painted a
full-strength brand block. With the monochrome "pig" palette in dark mode the
brand is near-white, so a selected command row rendered as a white slab against
a near-black sheet. Measured before the change: selected row rgb(250,250,250)
on a rgb(9,9,11) body.

`accent` now aliases `--accent-subtle` and `accent-foreground` aliases
`--accent-fg`, which is what those tokens were created for. The eleven places
where PIG's own components wanted a solid brand fill — filled chips, selected
card borders, progress bars — move to `primary`, which still resolves to
`--accent`. A `brand` alias is added for clarity.

After: selected row rgb(39,39,42) in dark and rgb(244,244,245) in light, both a
subtle tint above the body; the pipeline's active stage chip stays a solid
rgb(250,250,250) fill, unchanged.

Found by opening overlays, which earlier screenshot sweeps never did — every
route had been checked, but a dropdown or a command palette only misbehaves
once it is open. Worth remembering: page-level sweeps do not exercise portals.

Typecheck clean, 135 unit tests and e2e green, CSP hash unchanged, 0px
horizontal overflow across 12 routes at 393px and 1440px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:17:49 -07:00

264 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The pipeline boards, for both sides of the market.
*
* A column-per-stage board on desktop; on a phone, a stage picker and a single
* column. A horizontally scrolling eight-column board on a 390px screen is
* technically responsive and practically unusable — you cannot see where a
* card is going, which is the entire point of a board.
*/
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { PermissionGrant } from '@pig/core';
import { Pencil, Plus } from 'lucide-react';
import { get, money, relativeTime } from '@/lib/api';
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
import { usePageTitle } from '@/lib/title';
import { can } from '@/lib/permissions';
import {
DemandDealSheet,
SupplyDealSheet,
type DemandDealRecord,
type SupplyDealRecord,
} from '@/components/RecordSheets';
interface Board<T> {
stages: string[];
deals: { deal: T; accountName: string | null }[];
}
const STAGE_LABELS: Record<string, string> = {
qualification: 'Qualification',
legal: 'Legal',
scoping: 'Scoping',
proposal: 'Proposal',
procurement: 'Procurement',
poc: 'POC',
deployment: 'Deployment',
expansion: 'Expansion',
closed_won: 'Closed won',
closed_lost: 'Closed lost',
sourced: 'Sourced',
qualifying: 'Qualifying',
technical_diligence: 'Technical diligence',
financial_diligence: 'Financial diligence',
pricing: 'Pricing',
contracting: 'Contracting',
onboarding: 'Onboarding',
live: 'Live',
renewal: 'Renewal',
churned: 'Churned',
rejected: 'Rejected',
};
export function DemandPipeline() {
return (
<PipelineBoard<DemandDealRecord>
title="Demand"
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
endpoint="/api/deals/demand"
team="demand"
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
renderCard={(deal, accountName) => (
<>
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.acvCents ? (
<span className="nums text-sm font-semibold">{money(deal.acvCents)}</span>
) : null}
<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>
{/* Contract state is surfaced on the card because shipping capacity
without executed paper is the mistake this pipeline prevents. */}
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
</div>
</>
)}
/>
);
}
export function SupplyPipeline() {
return (
<PipelineBoard<SupplyDealRecord>
title="Supply"
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
endpoint="/api/deals/supply"
team="supply"
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
renderCard={(deal, accountName) => (
<>
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.gpuCount && deal.gpuType ? (
<Badge tone="accent">
{deal.gpuCount}× {deal.gpuType}
</Badge>
) : null}
{deal.targetCostPerGpuHourCents ? (
<span className="nums text-xs text-muted">
{money(deal.targetCostPerGpuHourCents)}/hr target
</span>
) : null}
</div>
</>
)}
/>
);
}
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({
title,
subtitle,
endpoint,
team,
renderCard,
renderSheet,
}: {
title: string;
subtitle: string;
endpoint: string;
team: 'supply' | 'demand';
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode;
}) {
usePageTitle(title);
const { data, isLoading } = useQuery({
queryKey: [endpoint],
queryFn: () => get<Board<T>>(endpoint),
});
const { data: me } = useQuery({
queryKey: ['me'],
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
});
const writable = can(me, 'deal:write', team);
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
const [activeStage, setActiveStage] = useState<string | null>(null);
const byStage = useMemo(() => {
const map = new Map<string, { deal: T; accountName: string | null }[]>();
for (const stage of data?.stages ?? []) map.set(stage, []);
for (const row of data?.deals ?? []) {
map.get(row.deal.stage)?.push(row);
}
return map;
}, [data]);
if (isLoading) return <Skeleton className="h-96" />;
if (!data || data.deals.length === 0) {
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
<Card>
<EmptyState
title={`No ${title.toLowerCase()} deals yet`}
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>}
/>
</Card>
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
</div>
);
}
const stages = data.stages;
const currentStage = activeStage ?? stages[0]!;
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
{/* Phone: pick one stage. The chips scroll; the board does not. */}
<div className="lg:hidden">
<div className="scroll-x -mx-4 flex gap-2 px-4 pb-1">
{stages.map((stage) => {
const count = byStage.get(stage)?.length ?? 0;
return (
<button
key={stage}
onClick={() => setActiveStage(stage)}
className={[
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
stage === currentStage
? 'bg-primary text-accent-on'
: 'bg-surface-2 text-muted',
].join(' ')}
>
{STAGE_LABELS[stage] ?? stage}
<span className="ml-1.5 opacity-70">{count}</span>
</button>
);
})}
</div>
<div className="mt-3 space-y-2">
{(byStage.get(currentStage) ?? []).map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
))}
{(byStage.get(currentStage) ?? []).length === 0 ? (
<p className="py-8 text-center text-sm text-muted">
Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.
</p>
) : null}
</div>
</div>
{/* Desktop: the full board, scrolling horizontally within its own pane
so the page itself never scrolls sideways. */}
<div className="scroll-x hidden lg:block">
<div className="flex gap-3 pb-2">
{stages.map((stage) => {
const rows = byStage.get(stage) ?? [];
return (
<section key={stage} className="w-72 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<h2 className="text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2>
<span className="nums text-xs text-muted">{rows.length}</span>
</div>
<div className="space-y-2">
{rows.map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
))}
</div>
</section>
);
})}
</div>
</div>
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
</div>
);
}
function DealCard<T extends { id: string; updatedAt: string }>({
row,
renderCard,
writable,
onEdit,
}: {
row: { deal: T; accountName: string | null };
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
writable: boolean;
onEdit(): void;
}) {
return (
<article className="card relative p-3 pr-12">
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title="Edit deal"><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
{renderCard(row.deal, row.accountName)}
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
</article>
);
}
function Header({ title, subtitle, writable, onCreate }: { title: string; subtitle: string; writable: boolean; onCreate(): void }) {
return (
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
<Button variant="primary" disabled={!writable} onClick={onCreate}><Plus aria-hidden />New deal</Button>
</header>
);
}