2763531ce4
CI / verify (push) Successful in 2m53s
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>
264 lines
9.9 KiB
TypeScript
264 lines
9.9 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|