diff --git a/apps/web/package.json b/apps/web/package.json index 9a59f5d..5ac3159 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@fontsource-variable/manrope": "^5.3.0", "@hookform/resolvers": "^5.7.1", "@pig/core": "workspace:*", "@radix-ui/react-avatar": "^1.2.6", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 726e741..6ff60b6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { lazy, Suspense, useEffect, useState } from 'react'; import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api'; import { ThemeProvider } from '@/lib/theme'; import { Shell } from '@/components/Shell'; @@ -11,7 +12,8 @@ import { SignIn } from '@/pages/SignIn'; import { CreateProfile } from '@/pages/CreateProfile'; import { Register } from '@/pages/Register'; import { PiggyMark } from '@/components/PiggyMark'; -import { EmptyState } from '@/components/ui'; +import { Badge, Card, EmptyState, Skeleton } from '@/components/ui'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Toaster } from '@/components/ui/sonner'; import { usePageTitle } from '@/lib/title'; @@ -230,14 +232,14 @@ function Placeholder({ title }: { title: string }) { return ( ); } function Team() { usePageTitle('Team'); - const { data } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['team'], queryFn: () => get< @@ -251,30 +253,101 @@ function Team() { >('/api/team'), }); + const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0); + const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size; + return ( -
-
-

Team

-

Supply, demand and research.

+
+
+
+

Access map

+

Team

+

+ See who can operate each side of the compute business and where ownership is thin. +

