From e12d27edd19bdf16ac77b3cc9ab623f90ef36e93 Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 13 Aug 2026 05:34:23 -0700 Subject: [PATCH] Polish every product workflow across desktop and mobile Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit. The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads. --- apps/web/package.json | 1 + apps/web/src/App.tsx | 119 ++++++-- apps/web/src/components/AllocationSheet.tsx | 21 +- apps/web/src/components/CommandPalette.tsx | 52 ++-- apps/web/src/components/DataTable.tsx | 2 +- .../web/src/components/GoogleSheetsSource.tsx | 12 +- apps/web/src/components/PiggyChat.tsx | 6 +- apps/web/src/components/RecordSheets.tsx | 21 +- apps/web/src/components/Shell.tsx | 111 ++++--- apps/web/src/components/SourcedValue.tsx | 14 +- apps/web/src/components/ui/index.tsx | 10 +- apps/web/src/index.css | 26 +- apps/web/src/pages/Accounts.tsx | 71 ++--- apps/web/src/pages/Capacity.tsx | 63 ++-- apps/web/src/pages/Contracts.tsx | 71 ++++- apps/web/src/pages/CreateProfile.tsx | 18 +- apps/web/src/pages/FactReview.tsx | 6 +- apps/web/src/pages/Growth.tsx | 49 +-- apps/web/src/pages/Imports.tsx | 3 +- apps/web/src/pages/Margin.tsx | 79 +++-- apps/web/src/pages/Overview.tsx | 43 ++- apps/web/src/pages/Piggy.tsx | 19 +- apps/web/src/pages/Pipeline.tsx | 283 ++++-------------- apps/web/src/pages/Register.tsx | 25 +- apps/web/src/pages/Settings.tsx | 36 ++- apps/web/src/pages/SignIn.tsx | 23 +- apps/web/tailwind.config.js | 2 +- pnpm-lock.yaml | 8 + 28 files changed, 672 insertions(+), 522 deletions(-) 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({ -