/** * Capacity — availability, and the matcher. * * The matcher is the screen that justifies the product: given what a customer * wants, what have we already bought that could serve them, and would selling * it make money? No generic CRM can answer either half. */ import { useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import type { PermissionGrant } from '@pig/core'; import { Search, Server, ShieldCheck, Zap } from 'lucide-react'; import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api'; import { usePageTitle } from '@/lib/title'; import { can } from '@/lib/permissions'; import { AllocationSheet, type AvailabilityRow, type MatchRow, } from '@/components/AllocationSheet'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input, Skeleton, } from '@/components/ui'; export function Capacity() { usePageTitle('Capacity'); const [tab, setTab] = useState<'available' | 'match'>('available'); const [allocation, setAllocation] = useState<{ open: boolean; preferredCommitmentId?: string; matches?: MatchRow[]; defaultGpuHours?: number; }>({ open: false }); const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'), }); const writable = can(me, 'deal:write', 'demand'); return (

Capacity

What we hold, what is sold, and what is still sellable.

{/* A segmented control rather than tabs — it reads correctly at phone width, where a tab row would either wrap or scroll. */}
{(['available', 'match'] as const).map((value) => ( ))}
{tab === 'available' ? ( setAllocation({ open: true, preferredCommitmentId }) } /> ) : ( setAllocation({ open: true, preferredCommitmentId, matches, defaultGpuHours }) } /> )} setAllocation((state) => ({ ...state, open }))} />
); } function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) { const { data, isLoading } = useQuery({ queryKey: ['availability'], queryFn: () => get('/api/capacity/availability'), }); if (isLoading) return ; if (!data || data.length === 0) { return ( } title="No live capacity commitments" description="Once you record what capacity you have committed to buy, this view shows how much of each block is sold, held, and still available." /> ); } return (
{data.map((row) => ( onAllocate(row.commitmentId)} /> ))}
); } function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; writable: boolean; onAllocate(): void }) { const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0; const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0; return ( /* * `min-w-0` is load-bearing. A grid item defaults to `min-width: auto`, * which means it refuses to shrink below its content — and `truncate` sets * `white-space: nowrap`, so a long title becomes unshrinkable content and * widens the whole track. The result is a page that scrolls sideways on a * phone. This is the fix, and it is needed on every grid child that * truncates. */
{row.name} {row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}

{row.gpuCount}× {row.gpuType} · {row.interconnectType} ·{' '} {shortDate(row.startsAt)}–{shortDate(row.endsAt)}

{/* Sold and held are shown as separate segments, because a full-looking bar made mostly of unconverted holds is a lie a seller would act on. */}
{percent(soldPct)} sold {row.heldGpuHours > 0 ? {percent(heldPct)} held : null} {compactNumber(row.availableGpuHours)} hrs free
Cost
{money(row.costPerGpuHourCents)}/hr
Break even
{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${money(row.breakEvenPriceCents)}/hr`}
); } function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: string, matches: MatchRow[], defaultGpuHours?: number): void }) { const [form, setForm] = useState({ gpuType: '', gpuCount: '64', totalGpuHours: '', startsAt: '', endsAt: '', requiresHighSpeedInterconnect: true, maxPrice: '', }); const mutation = useMutation({ mutationFn: () => post('/api/capacity/match', { gpuType: form.gpuType || undefined, gpuCount: Number(form.gpuCount) || 1, totalGpuHours: form.totalGpuHours ? Number(form.totalGpuHours) : undefined, startsAt: form.startsAt ? new Date(`${form.startsAt}T00:00:00`).toISOString() : undefined, endsAt: form.endsAt ? new Date(`${form.endsAt}T23:59:59`).toISOString() : undefined, requiresHighSpeedInterconnect: form.requiresHighSpeedInterconnect, maxPricePerGpuHourCents: form.maxPrice ? Math.round(Number(form.maxPrice) * 100) : undefined, }), }); return (
What does the customer need?
{ event.preventDefault(); mutation.mutate(); }} > setForm({ ...form, gpuType: e.target.value })} placeholder="H100_80GB" // Hardware identifiers are case-sensitive upstream; correcting // them for the user would produce silent mismatches. autoCapitalize="off" autoCorrect="off" spellCheck={false} /> setForm({ ...form, gpuCount: e.target.value })} // A numeric keypad on phones, without the spinner arrows and // scroll-to-change behaviour of type="number". inputMode="numeric" pattern="[0-9]*" /> setForm({ ...form, totalGpuHours: e.target.value })} inputMode="numeric" placeholder="Optional" /> setForm({ ...form, maxPrice: e.target.value })} inputMode="decimal" placeholder="Optional" /> setForm({ ...form, startsAt: e.target.value })} /> setForm({ ...form, endsAt: e.target.value })} />
{mutation.isSuccess ? ( mutation.data.length === 0 ? ( } title="Nothing on the book fits" description="No committed capacity matches. Search provider inventory to find capacity to buy, which would mean opening a supply deal." /> ) : (
{mutation.data.map((match) => (

{match.name}

{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '} {compactNumber(match.availableGpuHours)} hrs free

0.7 ? 'positive' : 'neutral'}> {percent(match.score)} fit
    {match.rationale.map((reason, i) => (
  • {reason}
  • ))}

{shortDate(match.startsAt)}–{shortDate(match.endsAt)} · {money(match.breakEvenPriceCents)}/hr break even

))}
) ) : null} {mutation.isError ? (

{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}

) : null}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( ); }