+
+ + Manage access in Settings +
-
- {(data ?? []).map((person) => ( -
-

{person.name}

- {person.title ?

{person.title}

: null} -
- {person.teams.map((t) => ( - - {t.team} - - ))} -
-
+ +
+ {[ + ['People', data?.length ?? 0], + ['Teams', representedTeams], + ['Assignments', assignments], + ].map(([label, value]) => ( + +

{label}

+

{value}

+
))}
+ + {error ? ( + + + + ) : null} + + {isLoading ? ( +
+ {[0, 1, 2].map((key) => )} +
+ ) : null} + + {!isLoading && !error && data?.length === 0 ? ( + + + + ) : null} + + {!isLoading && !error && data?.length ? ( +
+
+

People and permissions

+ Roles are enforced server-side +
+
+ {data.map((person) => { + const initials = person.name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(''); + return ( + +
+ + + {initials || 'P'} + + +
+

{person.name}

+

{person.title || 'Team member'}

+
+
+
+ {person.teams.map((membership) => ( + + {membership.team} · {membership.role} + + ))} +
+ {person.teams.length === 0 ? ( +

No operational team assigned

+ ) : null} +
+ ); + })} +
+
+ ) : null}
); } diff --git a/apps/web/src/components/AllocationSheet.tsx b/apps/web/src/components/AllocationSheet.tsx index c811951..b360d13 100644 --- a/apps/web/src/components/AllocationSheet.tsx +++ b/apps/web/src/components/AllocationSheet.tsx @@ -198,7 +198,7 @@ export function AllocationSheet({ resolver: zodResolver(formSchema), defaultValues: defaults(preferredCommitmentId, defaultGpuHours), }); - const { data: availability, isLoading: availabilityLoading } = useQuery({ + const { data: availability, isLoading: availabilityLoading, error: availabilityError } = useQuery({ queryKey: ['availability'], queryFn: () => get('/api/capacity/availability'), enabled: open, @@ -208,7 +208,7 @@ export function AllocationSheet({ queryFn: () => get('/api/commitments'), enabled: open, }); - const { data: demand } = useQuery({ + const { data: demand, isLoading: demandLoading, error: demandError } = useQuery({ queryKey: ['/api/deals/demand'], queryFn: () => get('/api/deals/demand'), enabled: open, @@ -309,7 +309,7 @@ export function AllocationSheet({ description: values.kind === 'hold' ? `${hours} GPU-hours reserved. The hold releases automatically when it expires.` - : `${hours} GPU-hours committed. Margin and utilisation have been updated.`, + : `${hours} GPU-hours committed. Margin and sold-capacity reporting have been updated.`, }, ); }, @@ -344,7 +344,7 @@ export function AllocationSheet({ return ( - + Reserve capacity @@ -358,13 +358,14 @@ export function AllocationSheet({ className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))} > -
+
{(['allocation', 'hold'] as const).map((value) => (
+ {availabilityError || demandError ? : null} +
Demand deal setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" /> + setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" /> {files.isLoading ? : files.isError ? : files.data?.files.length === 0 ? : (
{files.data?.files.map((file) => ( - @@ -174,7 +174,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT {metadata.isError ?
: null} {selectedSheet ?

Selected grid: {selectedSheet.rowCount} rows × {selectedSheet.columnCount} columns. Range limits are enforced again by the server.

: null} diff --git a/apps/web/src/components/PiggyChat.tsx b/apps/web/src/components/PiggyChat.tsx index 8afae66..78cd660 100644 --- a/apps/web/src/components/PiggyChat.tsx +++ b/apps/web/src/components/PiggyChat.tsx @@ -102,7 +102,7 @@ export function PiggyChatWorkspace() { description={ status.data?.enabled ? 'This credential does not have read access.' - : 'An administrator must enable Piggy and connect the internal service.' + : 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.' } /> ); @@ -216,7 +216,7 @@ function PiggyChatPanel({

What should we inspect?

-

Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access.

+

Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.

{(context ? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?'] @@ -256,7 +256,7 @@ function PiggyChatPanel({ )}
-

Check source records before acting on material terms.

+

Read-only session · Check source records before acting on material terms.

); diff --git a/apps/web/src/components/RecordSheets.tsx b/apps/web/src/components/RecordSheets.tsx index df34d5a..ff99136 100644 --- a/apps/web/src/components/RecordSheets.tsx +++ b/apps/web/src/components/RecordSheets.tsx @@ -18,7 +18,7 @@ import { LoaderCircle } from 'lucide-react'; import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; -import { Input } from '@/components/ui'; +import { Badge, Input } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Form, @@ -306,7 +306,7 @@ export function AccountSheet({ open, onOpenChange, record, identity }: SheetProp const side = form.watch('side'); return ( - +
save.mutate(values))}> @@ -395,7 +395,7 @@ export function ContactSheet({ open, onOpenChange, record, identity, defaultAcco }); return ( - + save.mutate(values))}> @@ -469,7 +469,7 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps + save.mutate(values))}> @@ -545,7 +545,7 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps + save.mutate(values))}> @@ -582,11 +582,12 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps - - + + + {category} {title} {description} @@ -598,7 +599,7 @@ function RecordSheet({ open, onOpenChange, title, description, children }: { ope } function SheetBody({ children }: { children: React.ReactNode }) { - return
{children}
; + return
{children}
; } function SheetActions({ pending, onCancel, label: actionLabel }: { pending: boolean; onCancel(): void; label: string }) { @@ -622,7 +623,7 @@ function FieldGrid({ children }: { children: React.ReactNode }) { function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { return ( -
+

{title}

{description ?

{description}

: null} diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx index 992e856..99a4342 100644 --- a/apps/web/src/components/Shell.tsx +++ b/apps/web/src/components/Shell.tsx @@ -36,24 +36,27 @@ import { Button, cn } from './ui'; interface NavItem extends CommandDestination { /** Shown in the phone tab bar. Space there is scarce, so only five fit. */ primary?: boolean; + group: 'Intelligence' | 'Marketplace' | 'Records' | 'Control'; } const NAV: NavItem[] = [ - { to: '/', label: 'Overview', icon: LayoutDashboard, primary: true }, - { to: '/growth', label: 'Growth', icon: Target }, - { to: '/piggy', label: 'Piggy', icon: MessageCircleMore }, - { to: '/margin', label: 'Margin', icon: TrendingUp, primary: true }, - { to: '/capacity', label: 'Capacity', icon: Server, primary: true }, - { to: '/demand', label: 'Demand', icon: Building2, primary: true }, - { to: '/supply', label: 'Supply', icon: Boxes, primary: true }, - { to: '/accounts', label: 'Accounts', icon: Building2 }, - { to: '/contracts', label: 'Contracts', icon: FileText }, - { to: '/imports', label: 'Import', icon: FileSpreadsheet }, - { to: '/team', label: 'Team', icon: Users }, - { to: '/facts', label: 'Fact review', icon: ShieldCheck }, - { to: '/settings', label: 'Settings', icon: Settings }, + { to: '/', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true }, + { to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' }, + { to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' }, + { to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true }, + { to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true }, + { to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true }, + { to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true }, + { to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' }, + { to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' }, + { to: '/imports', label: 'Import', icon: FileSpreadsheet, group: 'Records' }, + { to: '/team', label: 'Team', icon: Users, group: 'Control' }, + { to: '/facts', label: 'Fact review', icon: ShieldCheck, group: 'Control' }, + { to: '/settings', label: 'Settings', icon: Settings, group: 'Control' }, ]; +const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const; + export function Shell() { const location = useLocation(); const [commandOpen, setCommandOpen] = useState(false); @@ -72,36 +75,45 @@ export function Shell() { }, []); return ( -
+
{/* ------------------------------------------------- desktop sidebar */} @@ -128,7 +141,7 @@ export function Shell() { 'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border', // A translucent bar with a blur reads as native on iOS; the opaque // fallback keeps text legible where backdrop-filter is unsupported. - 'bg-surface/85 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-surface/70 lg:hidden', + 'bg-surface/90 px-4 backdrop-blur-xl supports-[backdrop-filter]:bg-surface/75 lg:hidden', 'pt-[var(--safe-top)]', )} style={{ height: 'calc(3.5rem + var(--safe-top))' }} @@ -166,7 +179,7 @@ export function Shell() { {/* ------------------------------------------------- mobile tab bar */}
diff --git a/apps/web/src/components/SourcedValue.tsx b/apps/web/src/components/SourcedValue.tsx index 6318c4a..bbf4769 100644 --- a/apps/web/src/components/SourcedValue.tsx +++ b/apps/web/src/components/SourcedValue.tsx @@ -1,5 +1,5 @@ import type { FactBand, FactStatus } from '@pig/core'; -import { ExternalLink, Link2, ScanSearch } from 'lucide-react'; +import { Clock3, ExternalLink, Link2, ScanSearch } from 'lucide-react'; import type { ReactNode } from 'react'; import { Badge } from '@/components/ui'; import { @@ -66,6 +66,9 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) { const summary = evidenceSummary(fact.evidence); const score = Number(fact.score); const confidence = Number.isFinite(score) ? `${Math.round(score * 100)}%` : 'Not scored'; + const observedDate = new Date(fact.observedAt); + const observedLabel = Number.isNaN(observedDate.getTime()) ? 'Observation date unavailable' : observedDate.toLocaleDateString(undefined, { dateStyle: 'medium' }); + const sourceHost = sourceUrl ? new URL(sourceUrl).hostname.replace(/^www\./, '') : null; return ( @@ -74,8 +77,8 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) { @@ -88,7 +91,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {

- {humanise(fact.field)} + Source evidence · {humanise(fact.field)}

{fact.value}

@@ -109,6 +112,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) { {humanise(fact.status)} {fact.method ? via {humanise(fact.method)} : null}
+
Observed
{sourceUrl ? ( - Open source + Open source{sourceHost ? ` · ${sourceHost}` : ''} ) : null} diff --git a/apps/web/src/components/ui/index.tsx b/apps/web/src/components/ui/index.tsx index 1822b97..2bbbda2 100644 --- a/apps/web/src/components/ui/index.tsx +++ b/apps/web/src/components/ui/index.tsx @@ -33,7 +33,7 @@ const buttonVariants = cva( { variants: { variant: { - primary: 'bg-accent text-accent-on hover:opacity-90 active:opacity-80', + primary: 'bg-primary text-primary-foreground shadow-sm hover:opacity-90 active:opacity-80', secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border', outline: 'border border-border bg-transparent hover:bg-surface-2', ghost: 'bg-transparent hover:bg-surface-2', @@ -41,7 +41,7 @@ const buttonVariants = cva( }, size: { // min-h keeps the target tappable even when the label is short. - sm: 'h-9 min-h-[36px] px-3 text-xs', + sm: 'h-11 min-h-[44px] px-3 text-xs', md: 'h-11 min-h-[44px] px-4', lg: 'h-12 min-h-[48px] px-6 text-base', icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0', @@ -113,10 +113,14 @@ export function CardContent({ className, ...props }: HTMLAttributes; } +export function CardFooter({ className, ...props }: HTMLAttributes) { + return
; +} + // -------------------------------------------------------------------- badge const badgeVariants = cva( - 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', + 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium', { variants: { tone: { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 07a29e7..db64368 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2,6 +2,14 @@ @tailwind components; @tailwind utilities; +@font-face { + font-family: 'Manrope Variable'; + font-style: normal; + font-display: swap; + font-weight: 200 800; + src: url('@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2') format('woff2-variations'); +} + /* * Surfaces and neutrals. * @@ -21,6 +29,7 @@ --warning: 32 95% 44%; --danger: 0 72% 45%; --info: 201 90% 40%; + --shadow: 240 10% 4%; /* Safe-area insets, so layout can reference them even at zero. */ --safe-top: env(safe-area-inset-top, 0px); @@ -41,6 +50,7 @@ --warning: 38 92% 60%; --danger: 0 84% 65%; --info: 199 89% 60%; + --shadow: 0 0% 0%; } @layer base { @@ -62,6 +72,11 @@ overscroll-behavior-y: none; } + ::selection { + background: hsl(var(--accent-subtle)); + color: hsl(var(--accent-fg)); + } + /* * Mobile Safari zooms the viewport when a focused input has a font size * below 16px, and never zooms back out. This is the fix — not @@ -117,7 +132,16 @@ } .card { - @apply rounded-xl border border-border bg-surface; + @apply rounded-2xl border border-border/90 bg-surface; + box-shadow: 0 1px 2px hsl(var(--shadow) / 0.035), 0 12px 36px hsl(var(--shadow) / 0.025); + } + + .app-canvas { + background-color: hsl(var(--bg)); + background-image: + radial-gradient(circle at 78% -12%, hsl(var(--accent-subtle) / 0.7), transparent 34rem), + linear-gradient(to bottom, hsl(var(--surface) / 0.25), transparent 28rem); + background-attachment: fixed; } /* Tabular figures keep money and percentages from jittering as they diff --git a/apps/web/src/pages/Accounts.tsx b/apps/web/src/pages/Accounts.tsx index 8d726ba..7af45e7 100644 --- a/apps/web/src/pages/Accounts.tsx +++ b/apps/web/src/pages/Accounts.tsx @@ -1,44 +1,44 @@ -import { useState } from 'react'; +import { useDeferredValue, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; import type { PermissionGrant } from '@pig/core'; -import { Pencil, Plus, UserPlus } from 'lucide-react'; +import { Building2, Mail, Pencil, Plus, RefreshCw, Search, UserPlus } from 'lucide-react'; import { AccountSheet, ContactSheet, type AccountRecord, type ContactRecord, type ContactRow } from '@/components/RecordSheets'; import { DataTable, DataTableColumnHeader } from '@/components/DataTable'; -import { Badge, Button, ConfidenceBadge, Skeleton } from '@/components/ui'; +import { Badge, Button, Card, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { get, relativeTime } from '@/lib/api'; import { can } from '@/lib/permissions'; import { usePageTitle } from '@/lib/title'; interface Me { permissions: PermissionGrant[] } +type View = 'accounts' | 'contacts'; export function Accounts() { usePageTitle('Accounts'); const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all'); - const [view, setView] = useState<'accounts' | 'contacts'>('accounts'); + const [view, setView] = useState('accounts'); + const [mobileSearch, setMobileSearch] = useState(''); + const deferredSearch = useDeferredValue(mobileSearch.trim().toLocaleLowerCase()); const [accountSheet, setAccountSheet] = useState<{ open: boolean; record?: AccountRecord }>({ open: false }); const [contactSheet, setContactSheet] = useState<{ open: boolean; record?: ContactRecord; accountId?: string }>({ open: false }); const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get('/api/me') }); - const { data: accountData, isLoading: accountsLoading } = useQuery({ - queryKey: ['accounts', side], - queryFn: () => get(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`), - }); - const { data: contactData, isLoading: contactsLoading } = useQuery({ - queryKey: ['contacts', 'table'], - queryFn: () => get('/api/contacts'), - }); + const accountsQuery = useQuery({ queryKey: ['accounts', side], queryFn: () => get(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`) }); + const contactsQuery = useQuery({ queryKey: ['contacts', 'table'], queryFn: () => get('/api/contacts') }); const canDemand = can(me, 'deal:write', 'demand'); const canSupply = can(me, 'deal:write', 'supply'); const canAny = canDemand || canSupply; const canAccount = (account: AccountRecord) => account.side === 'both' ? canAny : account.side === 'demand' ? canDemand : canSupply; + const canContact = (row: ContactRow) => row.accountSide === 'both' ? canAny : row.accountSide === 'demand' ? canDemand : row.accountSide === 'supply' ? canSupply : false; + const visibleAccounts = useMemo(() => !deferredSearch ? accountsQuery.data ?? [] : (accountsQuery.data ?? []).filter((account) => [account.name, account.domain, account.country, account.supplierType, account.customerSegment].some((value) => value?.toLocaleLowerCase().includes(deferredSearch))), [accountsQuery.data, deferredSearch]); + const visibleContacts = useMemo(() => !deferredSearch ? contactsQuery.data ?? [] : (contactsQuery.data ?? []).filter((row) => [row.contact.fullName, row.contact.email, row.contact.title, row.accountName].some((value) => value?.toLocaleLowerCase().includes(deferredSearch))), [contactsQuery.data, deferredSearch]); const accountColumns: ColumnDef[] = [ { id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => , cell: ({ row }) =>

{row.original.name}

{row.original.domain ?

{row.original.domain}

: null}
}, - { accessorKey: 'side', header: ({ column }) => , cell: ({ row }) => {row.original.side} }, - { id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => , cell: ({ row }) => { const type = row.original.supplierType ?? row.original.customerSegment; return type ? {type.replace(/_/g, ' ')} : '—'; } }, + { accessorKey: 'side', header: ({ column }) => , cell: ({ row }) => }, + { id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => , cell: ({ row }) => { const type = accountType(row.original); return type ? {type.replace(/_/g, ' ')} : '—'; } }, { accessorKey: 'country', header: ({ column }) => , cell: ({ row }) => row.original.country ?? '—' }, - { accessorKey: 'confidence', header: ({ column }) => , cell: ({ row }) => row.original.confidence === 'confirmed' ? Confirmed : }, + { accessorKey: 'confidence', header: ({ column }) => , cell: ({ row }) => }, { accessorKey: 'lastActivityAt', header: ({ column }) => , cell: ({ row }) => row.original.lastActivityAt ? relativeTime(row.original.lastActivityAt) : '—' }, { id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) =>
}, ]; @@ -47,27 +47,28 @@ export function Accounts() { { accessorKey: 'accountName', header: ({ column }) => , cell: ({ row }) => row.original.accountName ?? 'Unassigned' }, { id: 'title', accessorFn: (row) => row.contact.title ?? '', header: ({ column }) => , cell: ({ row }) => row.original.contact.title ?? '—' }, { id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => , cell: ({ row }) => {row.original.contact.affiliation.replace(/_/g, ' ')} }, - { id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => , cell: ({ row }) => row.original.contact.confidence === 'confirmed' ? Confirmed : }, + { id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => , cell: ({ row }) => }, { id: 'lastActivityAt', accessorFn: (row) => row.contact.lastActivityAt ?? '', header: ({ column }) => , cell: ({ row }) => row.original.contact.lastActivityAt ? relativeTime(row.original.contact.lastActivityAt) : '—' }, - { id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => { const allowed = row.original.accountSide === 'both' ? canAny : row.original.accountSide === 'demand' ? canDemand : row.original.accountSide === 'supply' ? canSupply : false; return
; } }, + { id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) =>
}, ]; + const activeQuery = view === 'accounts' ? accountsQuery : contactsQuery; + const count = view === 'accounts' ? accountsQuery.data?.length ?? 0 : contactsQuery.data?.length ?? 0; - return ( -
-
-

Accounts

Providers we buy from, customers we sell to, and the people who make each relationship real.

-
- - -
-
-
- setView(value as 'accounts' | 'contacts')}>AccountsContacts - {view === 'accounts' ?
{(['all', 'supply', 'demand'] as const).map((value) => )}
: null} -
- {view === 'accounts' ? accountsLoading ? : : contactsLoading ? : } - setAccountSheet((state) => ({ ...state, open }))} record={accountSheet.record} identity={me} /> - setContactSheet((state) => ({ ...state, open }))} record={contactSheet.record} defaultAccountId={contactSheet.accountId} identity={me} /> -
- ); + return
+

Accounts

Providers we buy from, customers we sell to, and the people who make each relationship real.

+
{ setView(value as View); setMobileSearch(''); }}>AccountsContacts

{count} {view}{view === 'accounts' && side !== 'all' ? ` · ${side}` : ''}

{view === 'accounts' ?
{(['all', 'supply', 'demand'] as const).map((value) => )}
: null}
+ + {activeQuery.isLoading ? : null} + {activeQuery.isError ? void activeQuery.refetch()}>Try again} /> : null} + {!activeQuery.isLoading && !activeQuery.isError && view === 'accounts' ? <>
{visibleAccounts.map((account) => setContactSheet({ open: true, accountId: account.id })} onEdit={() => setAccountSheet({ open: true, record: account })} />)}{visibleAccounts.length === 0 ? } title={accountsQuery.data?.length ? 'No accounts match' : `No ${side === 'all' ? '' : `${side} `}accounts`} description={accountsQuery.data?.length ? 'Try a broader search.' : 'No relationship records are available in this view.'} /> : null}
: null} + {!activeQuery.isLoading && !activeQuery.isError && view === 'contacts' ? <>
{visibleContacts.map((row) => setContactSheet({ open: true, record: row.contact })} />)}{visibleContacts.length === 0 ? } title={contactsQuery.data?.length ? 'No contacts match' : 'No contacts recorded'} description={contactsQuery.data?.length ? 'Try a broader search.' : 'Add a sourced contact to an account when the relationship is known.'} /> : null}
: null} + setAccountSheet((state) => ({ ...state, open }))} record={accountSheet.record} identity={me} /> setContactSheet((state) => ({ ...state, open }))} record={contactSheet.record} defaultAccountId={contactSheet.accountId} identity={me} /> +
; } + +function AccountCard({ account, writable, onAddContact, onEdit }: { account: AccountRecord; writable: boolean; onAddContact(): void; onEdit(): void }) { const type = accountType(account); return

{account.name}

{account.domain ?? 'No domain recorded'}

} />
; } +function ContactCard({ row, writable, onEdit }: { row: ContactRow; writable: boolean; onEdit(): void }) { return

{row.contact.fullName}

{row.contact.title ?? 'No title recorded'}

{row.contact.affiliation.replace(/_/g, ' ')}
} />
; } +function RecordValue({ label, value }: { label: string; value: React.ReactNode }) { return

{label}

{value}
; } +function SideBadge({ side }: { side: AccountRecord['side'] }) { return {side === 'supply' ? 'Buy-side' : side === 'demand' ? 'Sell-side' : 'Both sides'}; } +function Confidence({ confidence }: { confidence: string }) { return confidence === 'confirmed' ? Confirmed : ; } +function accountType(account: AccountRecord) { return account.supplierType ?? account.customerSegment; } diff --git a/apps/web/src/pages/Capacity.tsx b/apps/web/src/pages/Capacity.tsx index 05a545f..0cfabd8 100644 --- a/apps/web/src/pages/Capacity.tsx +++ b/apps/web/src/pages/Capacity.tsx @@ -8,7 +8,7 @@ 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 { AlertTriangle, 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'; @@ -45,7 +45,7 @@ export function Capacity() { const writable = can(me, 'deal:write', 'demand'); return ( -
+

Capacity

@@ -100,12 +100,16 @@ export function Capacity() { } function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) { - const { data, isLoading } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: ['availability'], queryFn: () => get('/api/capacity/availability'), }); - if (isLoading) return ; + if (isLoading) return

{Array.from({ length: 5 }).map((_, index) => )}
; + + if (error) { + return } title="Could not load capacity" description={error instanceof Error ? error.message : 'Availability is unavailable.'} />; + } if (!data || data.length === 0) { return ( @@ -146,7 +150,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
- {row.name} + {row.name} {row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'} @@ -160,7 +164,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri {/* 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 + {compactNumber(row.availableGpuHours)} hrs sellable
Cost
-
{money(row.costPerGpuHourCents)}/hr
+
{money(row.costPerGpuHourCents)}/GPU-hr
Break even
{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' - : `${money(row.breakEvenPriceCents)}/hr`} + : `${money(row.breakEvenPriceCents)}/GPU-hr`}
@@ -324,7 +336,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s ) : ( -
+
{mutation.data.map((match) => ( @@ -333,7 +345,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s

{match.name}

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

0.7 ? 'positive' : 'neutral'}> @@ -354,7 +366,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s

- {shortDate(match.startsAt)}–{shortDate(match.endsAt)} · {money(match.breakEvenPriceCents)}/hr break even + {shortDate(match.startsAt)}–{shortDate(match.endsAt)} · {match.breakEvenPriceCents == null ? 'fully sold' : match.breakEvenPriceCents === 0 ? 'cost covered' : `${money(match.breakEvenPriceCents)}/GPU-hr break even`}

+
) : null}
); } -function Field({ label, children }: { label: string; children: React.ReactNode }) { +function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: React.ReactNode }) { return ( -
-
+ {contractsQuery.data?.length ? ( +
+ + + 0} /> +
+ ) : null} + +
+ {!contractsQuery.isLoading && !contractsQuery.isError && contractsQuery.data?.length ? ( +
+

{visible.length} of {contractsQuery.data.length} contracts shown

+ {filtered ? : null} +
+ ) : null} + {contractsQuery.isLoading ? : null} - {!contractsQuery.isLoading && visible.length === 0 ? ( + {contractsQuery.isError ? } title="Contracts unavailable" description={contractsQuery.error.message} action={} /> : null} + {!contractsQuery.isLoading && !contractsQuery.isError && visible.length === 0 ? ( {TYPE_LABELS[row.contract.type]} + {row.contract.parentContractId ? Child paper : null}

{row.contract.title}

{row.accountName}

-
- {row.contract.side} +
+

{row.contract.side} · {terminationLabel(row.contract.terminationTier)}

{shortDate(row.contract.effectiveAt)} {shortDate(row.contract.expiresAt)}

@@ -482,6 +511,8 @@ export function Contracts() { contractId={selectedId} detail={detailQuery.data} isLoading={detailQuery.isLoading} + error={detailQuery.error instanceof Error ? detailQuery.error : null} + onRetry={() => void detailQuery.refetch()} onOpenChange={(open) => { if (!open) { setSelectedId(null); @@ -521,6 +552,8 @@ function ContractDetailSheet({ contractId, detail, isLoading, + error, + onRetry, onOpenChange, onEdit, editing, @@ -531,6 +564,8 @@ function ContractDetailSheet({ contractId: string | null; detail?: ContractDetail; isLoading: boolean; + error: Error | null; + onRetry(): void; onOpenChange(open: boolean): void; onEdit(): void; editing: boolean; @@ -541,7 +576,9 @@ function ContractDetailSheet({ return ( - {isLoading || !detail ? ( + {error ? ( + <>Contract unavailableThe selected paper could not be loaded.
} title="Could not load contract" description={error.message} action={} />
+ ) : isLoading || !detail ? ( <> ContractLoading contract terms. @@ -600,8 +637,8 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
) : null} -
- {detail.contract.documentUrl ? ( - + ) : null}
- - Summary - Service levels - Obligations + + Summary + Service levels + Obligations {detail.obligations.length} @@ -987,6 +1024,10 @@ function Section({ title, description, children }: { title: string; description? return

{title}

{description ?

{description}

: null}
{children}
; } +function PortfolioMetric({ label, value, urgent = false }: { label: string; value: number; urgent?: boolean }) { + return

{label}

{value}

; +} + function TermGrid({ children }: { children: React.ReactNode }) { return
{children}
; } function Value({ label, value }: { label: string; value: React.ReactNode }) { return
{label}
{value}
; } function EffectiveValue({ label, term, suffix = '', format = String }: { label: string; term?: EffectiveTerm; suffix?: string; format?(value: unknown): React.ReactNode }) { return
{label}{term?.inherited ? Inherited : null}
{term ? <>{format(term.value)}{suffix} : '—'}
; } diff --git a/apps/web/src/pages/CreateProfile.tsx b/apps/web/src/pages/CreateProfile.tsx index d3ced5e..25f7b05 100644 --- a/apps/web/src/pages/CreateProfile.tsx +++ b/apps/web/src/pages/CreateProfile.tsx @@ -11,6 +11,7 @@ import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core'; import { ApiError, post, type PublicConfig } from '@/lib/api'; import { Button, Card, CardContent, Input } from '@/components/ui'; import { PiggyMark } from '@/components/PiggyMark'; +import { usePageTitle } from '@/lib/title'; export function CreateProfile({ config, @@ -19,6 +20,7 @@ export function CreateProfile({ config: PublicConfig; onCreated: () => void; }) { + usePageTitle('Join workspace'); const [name, setName] = useState(''); const [title, setTitle] = useState(''); const [team, setTeam] = useState('demand'); @@ -56,7 +58,7 @@ export function CreateProfile({

Set up your profile

- You're signed in. One more step to join the workspace. + You're already authenticated. This creates a PIG profile only after workspace access is verified.

@@ -64,9 +66,11 @@ export function CreateProfile({ -

Fact review

- Resolve Piggy's lower-confidence claims before anyone treats them as record truth. + Inspect provenance, decide whether the evidence is credible, and keep uncertain claims out of record truth.

- 0 ? 'warning' : 'positive'}> + 0 ? 'warning' : 'positive'} aria-live="polite"> {items.length} awaiting review
@@ -113,7 +113,7 @@ export function FactReview() {
-

Approval validates the evidence, not the CRM field.

+

Approval validates evidence only. It does not write a CRM field.

Approved facts remain separate from accounts and contacts. No value is overwritten until PIG has a field-aware applicator with conflict handling. diff --git a/apps/web/src/pages/Growth.tsx b/apps/web/src/pages/Growth.tsx index a8d44e6..d16df63 100644 --- a/apps/web/src/pages/Growth.tsx +++ b/apps/web/src/pages/Growth.tsx @@ -17,7 +17,7 @@ import { } from 'lucide-react'; import { Link } from 'react-router-dom'; import { PiggyAskButton } from '@/components/PiggyChat'; -import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; import { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api'; import { usePageTitle } from '@/lib/title'; @@ -53,7 +53,7 @@ type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle'; export function Growth() { usePageTitle('Growth'); const [view, setView] = useState('priority'); - const { data, isLoading } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: ['growth'], queryFn: () => get('/api/growth'), }); @@ -65,8 +65,8 @@ export function Growth() { return data.customers; }, [data, view]); - if (isLoading) return ; - if (!data) return ; + if (isLoading) return

{Array.from({ length: 4 }).map((_, index) => )}
; + if (error || !data) return ; const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length; const expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length; @@ -74,14 +74,14 @@ export function Growth() { const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0); return ( -
-
+
+
-
Compute growth intelligence
-

Know who to expand, renew, or protect next.

-

Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Scores rank attention; they are not win or churn probabilities.

-

Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}

+
Growth intelligence
+

Expand, renew, or protect the right account next.

+

Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Attention scores are not win or churn probabilities.

+

Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}

@@ -91,14 +91,7 @@ export function Growth() {
) : null} -
- - - - -
- -
+
{([ ['priority', 'Priority'], ['expansion', 'Expansion'], @@ -106,10 +99,17 @@ export function Growth() { ['risk', 'Risk'], ['idle', 'Idle supply'], ] as const).map(([value, label]) => ( - + ))}
+
+ + + + +
+ {view === 'idle' ? : ( customers.length ? (
@@ -129,29 +129,30 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
{account.name.slice(0, 2).toUpperCase()}
-
{account.name}

{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}

-
{lifecycle.score}
attention
+
{account.name}

{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}

+
{lifecycle.score}
attention
{lifecycle.facets.map((facet) => )}
- +
- {lifecycle.signals.slice(0, 3).map((signal) => ( + {lifecycle.signals.slice(0, 2).map((signal) => (
ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3"> +{signal.weight}

{signal.explanation}

{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}

))} + {lifecycle.signals.length > 2 ?

+{lifecycle.signals.length - 2} more evidence signal{lifecycle.signals.length === 3 ? '' : 's'} in the account context

: null}
{lifecycle.blockers.length ?
{lifecycle.blockers[0]}
: null}
- Open account + Open account
diff --git a/apps/web/src/pages/Imports.tsx b/apps/web/src/pages/Imports.tsx index 5a6af92..1092381 100644 --- a/apps/web/src/pages/Imports.tsx +++ b/apps/web/src/pages/Imports.tsx @@ -133,7 +133,7 @@ export function Imports() {

Import data

-

Map a CSV or Excel table into PIG, inspect every create or update, then commit the reviewed plan atomically.

+

Stage source data, inspect every create or update, then commit the reviewed plan atomically. Nothing writes to PIG before review.

@@ -185,6 +185,7 @@ export function Imports() {
{ diff --git a/apps/web/src/pages/Margin.tsx b/apps/web/src/pages/Margin.tsx index a48a0f1..e80c741 100644 --- a/apps/web/src/pages/Margin.tsx +++ b/apps/web/src/pages/Margin.tsx @@ -6,8 +6,10 @@ * is the entire value of this view. */ import { useQuery } from '@tanstack/react-query'; +import { ArrowRight } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { compactNumber, get, money, moneyExact, percent } from '@/lib/api'; -import { Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; +import { Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; import { usePageTitle } from '@/lib/title'; interface MarginReport { @@ -38,13 +40,25 @@ interface MarginReport { export function Margin() { usePageTitle('Margin'); - const { data, isLoading } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: ['margin'], queryFn: () => get('/api/capacity/margin'), }); - if (isLoading) return ; - if (!data || data.blocks.length === 0) { + if (isLoading) { + return
{Array.from({ length: 4 }).map((_, index) => )}
; + } + if (error || !data) { + return ( + + + + + + + ); + } + if (data.blocks.length === 0) { return ( +

Margin

@@ -64,7 +78,7 @@ export function Margin() {

-
+
- - By commitment + +
+ By commitment +

Sold ratio describes contracted capacity sold, not workload utilization.

+
+ + Capacity +
-
+
- - + + @@ -129,22 +149,39 @@ export function Margin() { is technically true and reads like a bug — the same fix already applied on the capacity cards. */} - + ))}
Commitment SoldFreeUtilisationSellableSold ratio Cost/hr Break even
- {block.breakEvenPriceCents == null ? ( - Sold out - ) : block.breakEvenPriceCents === 0 ? ( - Covered - ) : ( - {moneyExact(block.breakEvenPriceCents)} - )} -
+
+ {data.blocks.map((block) => ( +
+
+
+

{block.name}

+

{block.gpuCount}× {block.gpuType}

+
+ {percent(block.utilisation)} sold +
+
+
Sold capacity
{compactNumber(block.soldGpuHours)} hrs
+
Sellable capacity
{compactNumber(block.availableGpuHours)} hrs
+
Our cost
{moneyExact(block.costPerGpuHourCents)}/GPU-hr
+
Break even
+
+
+ ))} +
); } + +function BreakEven({ value }: { value: number | null }) { + if (value == null) return Sold out; + if (value === 0) return Cost covered; + return {moneyExact(value)}/GPU-hr; +} diff --git a/apps/web/src/pages/Overview.tsx b/apps/web/src/pages/Overview.tsx index cc4a316..88a4bd4 100644 --- a/apps/web/src/pages/Overview.tsx +++ b/apps/web/src/pages/Overview.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react'; import { Link } from 'react-router-dom'; import { compactNumber, get, money, percent, relativeTime } from '@/lib/api'; -import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; import { usePageTitle } from '@/lib/title'; interface Dashboard { @@ -46,7 +46,7 @@ interface Dashboard { export function Overview() { usePageTitle('Overview'); - const { data, isLoading, error } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: ['dashboard'], queryFn: () => get('/api/dashboard'), // The book does not change second to second, but it does change while @@ -66,19 +66,25 @@ export function Overview() { if (error || !data) { return ( - + + + + + + ); } const m = data.margin; const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger'; const firstName = data.me.name.split(' ')[0]; + const idleExposureCents = data.idleAlerts.reduce((sum, alert) => sum + alert.idleCostCents, 0); return ( -
+

{greeting()}, {firstName} @@ -90,7 +96,7 @@ export function Overview() {

-
+
0 ? ( - - - Capacity you are paying for and not selling + +
+ +
+ Capacity you are paying for and not selling +

Prioritized by idle cost exposure.

+
+
+ {money(idleExposureCents)}
- + {data.idleAlerts.map((alert) => (

{alert.name}

- {alert.gpuCount}× {alert.gpuType} · {percent(alert.utilisation)} utilised + {alert.gpuCount}× {alert.gpuType} · {percent(alert.utilisation)} sold {/* A zero break-even means the block's cost is already covered, so any further sale is upside. Printing @@ -154,7 +166,8 @@ export function Overview() { Match diff --git a/apps/web/src/pages/Piggy.tsx b/apps/web/src/pages/Piggy.tsx index d54357c..3bee2d7 100644 --- a/apps/web/src/pages/Piggy.tsx +++ b/apps/web/src/pages/Piggy.tsx @@ -5,10 +5,23 @@ export function Piggy() { usePageTitle('Piggy'); return (

-
-

Piggy

-

Ask across the GPU book, then inspect the PIG records behind the answer.

+
+
+

Piggy

+

Ask across the GPU book, then inspect the PIG records behind the answer.

+
+
+ + Read-only workspace +
+
+ Inspection boundary.{' '} + + Piggy can query scoped PIG records, but this chat cannot create or update CRM data. + Verify material terms against the cited records before acting. + +
); diff --git a/apps/web/src/pages/Pipeline.tsx b/apps/web/src/pages/Pipeline.tsx index e5b83da..b3b750b 100644 --- a/apps/web/src/pages/Pipeline.tsx +++ b/apps/web/src/pages/Pipeline.tsx @@ -1,25 +1,18 @@ /** * 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. + * 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. */ -import { useMemo, useState } from 'react'; +import { useDeferredValue, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { PermissionGrant } from '@pig/core'; -import { Pencil, Plus } from 'lucide-react'; +import { Pencil, Plus, RefreshCw, Search } from 'lucide-react'; import { get, money, relativeTime } from '@/lib/api'; -import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui'; +import { Badge, Button, Card, EmptyState, Input, Skeleton } from '@/components/ui'; import { usePageTitle } from '@/lib/title'; import { can } from '@/lib/permissions'; -import { - DemandDealSheet, - SupplyDealSheet, - type DemandDealRecord, - type SupplyDealRecord, -} from '@/components/RecordSheets'; +import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets'; interface Board { stages: string[]; @@ -27,237 +20,79 @@ interface Board { } 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', + 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} -
- - )} - /> - ); + 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, accountName) => <>

{deal.name}

{accountName ?? 'No account'}

{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" - 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} -
- - )} - /> - ); + 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, accountName) => <>

{deal.name}

{accountName ?? 'No account'}

{deal.gpuCount != null && deal.gpuType ? {deal.gpuCount}× {deal.gpuType} : null}{deal.targetCostPerGpuHourCents != null ? {money(deal.targetCostPerGpuHourCents)}/hr target : null}
} + />; } -function PipelineBoard({ - title, - subtitle, - endpoint, - team, - renderCard, - renderSheet, -}: { - title: string; - subtitle: string; - endpoint: string; - team: 'supply' | 'demand'; +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 { data, isLoading } = useQuery({ - queryKey: [endpoint], - queryFn: () => get>(endpoint), - }); - const { data: me } = useQuery({ - queryKey: ['me'], - queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'), - }); + 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 [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 data?.stages ?? []) map.set(stage, []); - for (const row of data?.deals ?? []) { - map.get(row.deal.stage)?.push(row); - } + for (const stage of boardQuery.data?.stages ?? []) map.set(stage, []); + for (const row of filteredDeals) map.get(row.deal.stage)?.push(row); return map; - }, [data]); + }, [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 }); - if (isLoading) return ; + if (boardQuery.isLoading) return
setSheet({ open: true })} />
; + if (boardQuery.isError) return
setSheet({ open: true })} /> void boardQuery.refetch()}>Try again} />{sheetNode}
; + if (!boardQuery.data || boardQuery.data.deals.length === 0) return
setSheet({ open: true })} /> setSheet({ open: true })}>New {title.toLowerCase()} deal} />{sheetNode}
; - 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 })} -
- ); + return
+
setSheet({ open: true })} /> +
+ + row.deal))} /> +
+
{(byStage.get(currentStage) ?? []).map((row) => 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) => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? : null}
; })}
+ {sheetNode} +
; } -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}

- -
- ); +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)}

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 }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void }) { return

{title}

{orientation}

{subtitle}

; } diff --git a/apps/web/src/pages/Register.tsx b/apps/web/src/pages/Register.tsx index 4583e50..124c5ed 100644 --- a/apps/web/src/pages/Register.tsx +++ b/apps/web/src/pages/Register.tsx @@ -100,7 +100,10 @@ export function Register({

Create your account

- Use any email you like — your invite code is what grants access. + Use an email you control. A valid PIG invite is required before the server creates workspace access. +

+

+ This flow does not enable open registration on the shared identity provider.

@@ -108,9 +111,11 @@ export function Register({ -