/** * The pipeline boards, for both sides of the market. * * Stages remain ordered, but wrap into a scanable desktop grid instead of * hiding the back half of the funnel behind a multi-screen horizontal rail. * * Each card can also put itself in front of Piggy. A board of thirteen deals * docked next to an agent that only knows it is "on /demand" answers every * question from stage totals, so the card carries a focus control and the page * publishes that deal as the ambient context while it is held. */ import { useDeferredValue, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { PermissionGrant } from '@pig/core'; import { Pencil, Plus, RefreshCw, Search } from 'lucide-react'; import { get, money, relativeTime, unitPrice } from '@/lib/api'; import { PiggyAskButton } from '@/components/PiggyChat'; import { Badge, Button, Card, EmptyState, Input, Skeleton, cn } from '@/components/ui'; import { usePageTitle } from '@/lib/title'; import { usePiggyContext } from '@/lib/piggy-context'; import { can } from '@/lib/permissions'; import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets'; interface Board { stages: string[]; deals: { deal: T; accountName: string | null }[]; } const STAGE_LABELS: Record = { 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 title="Demand" orientation="Sell-side" subtitle="Selling compute and post-training. Legal sits early because paper gates delivery rather than merely closing it." endpoint="/api/deals/demand" team="demand" searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.productLine}`} metricLabel="Visible ACV" metricValue={(deals) => money(deals.reduce((total, deal) => total + (deal.acvCents ?? 0), 0))} renderSheet={({ open, onOpenChange, record }) => } renderCard={(deal) =>
{deal.acvCents != null ? {money(deal.acvCents)} : null}{deal.productLine.replace(/_/g, ' ')}{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? MSA : null}{deal.dpaExecuted ? DPA : null}
} />; } export function SupplyPipeline() { return title="Supply" orientation="Buy-side" subtitle="Sourcing GPU capacity. Technical and financial diligence remain separate gates because accepting capacity is a two-key decision." endpoint="/api/deals/supply" team="supply" searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.gpuType ?? ''}`} metricLabel="GPU opportunity" metricValue={(deals) => `${deals.reduce((total, deal) => total + (deal.gpuCount ?? 0), 0).toLocaleString()} GPUs`} renderSheet={({ open, onOpenChange, record }) => } renderCard={(deal) =>
{deal.gpuCount != null && deal.gpuType ? {deal.gpuCount}× {deal.gpuType} : null}{/* A per-GPU-hour price goes through `unitPrice`, never `money`: at $1.60 the cents are the number, and `money` drops them when they happen to be round. */}{deal.targetCostPerGpuHourCents != null ? {unitPrice(deal.targetCostPerGpuHourCents)}/GPU-hr target : null}
} />; } function PipelineBoard({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: { title: string; orientation: string; subtitle: string; endpoint: string; team: 'supply' | 'demand'; searchText: (deal: T, accountName: string | null) => string; metricLabel: string; metricValue: (deals: T[]) => string; renderCard: (deal: T, accountName: string | null) => React.ReactNode; renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode; }) { usePageTitle(title); const boardQuery = useQuery({ queryKey: [endpoint], queryFn: () => get>(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(null); const [focusedId, setFocusedId] = useState(null); const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()); const filteredDeals = useMemo(() => !deferredQuery ? boardQuery.data?.deals ?? [] : (boardQuery.data?.deals ?? []).filter((row) => searchText(row.deal, row.accountName).toLocaleLowerCase().includes(deferredQuery)), [boardQuery.data?.deals, deferredQuery, searchText]); const byStage = useMemo(() => { const map = new Map(); for (const stage of boardQuery.data?.stages ?? []) map.set(stage, []); for (const row of filteredDeals) map.get(row.deal.stage)?.push(row); return map; }, [boardQuery.data?.stages, filteredDeals]); const stages = boardQuery.data?.stages ?? []; const populatedStage = stages.find((stage) => (byStage.get(stage)?.length ?? 0) > 0); const currentStage = activeStage && stages.includes(activeStage) ? activeStage : populatedStage ?? stages[0] ?? ''; const activeStageCount = stages.filter((stage) => (byStage.get(stage)?.length ?? 0) > 0).length; const sheetNode = renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record }); // What Piggy is looking at while this board is open: the deal the user put in // focus, else the board itself. Resolved against the current rows rather than // stored alongside the id, so a deal that leaves the board on a refetch // returns the dock to the page instead of holding a card nobody can see. const focusedDeal = (boardQuery.data?.deals ?? []).find((row) => row.deal.id === focusedId)?.deal; usePiggyContext( focusedDeal ? { type: team === 'demand' ? 'demand_deal' : 'supply_deal', id: focusedDeal.id, label: focusedDeal.name } : { type: 'page', route: team === 'demand' ? '/demand' : '/supply', label: `${title} pipeline` }, ); // Built once: the four returns below all render it, and a header assembled // separately in each is a header that ends up different in the error state. const header =
setSheet({ open: true })} askLabel={focusedDeal ? 'Ask about this deal' : 'Ask Piggy'} askPrompt={focusedDeal ? (team === 'demand' ? 'Are the hours behind this deal actually booked, and is it going to close when it says it will?' : 'How many GPU-hours would this add, at what cost per hour, and what is still outstanding before we can sign it?') : (team === 'demand' ? 'Which open deal is worth the most, and when is it meant to land?' : 'Are we lining up more capacity than the demand side can absorb?')} />; if (boardQuery.isLoading) return
{header}
; if (boardQuery.isError) return
{header} void boardQuery.refetch()}>Try again} />{sheetNode}
; if (!boardQuery.data || boardQuery.data.deals.length === 0) return
{header} setSheet({ open: true })}>New {title.toLowerCase()} deal} />{sheetNode}
; const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id)); return
{header}
row.deal))} />
{(byStage.get(currentStage) ?? []).map((row) => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? : null}

{activeStageCount} of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work

Stage order runs left to right, then down.

{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return
{index + 1}

{STAGE_LABELS[stage] ?? stage}

{rows.length}
{rows.map((row) => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? : null}
; })}
{sheetNode}
; } /** * One deal on the board. * * The name and account are drawn here rather than by `renderCard` because they * are the control that points Piggy at this deal, and a second icon button * beside the pencil would have cost the title another 44px of a card that is * already a quarter of a column wide — the board would have been asking which * matters more, reading the deal or asking about it. */ function DealCard({ row, renderCard, writable, focused, onEdit, onFocus }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; focused: boolean; onEdit(): void; onFocus(): void }) { // `ring-brand`, not `ring-accent`: in this Tailwind config `accent` is // shadcn's subtle surface, so a ring drawn in it is invisible against the // card. The brand is the monochrome that inverts with the theme. return
{renderCard(row.deal, row.accountName)}

Updated {relativeTime(row.deal.updatedAt)}

; } function PipelineStat({ label, value }: { label: string; value: string }) { return

{label}

{value}

; } function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return

{filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}

; } function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) { return

{title}

{orientation}

{subtitle}

{/* Reversed above `sm` rather than reordered: stacked on a phone the primary action has to come first, and on a wide header the same button belongs at the right edge where it has always been. */}
{/* No `context` prop on purpose: this asks about whatever the board has published, which is the focused deal when there is one. Passing the page here would pin it to the board and quietly ignore the card the user just put in focus. */}
; }