/** * 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 { 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" 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 }) => } renderCard={(deal, accountName) => ( <>

{deal.name}

{accountName ?? 'No account'}

{deal.acvCents ? ( {money(deal.acvCents)} ) : null} {deal.productLine.replace(/_/g, ' ')} {/* Contract state is surfaced on the card because shipping capacity without executed paper is the mistake this pipeline prevents. */} {deal.msaExecuted ? MSA : null} {deal.dpaExecuted ? DPA : null}
)} /> ); } export function SupplyPipeline() { return ( 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 }) => } renderCard={(deal, accountName) => ( <>

{deal.name}

{accountName ?? 'No account'}

{deal.gpuCount && deal.gpuType ? ( {deal.gpuCount}× {deal.gpuType} ) : null} {deal.targetCostPerGpuHourCents ? ( {money(deal.targetCostPerGpuHourCents)}/hr target ) : null}
)} /> ); } function PipelineBoard({ 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>(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 byStage = useMemo(() => { const map = new Map(); 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 ; if (!data || data.deals.length === 0) { return (
setSheet({ open: true })} /> setSheet({ open: true })}>New {title.toLowerCase()} deal} /> {renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
); } const stages = data.stages; const currentStage = activeStage ?? stages[0]!; return (
setSheet({ open: true })} /> {/* Phone: pick one stage. The chips scroll; the board does not. */}
{stages.map((stage) => { const count = byStage.get(stage)?.length ?? 0; return ( ); })}
{(byStage.get(currentStage) ?? []).map((row) => ( setSheet({ open: true, record: row.deal })} /> ))} {(byStage.get(currentStage) ?? []).length === 0 ? (

Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.

) : null}
{/* Desktop: the full board, scrolling horizontally within its own pane so the page itself never scrolls sideways. */}
{stages.map((stage) => { const rows = byStage.get(stage) ?? []; return (

{STAGE_LABELS[stage] ?? stage}

{rows.length}
{rows.map((row) => ( setSheet({ open: true, record: row.deal })} /> ))}
); })}
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
); } function DealCard({ row, renderCard, writable, onEdit, }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; onEdit(): void; }) { return (
{renderCard(row.deal, row.accountName)}

{relativeTime(row.deal.updatedAt)}

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

{title}

{subtitle}

); }