18d5f5bfc0
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace around it. The layout was already right — the audit found the approval card to be the best-designed object in the repo, and the account page's empty panels less finished than anything in the workspace. What was wrong was vocabulary: nobody had written the small things down, so both halves kept inventing them. Piggy was drawn with five different marks — a pig in the dock, a sparkle in the sidebar and again on the model picker, a speech bubble on the Ask buttons, and a stock robot glyph on every assistant message, which is the one people look at most. There is now one mark. The composer, which is the first control in the product since sign-in lands on /piggy, was the only un-adapted shadcn field left: 6px radius against a 12px Send button it sat 8px from. A stat tile had been reinvented six times at three numeral scales, and the same uppercase micro-label existed in five variants, two of them one tab apart in the same rail. There were 63 hand-written font sizes: not a scale, sixty-three opinions. Underneath that, the focus ring was invisible. The global rule used ring-accent, which Tailwind deliberately aliases onto the hover tint, so the ring measured 1.01:1 against the light canvas — no visible focus indicator anywhere in the product, for any accent, in either theme. It is ring-brand now and measures 17:1. The warning, positive and info tones were darkened until each clears 4.5:1 on a card, on inset and on its own chip, and the light canvas moved to 98% so a card lifts without leaning on its shadow. The mobile work is the part worth reading. A landscape phone gave the transcript 28% of the viewport and a keyboard-up phone 16%, against a 45% floor — and the fixed tab bar painted over the composer, covering the safety sentence and half the Send button, because two source comments asserted the bar stood down on short viewports and it never had. Both fixed and measured by hit-testing rather than by screenshot. The composer itself was 64px tall for a blank second line nobody typed, because the auto-resize effect sizes to scrollHeight and scrollHeight counts rows — a CSS height could not win against an inline style, so the attribute was the honest lever. Verified across both themes driven through the app's own control: no horizontal overflow on 15 routes at four viewports, 672 stat values that fit, 297 labels at exactly 11px/500, Escape returning focus to its opener rather than the body on every overlay, and a rejected write no longer reporting "Succeeded" with a green check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
16 KiB
TypeScript
181 lines
16 KiB
TypeScript
/**
|
||
* 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, Label, Skeleton, Stat, cn } from '@/components/ui';
|
||
import { FormField } from '@/components/ui/form-field';
|
||
import { PageHeader } from '@/components/ui/page-header';
|
||
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<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" 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 }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||
renderCard={(deal) => <div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.acvCents != null ? <span className="nums text-sm font-semibold">{money(deal.acvCents)}</span> : null}<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>{/* Paper state prevents delivery readiness from being inferred from stage. */}{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" 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 }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||
renderCard={(deal) => <div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.gpuCount != null && deal.gpuType ? <Badge tone="accent">{deal.gpuCount}× {deal.gpuType}</Badge> : 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 ? <span className="nums text-xs text-muted">{unitPrice(deal.targetCostPerGpuHourCents)}/GPU-hr target</span> : null}</div>}
|
||
/>;
|
||
}
|
||
|
||
function PipelineBoard<T extends { id: string; name: string; stage: string; updatedAt: string }>({ 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<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 [focusedId, setFocusedId] = useState<string | null>(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<string, { deal: T; accountName: string | null }[]>();
|
||
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 = <Header
|
||
title={title} orientation={orientation} subtitle={subtitle} writable={writable}
|
||
onCreate={() => 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 <div className="space-y-6">{header}<Skeleton className="h-96" /></div>;
|
||
if (boardQuery.isError) return <div className="space-y-6">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
|
||
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-6">{header}<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>{sheetNode}</div>;
|
||
|
||
const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id));
|
||
|
||
return <div className="space-y-6">
|
||
{header}
|
||
<section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
|
||
<label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
|
||
<Stat size="sm" surface="bare" className="min-w-[7rem] rounded-md bg-surface px-3 py-2" label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><Stat size="sm" surface="bare" className="min-w-[7rem] rounded-md bg-surface px-3 py-2" label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
|
||
</section>
|
||
<div className="lg:hidden"><FormField label="Focus stage"><select id={`${team}-stage`} className="h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select></FormField><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
|
||
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-xs text-muted">{index + 1}</span><Label as="h2" id={`${team}-${stage}`} className="truncate">{STAGE_LABELS[stage] ?? stage}</Label></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
|
||
{sheetNode}
|
||
</div>;
|
||
}
|
||
|
||
/**
|
||
* 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<T extends { id: string; name: string; updatedAt: string }>({ 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 }) {
|
||
// The focus ring is the brand across the product, so a selected card is
|
||
// marked the same way rather than in a colour that means something else.
|
||
return <article className={cn('card relative min-w-0 p-3 pr-12', focused && 'ring-2 ring-brand')}>
|
||
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
|
||
<button
|
||
type="button"
|
||
aria-pressed={focused}
|
||
onClick={onFocus}
|
||
title={focused ? 'Stop pointing Piggy at this deal' : 'Point Piggy at this deal'}
|
||
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors duration-1 ease-enter hover:bg-surface-2"
|
||
>
|
||
<span className="block truncate font-medium">{row.deal.name}</span>
|
||
<span className="block truncate text-xs text-muted">{row.accountName ?? 'No account'}</span>
|
||
<span className="sr-only">{focused ? 'Piggy is looking at this deal' : 'Point Piggy at this deal'}</span>
|
||
</button>
|
||
{renderCard(row.deal, row.accountName)}
|
||
<p className="mt-2 text-xs text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
|
||
</article>;
|
||
}
|
||
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) {
|
||
return <EmptyState
|
||
size={compact ? 'inline' : 'panel'}
|
||
className={compact ? 'rounded-lg border border-dashed border-border' : undefined}
|
||
title={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 <PageHeader
|
||
title={<span className="flex flex-wrap items-center gap-2">{title}<Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></span>}
|
||
description={subtitle}
|
||
actions={<Button variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>}
|
||
/*
|
||
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.
|
||
*/
|
||
ask={<PiggyAskButton label={askLabel} prompt={askPrompt} />}
|
||
/>;
|
||
}
|