Polish every product workflow across desktop and mobile
CI / verify (push) Successful in 3m32s

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.
This commit is contained in:
2026-08-13 05:34:23 -07:00
parent 1318c0b841
commit e12d27edd1
28 changed files with 672 additions and 522 deletions
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/manrope": "^5.3.0",
"@hookform/resolvers": "^5.7.1", "@hookform/resolvers": "^5.7.1",
"@pig/core": "workspace:*", "@pig/core": "workspace:*",
"@radix-ui/react-avatar": "^1.2.6", "@radix-ui/react-avatar": "^1.2.6",
+93 -20
View File
@@ -4,6 +4,7 @@
import { lazy, Suspense, useEffect, useState } from 'react'; import { lazy, Suspense, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom'; 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 { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
import { ThemeProvider } from '@/lib/theme'; import { ThemeProvider } from '@/lib/theme';
import { Shell } from '@/components/Shell'; import { Shell } from '@/components/Shell';
@@ -11,7 +12,8 @@ import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile'; import { CreateProfile } from '@/pages/CreateProfile';
import { Register } from '@/pages/Register'; import { Register } from '@/pages/Register';
import { PiggyMark } from '@/components/PiggyMark'; 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 { Toaster } from '@/components/ui/sonner';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
@@ -230,14 +232,14 @@ function Placeholder({ title }: { title: string }) {
return ( return (
<EmptyState <EmptyState
title={title} title={title}
description="Not built yet. The schema supports it — this is the next screen to write." description="That page does not exist or may have moved. Use Search to return to a workspace."
/> />
); );
} }
function Team() { function Team() {
usePageTitle('Team'); usePageTitle('Team');
const { data } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['team'], queryKey: ['team'],
queryFn: () => queryFn: () =>
get< get<
@@ -251,30 +253,101 @@ function Team() {
>('/api/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 ( return (
<div className="space-y-5"> <div className="flex flex-col gap-6">
<header> <header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Team</h1> <div>
<p className="mt-1 text-sm text-muted">Supply, demand and research.</p> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
</header> <h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
{(data ?? []).map((person) => ( See who can operate each side of the compute business and where ownership is thin.
<div key={person.id} className="card min-w-0 p-4"> </p>
<p className="font-medium">{person.name}</p> </div>
{person.title ? <p className="text-sm text-muted">{person.title}</p> : null} <Link
<div className="mt-2 flex flex-wrap gap-1.5"> to="/settings"
{person.teams.map((t) => ( className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
<span
key={t.team}
className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent-fg"
> >
{t.team} Manage access in Settings
</span> </Link>
</header>
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
{[
['People', data?.length ?? 0],
['Teams', representedTeams],
['Assignments', assignments],
].map(([label, value]) => (
<Card key={label} className="p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
</Card>
))} ))}
</div> </div>
{error ? (
<Card>
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
</Card>
) : null}
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{[0, 1, 2].map((key) => <Skeleton key={key} className="h-40 rounded-2xl" />)}
</div> </div>
) : null}
{!isLoading && !error && data?.length === 0 ? (
<Card>
<EmptyState title="No team members yet" description="Invite and assign the first operator from Settings." />
</Card>
) : null}
{!isLoading && !error && data?.length ? (
<section aria-labelledby="team-members-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
<span className="text-xs text-muted">Roles are enforced server-side</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{data.map((person) => {
const initials = person.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
return (
<Card key={person.id} className="p-4 sm:p-5">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-11 border border-border">
<AvatarFallback className="bg-accent-subtle text-sm font-semibold text-accent-fg">
{initials || 'P'}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-semibold">{person.name}</p>
<p className="truncate text-sm text-muted">{person.title || 'Team member'}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{person.teams.map((membership) => (
<Badge key={`${membership.team}:${membership.role}`} tone="accent">
{membership.team} · {membership.role}
</Badge>
))} ))}
</div> </div>
{person.teams.length === 0 ? (
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
) : null}
</Card>
);
})}
</div>
</section>
) : null}
</div> </div>
); );
} }
+12 -9
View File
@@ -198,7 +198,7 @@ export function AllocationSheet({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: defaults(preferredCommitmentId, defaultGpuHours), defaultValues: defaults(preferredCommitmentId, defaultGpuHours),
}); });
const { data: availability, isLoading: availabilityLoading } = useQuery({ const { data: availability, isLoading: availabilityLoading, error: availabilityError } = useQuery({
queryKey: ['availability'], queryKey: ['availability'],
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'), queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
enabled: open, enabled: open,
@@ -208,7 +208,7 @@ export function AllocationSheet({
queryFn: () => get<CommitmentRow[]>('/api/commitments'), queryFn: () => get<CommitmentRow[]>('/api/commitments'),
enabled: open, enabled: open,
}); });
const { data: demand } = useQuery({ const { data: demand, isLoading: demandLoading, error: demandError } = useQuery({
queryKey: ['/api/deals/demand'], queryKey: ['/api/deals/demand'],
queryFn: () => get<DemandBoard>('/api/deals/demand'), queryFn: () => get<DemandBoard>('/api/deals/demand'),
enabled: open, enabled: open,
@@ -309,7 +309,7 @@ export function AllocationSheet({
description: description:
values.kind === 'hold' values.kind === 'hold'
? `${hours} GPU-hours reserved. The hold releases automatically when it expires.` ? `${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 ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"> <SheetContent className="flex h-full w-full max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6"> <SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
<SheetTitle>Reserve capacity</SheetTitle> <SheetTitle>Reserve capacity</SheetTitle>
<SheetDescription> <SheetDescription>
@@ -358,13 +358,14 @@ export function AllocationSheet({
className="flex min-h-0 flex-1 flex-col" className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit((values) => save.mutate(values))} onSubmit={form.handleSubmit((values) => save.mutate(values))}
> >
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-5 py-5 sm:px-6"> <div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-5 py-5 sm:gap-6 sm:px-6">
<div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type"> <div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type">
{(['allocation', 'hold'] as const).map((value) => ( {(['allocation', 'hold'] as const).map((value) => (
<button <button
key={value} key={value}
type="button" type="button"
onClick={() => form.setValue('kind', value)} onClick={() => form.setValue('kind', value)}
aria-pressed={kind === value}
className={ className={
kind === value kind === value
? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm' ? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm'
@@ -376,6 +377,8 @@ export function AllocationSheet({
))} ))}
</div> </div>
{availabilityError || demandError ? <ServerError message={errorMessage(availabilityError ?? demandError)} /> : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField <FormField
control={form.control} control={form.control}
@@ -412,7 +415,7 @@ export function AllocationSheet({
<FormLabel>Demand deal</FormLabel> <FormLabel>Demand deal</FormLabel>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<FormControl> <FormControl>
<SelectTrigger className="h-11"><SelectValue placeholder="Select the customer deal" /></SelectTrigger> <SelectTrigger className="h-11"><SelectValue placeholder={demandLoading ? 'Loading customer deals…' : 'Select the customer deal'} /></SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -496,7 +499,7 @@ export function AllocationSheet({
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''} {allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
</p> </p>
</div> </div>
<Button type="button" variant="outline" className="shrink-0" disabled={release.isPending} onClick={() => release.mutate(allocation.id)}> <Button type="button" variant="outline" className="w-full shrink-0 sm:w-auto" disabled={release.isPending} onClick={() => release.mutate(allocation.id)}>
{release.isPending && release.variables === allocation.id ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <RotateCcw data-icon="inline-start" aria-hidden />} {release.isPending && release.variables === allocation.id ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <RotateCcw data-icon="inline-start" aria-hidden />}
Release Release
</Button> </Button>
@@ -543,12 +546,12 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
<section className="rounded-xl border border-border bg-surface-2 p-4"> <section className="rounded-xl border border-border bg-surface-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-semibold">{row.name}</p> <p className="break-words font-semibold leading-snug">{row.name}</p>
<p className="mt-1 text-xs text-muted">{row.gpuCount}× {row.gpuType} · {row.interconnectType} · {row.securityTier.replace(/_/g, ' ')}</p> <p className="mt-1 text-xs text-muted">{row.gpuCount}× {row.gpuType} · {row.interconnectType} · {row.securityTier.replace(/_/g, ' ')}</p>
</div> </div>
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null} {match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
</div> </div>
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface"> <div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours available`}>
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} /> <div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} /> <div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
</div> </div>
+17 -5
View File
@@ -1,3 +1,4 @@
import { Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { import {
@@ -7,6 +8,7 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
CommandSeparator,
CommandShortcut, CommandShortcut,
} from '@/components/ui/command'; } from '@/components/ui/command';
@@ -15,6 +17,7 @@ export interface CommandDestination {
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
shortcut?: string; shortcut?: string;
group?: string;
} }
export function CommandPalette({ export function CommandPalette({
@@ -27,17 +30,24 @@ export function CommandPalette({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
return ( return (
<CommandDialog open={open} onOpenChange={onOpenChange}> <CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Go to a page…" /> <CommandInput placeholder="Search pages and workflows…" aria-label="Search pages and workflows" />
<CommandList> <CommandList className="max-h-[min(70dvh,32rem)] p-1">
<CommandEmpty>No pages found.</CommandEmpty> <CommandEmpty>No pages found.</CommandEmpty>
<CommandGroup heading="Navigate"> {groups.map((group, index) => (
{destinations.map((destination) => ( <Fragment key={group}>
{index > 0 ? <CommandSeparator /> : null}
<CommandGroup heading={group}>
{destinations
.filter((destination) => (destination.group ?? 'Navigate') === group)
.map((destination) => (
<CommandItem <CommandItem
key={destination.to} key={destination.to}
value={destination.label} value={`${destination.label} ${group}`}
className="min-h-11 rounded-lg"
onSelect={() => { onSelect={() => {
navigate(destination.to); navigate(destination.to);
onOpenChange(false); onOpenChange(false);
@@ -51,6 +61,8 @@ export function CommandPalette({
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
</Fragment>
))}
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
); );
+1 -1
View File
@@ -225,7 +225,7 @@ export function DataTableColumnHeader<TData, TValue>({
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="-ml-3" className="-ml-3 min-h-11"
onClick={() => column.toggleSorting(direction === 'asc')} onClick={() => column.toggleSorting(direction === 'asc')}
aria-label={`Sort by ${title}${direction ? `, currently ${direction}ending` : ''}`} aria-label={`Sort by ${title}${direction ? `, currently ${direction}ending` : ''}`}
> >
@@ -102,8 +102,8 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
<Card> <Card>
<CardHeader><CardTitle className="text-base">Connect Google Sheets</CardTitle></CardHeader> <CardHeader><CardTitle className="text-base">Connect Google Sheets</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted">PIG requests read-only spreadsheet values and Drive metadata only when you start an import. Tokens remain encrypted on the server.</p> <p className="text-sm text-muted">PIG requests read-only spreadsheet values and Drive metadata only when you start an import. Tokens remain encrypted on the server and are never returned to this page.</p>
<Button variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}> <Button className="w-full sm:w-auto" variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}>
{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <ExternalLink data-icon="inline-start" aria-hidden />} {connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <ExternalLink data-icon="inline-start" aria-hidden />}
Connect Google Connect Google
</Button> </Button>
@@ -117,7 +117,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded-lg border border-border bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 rounded-lg border border-border bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-sm font-medium">Google Sheets connected</p><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'}</p></div> <div><div className="mb-1 flex items-center gap-2"><p className="text-sm font-medium">Google Sheets connected</p><Badge tone="neutral">Read only</Badge></div><p className="text-xs text-muted">{status.connectedAt ? `Connected ${relativeTime(status.connectedAt)}` : 'Encrypted server-side connection'} · selecting a range only stages a preview</p></div>
<Button variant="outline" disabled={disconnect.isPending} onClick={() => disconnect.mutate()}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button> <Button variant="outline" disabled={disconnect.isPending} onClick={() => disconnect.mutate()}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
</div> </div>
{disconnect.isError ? <ErrorText error={disconnect.error} /> : null} {disconnect.isError ? <ErrorText error={disconnect.error} /> : null}
@@ -131,13 +131,13 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
setPageToken(null); setPageToken(null);
setPreviousTokens([]); setPreviousTokens([]);
}}> }}>
<Input value={searchDraft} onChange={(event) => setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" /> <Input name="spreadsheetSearch" aria-label="Search spreadsheet names" value={searchDraft} onChange={(event) => setSearchDraft(event.target.value)} placeholder="Search spreadsheet names" />
<Button type="submit" variant="outline"><Search data-icon="inline-start" aria-hidden />Search</Button> <Button type="submit" variant="outline"><Search data-icon="inline-start" aria-hidden />Search</Button>
</form> </form>
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : ( {files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{files.data?.files.map((file) => ( {files.data?.files.map((file) => (
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}> <button key={file.id} type="button" aria-pressed={spreadsheetId === file.id} onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
<p className="truncate text-sm font-medium">{file.name}</p> <p className="truncate text-sm font-medium">{file.name}</p>
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p> <p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
</button> </button>
@@ -174,7 +174,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
</Select> </Select>
</label> </label>
<label className="flex flex-col gap-1.5 text-sm font-medium">A1 range <label className="flex flex-col gap-1.5 text-sm font-medium">A1 range
<Input value={range} onChange={(event) => setRange(event.target.value)} placeholder="A1:H500" autoCapitalize="off" autoCorrect="off" spellCheck={false} /> <Input name="a1Range" value={range} onChange={(event) => setRange(event.target.value)} placeholder="A1:H500" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
</label> </label>
{metadata.isError ? <div className="sm:col-span-2"><ErrorText error={metadata.error} /></div> : null} {metadata.isError ? <div className="sm:col-span-2"><ErrorText error={metadata.error} /></div> : null}
{selectedSheet ? <p className="text-xs text-muted sm:col-span-2">Selected grid: {selectedSheet.rowCount} rows × {selectedSheet.columnCount} columns. Range limits are enforced again by the server.</p> : null} {selectedSheet ? <p className="text-xs text-muted sm:col-span-2">Selected grid: {selectedSheet.rowCount} rows × {selectedSheet.columnCount} columns. Range limits are enforced again by the server.</p> : null}
+3 -3
View File
@@ -102,7 +102,7 @@ export function PiggyChatWorkspace() {
description={ description={
status.data?.enabled status.data?.enabled
? 'This credential does not have read access.' ? '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({
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center"> <div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg"><Sparkles aria-hidden /></div> <div className="flex size-12 items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg"><Sparkles aria-hidden /></div>
<h2 className="mt-4 font-semibold">What should we inspect?</h2> <h2 className="mt-4 font-semibold">What should we inspect?</h2>
<p className="mt-1 text-sm text-muted">Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access.</p> <p className="mt-1 text-sm text-muted">Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
<div className="mt-4 grid w-full gap-2"> <div className="mt-4 grid w-full gap-2">
{(context {(context
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?'] ? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
@@ -256,7 +256,7 @@ function PiggyChatPanel({
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button> <Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
)} )}
</div> </div>
<p className="mt-2 text-center text-[11px] text-muted">Check source records before acting on material terms.</p> <p className="mt-2 text-center text-[11px] text-muted">Read-only session · Check source records before acting on material terms.</p>
</form> </form>
</div> </div>
); );
+11 -10
View File
@@ -18,7 +18,7 @@ import { LoaderCircle } from 'lucide-react';
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form'; import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import { Input } from '@/components/ui'; import { Badge, Input } from '@/components/ui';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Form, Form,
@@ -306,7 +306,7 @@ export function AccountSheet({ open, onOpenChange, record, identity }: SheetProp
const side = form.watch('side'); const side = form.watch('side');
return ( return (
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit account' : 'New account'} description="Keep the commercial side explicit. It controls which team can work the record and where its deals belong."> <RecordSheet open={open} onOpenChange={onOpenChange} category="Relationship" title={record ? 'Edit account' : 'New account'} description="Keep the commercial side explicit. It controls which team can work the record and where its deals belong.">
<Form {...form}> <Form {...form}>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}> <form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
<SheetBody> <SheetBody>
@@ -395,7 +395,7 @@ export function ContactSheet({ open, onOpenChange, record, identity, defaultAcco
}); });
return ( return (
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit contact' : 'New contact'} description="Record only what is known. PIG never guesses a real persons address or employment relationship."> <RecordSheet open={open} onOpenChange={onOpenChange} category="Person" title={record ? 'Edit contact' : 'New contact'} description="Record only what is known. PIG never guesses a real persons address or employment relationship.">
<Form {...form}> <Form {...form}>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}> <form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
<SheetBody> <SheetBody>
@@ -469,7 +469,7 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
}); });
return ( return (
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit demand deal' : 'New demand deal'} description="Capture the commercial case and paper state. Capacity requirements remain separate so the matcher can reason about the technical shape."> <RecordSheet open={open} onOpenChange={onOpenChange} category="Demand · sell-side" title={record ? 'Edit demand deal' : 'New demand deal'} description="Capture the commercial case and paper state. Capacity requirements remain separate so the matcher can reason about the technical shape.">
<Form {...form}> <Form {...form}>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}> <form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
<SheetBody> <SheetBody>
@@ -545,7 +545,7 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
}); });
return ( return (
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit supply deal' : 'New supply deal'} description="Qualify the capacity and economics independently. A supplier relationship is not interchangeable with a customer opportunity."> <RecordSheet open={open} onOpenChange={onOpenChange} category="Supply · buy-side" title={record ? 'Edit supply deal' : 'New supply deal'} description="Qualify the capacity and economics independently. A supplier relationship is not interchangeable with a customer opportunity.">
<Form {...form}> <Form {...form}>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}> <form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
<SheetBody> <SheetBody>
@@ -582,11 +582,12 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
); );
} }
function RecordSheet({ open, onOpenChange, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; title: string; description: string; children: React.ReactNode }) { function RecordSheet({ open, onOpenChange, category, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; category: string; title: string; description: string; children: React.ReactNode }) {
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"> <SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6"> <SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 pr-14 text-left sm:px-6 sm:pr-14">
<Badge className="mb-1 w-fit" tone="neutral">{category}</Badge>
<SheetTitle>{title}</SheetTitle> <SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription> <SheetDescription>{description}</SheetDescription>
</SheetHeader> </SheetHeader>
@@ -598,7 +599,7 @@ function RecordSheet({ open, onOpenChange, title, description, children }: { ope
} }
function SheetBody({ children }: { children: React.ReactNode }) { function SheetBody({ children }: { children: React.ReactNode }) {
return <div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>; return <div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6">{children}</div>;
} }
function SheetActions({ pending, onCancel, label: actionLabel }: { pending: boolean; onCancel(): void; label: string }) { 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 }) { function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return ( return (
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4 border-t border-border pt-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">{title}</h3> <h3 className="text-sm font-semibold">{title}</h3>
{description ? <p className="text-xs leading-relaxed text-muted-foreground">{description}</p> : null} {description ? <p className="text-xs leading-relaxed text-muted-foreground">{description}</p> : null}
+53 -34
View File
@@ -36,24 +36,27 @@ import { Button, cn } from './ui';
interface NavItem extends CommandDestination { interface NavItem extends CommandDestination {
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */ /** Shown in the phone tab bar. Space there is scarce, so only five fit. */
primary?: boolean; primary?: boolean;
group: 'Intelligence' | 'Marketplace' | 'Records' | 'Control';
} }
const NAV: NavItem[] = [ const NAV: NavItem[] = [
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true }, { to: '/', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true },
{ to: '/growth', label: 'Growth', icon: Target }, { to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' },
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore }, { to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true }, { to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true }, { to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
{ to: '/demand', label: 'Demand', icon: Building2, primary: true }, { to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
{ to: '/supply', label: 'Supply', icon: Boxes, primary: true }, { to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
{ to: '/accounts', label: 'Accounts', icon: Building2 }, { to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' },
{ to: '/contracts', label: 'Contracts', icon: FileText }, { to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' },
{ to: '/imports', label: 'Import', icon: FileSpreadsheet }, { to: '/imports', label: 'Import', icon: FileSpreadsheet, group: 'Records' },
{ to: '/team', label: 'Team', icon: Users }, { to: '/team', label: 'Team', icon: Users, group: 'Control' },
{ to: '/facts', label: 'Fact review', icon: ShieldCheck }, { to: '/facts', label: 'Fact review', icon: ShieldCheck, group: 'Control' },
{ to: '/settings', label: 'Settings', icon: Settings }, { to: '/settings', label: 'Settings', icon: Settings, group: 'Control' },
]; ];
const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
export function Shell() { export function Shell() {
const location = useLocation(); const location = useLocation();
const [commandOpen, setCommandOpen] = useState(false); const [commandOpen, setCommandOpen] = useState(false);
@@ -72,37 +75,46 @@ export function Shell() {
}, []); }, []);
return ( return (
<div className="min-h-dvh bg-bg"> <div className="app-canvas min-h-dvh bg-bg">
{/* ------------------------------------------------- desktop sidebar */} {/* ------------------------------------------------- desktop sidebar */}
<aside <aside
className={cn( className={cn(
'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface lg:flex', 'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface/95 backdrop-blur-xl lg:flex',
// Respect the safe area on notched displays in landscape. // Respect the safe area on notched displays in landscape.
'pl-[var(--safe-left)]', 'pl-[var(--safe-left)]',
)} )}
> >
<div className="flex h-16 items-center px-5"> <div className="flex h-16 items-center border-b border-border/70 px-5">
<PiggyLogo /> <PiggyLogo />
</div> </div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4"> <nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="Workspace">
{NAV.map((item) => ( {NAV_GROUPS.map((group) => (
<div key={group} className="mb-3 last:mb-0">
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80">
{group}
</p>
<div className="flex flex-col gap-0.5">
{NAV.filter((item) => item.group === group).map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors', 'group flex min-h-[44px] items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-[background-color,color,transform]',
isActive isActive
? 'bg-accent-subtle text-accent-fg' ? 'bg-accent-subtle text-accent-fg shadow-sm'
: 'text-muted hover:bg-surface-2 hover:text-fg', : 'text-muted hover:bg-surface-2 hover:text-fg active:translate-x-0.5',
) )
} }
> >
<item.icon className="h-4 w-4 shrink-0" aria-hidden /> <item.icon className="size-4 shrink-0" aria-hidden />
{item.label} {item.label}
</NavLink> </NavLink>
))} ))}
</div>
</div>
))}
</nav> </nav>
<Button <Button
type="button" type="button"
@@ -117,8 +129,9 @@ export function Shell() {
K K
</kbd> </kbd>
</Button> </Button>
<div className="border-t border-border px-5 py-3 text-xs text-muted"> <div className="border-t border-border px-5 py-3">
Prime Intellect Growth <p className="text-xs font-medium text-fg">Prime Intellect Growth</p>
<p className="mt-0.5 text-[11px] text-muted">Compute revenue system</p>
</div> </div>
</aside> </aside>
@@ -128,7 +141,7 @@ export function Shell() {
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border', '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 // A translucent bar with a blur reads as native on iOS; the opaque
// fallback keeps text legible where backdrop-filter is unsupported. // 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)]', 'pt-[var(--safe-top)]',
)} )}
style={{ height: 'calc(3.5rem + var(--safe-top))' }} style={{ height: 'calc(3.5rem + var(--safe-top))' }}
@@ -166,7 +179,7 @@ export function Shell() {
{/* ------------------------------------------------- mobile tab bar */} {/* ------------------------------------------------- mobile tab bar */}
<nav <nav
className={cn( className={cn(
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/90 backdrop-blur-md lg:hidden', 'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
'supports-[backdrop-filter]:bg-surface/80', 'supports-[backdrop-filter]:bg-surface/80',
)} )}
style={{ paddingBottom: 'var(--safe-bottom)' }} style={{ paddingBottom: 'var(--safe-bottom)' }}
@@ -178,15 +191,21 @@ export function Shell() {
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => className="tap flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
cn(
'tap flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium',
isActive ? 'text-accent-fg' : 'text-muted',
)
}
> >
<item.icon className="h-5 w-5" aria-hidden /> {({ isActive }) => (
{item.label} <>
<span
className={cn(
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
<span className={isActive ? 'text-accent-fg' : undefined}>{item.label}</span>
</>
)}
</NavLink> </NavLink>
))} ))}
</div> </div>
+9 -5
View File
@@ -1,5 +1,5 @@
import type { FactBand, FactStatus } from '@pig/core'; 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 type { ReactNode } from 'react';
import { Badge } from '@/components/ui'; import { Badge } from '@/components/ui';
import { import {
@@ -66,6 +66,9 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
const summary = evidenceSummary(fact.evidence); const summary = evidenceSummary(fact.evidence);
const score = Number(fact.score); const score = Number(fact.score);
const confidence = Number.isFinite(score) ? `${Math.round(score * 100)}%` : 'Not scored'; 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 ( return (
<span className={cn('inline-flex min-w-0 items-center gap-1.5', className)}> <span className={cn('inline-flex min-w-0 items-center gap-1.5', className)}>
@@ -74,8 +77,8 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="tap -m-2 inline-flex shrink-0 items-center justify-center rounded-md p-2 text-accent-fg hover:bg-accent-subtle" className="tap -my-2 inline-flex size-11 shrink-0 items-center justify-center rounded-md text-accent-fg hover:bg-accent-subtle"
aria-label={`View evidence for ${fact.field}`} aria-label={`View ${fact.band} evidence for ${fact.field}`}
> >
<Link2 className="size-3.5" aria-hidden /> <Link2 className="size-3.5" aria-hidden />
</button> </button>
@@ -88,7 +91,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
<div className="flex min-w-0 items-start justify-between gap-3"> <div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted"> <p className="text-xs font-medium uppercase tracking-wide text-muted">
{humanise(fact.field)} Source evidence · {humanise(fact.field)}
</p> </p>
<p className="mt-1 break-words text-sm font-medium">{fact.value}</p> <p className="mt-1 break-words text-sm font-medium">{fact.value}</p>
</div> </div>
@@ -109,6 +112,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
<Badge tone="neutral">{humanise(fact.status)}</Badge> <Badge tone="neutral">{humanise(fact.status)}</Badge>
{fact.method ? <span>via {humanise(fact.method)}</span> : null} {fact.method ? <span>via {humanise(fact.method)}</span> : null}
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted"><Clock3 className="size-3.5 shrink-0" aria-hidden /><span>Observed <time dateTime={fact.observedAt}>{observedLabel}</time></span></div>
{sourceUrl ? ( {sourceUrl ? (
<a <a
href={sourceUrl} href={sourceUrl}
@@ -116,7 +120,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
rel="noreferrer" rel="noreferrer"
className="inline-flex min-h-11 items-center gap-2 break-all text-sm font-medium text-accent-fg hover:underline" className="inline-flex min-h-11 items-center gap-2 break-all text-sm font-medium text-accent-fg hover:underline"
> >
Open source Open source{sourceHost ? ` · ${sourceHost}` : ''}
<ExternalLink className="size-3.5 shrink-0" aria-hidden /> <ExternalLink className="size-3.5 shrink-0" aria-hidden />
</a> </a>
) : null} ) : null}
+7 -3
View File
@@ -33,7 +33,7 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { 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', secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border',
outline: 'border border-border bg-transparent hover:bg-surface-2', outline: 'border border-border bg-transparent hover:bg-surface-2',
ghost: 'bg-transparent hover:bg-surface-2', ghost: 'bg-transparent hover:bg-surface-2',
@@ -41,7 +41,7 @@ const buttonVariants = cva(
}, },
size: { size: {
// min-h keeps the target tappable even when the label is short. // 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', md: 'h-11 min-h-[44px] px-4',
lg: 'h-12 min-h-[48px] px-6 text-base', lg: 'h-12 min-h-[48px] px-6 text-base',
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0', icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
@@ -113,10 +113,14 @@ export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivEleme
return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />; return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
} }
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex items-center gap-3 p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
}
// -------------------------------------------------------------------- badge // -------------------------------------------------------------------- badge
const badgeVariants = cva( 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: { variants: {
tone: { tone: {
+25 -1
View File
@@ -2,6 +2,14 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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. * Surfaces and neutrals.
* *
@@ -21,6 +29,7 @@
--warning: 32 95% 44%; --warning: 32 95% 44%;
--danger: 0 72% 45%; --danger: 0 72% 45%;
--info: 201 90% 40%; --info: 201 90% 40%;
--shadow: 240 10% 4%;
/* Safe-area insets, so layout can reference them even at zero. */ /* Safe-area insets, so layout can reference them even at zero. */
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
@@ -41,6 +50,7 @@
--warning: 38 92% 60%; --warning: 38 92% 60%;
--danger: 0 84% 65%; --danger: 0 84% 65%;
--info: 199 89% 60%; --info: 199 89% 60%;
--shadow: 0 0% 0%;
} }
@layer base { @layer base {
@@ -62,6 +72,11 @@
overscroll-behavior-y: none; 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 * 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 * below 16px, and never zooms back out. This is the fix — not
@@ -117,7 +132,16 @@
} }
.card { .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 /* Tabular figures keep money and percentages from jittering as they
+36 -35
View File
@@ -1,44 +1,44 @@
import { useState } from 'react'; import { useDeferredValue, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import type { PermissionGrant } from '@pig/core'; 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 { AccountSheet, ContactSheet, type AccountRecord, type ContactRecord, type ContactRow } from '@/components/RecordSheets';
import { DataTable, DataTableColumnHeader } from '@/components/DataTable'; 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { get, relativeTime } from '@/lib/api'; import { get, relativeTime } from '@/lib/api';
import { can } from '@/lib/permissions'; import { can } from '@/lib/permissions';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
interface Me { permissions: PermissionGrant[] } interface Me { permissions: PermissionGrant[] }
type View = 'accounts' | 'contacts';
export function Accounts() { export function Accounts() {
usePageTitle('Accounts'); usePageTitle('Accounts');
const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all'); const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all');
const [view, setView] = useState<'accounts' | 'contacts'>('accounts'); const [view, setView] = useState<View>('accounts');
const [mobileSearch, setMobileSearch] = useState('');
const deferredSearch = useDeferredValue(mobileSearch.trim().toLocaleLowerCase());
const [accountSheet, setAccountSheet] = useState<{ open: boolean; record?: AccountRecord }>({ open: false }); const [accountSheet, setAccountSheet] = useState<{ open: boolean; record?: AccountRecord }>({ open: false });
const [contactSheet, setContactSheet] = useState<{ open: boolean; record?: ContactRecord; accountId?: string }>({ open: false }); const [contactSheet, setContactSheet] = useState<{ open: boolean; record?: ContactRecord; accountId?: string }>({ open: false });
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') }); const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
const { data: accountData, isLoading: accountsLoading } = useQuery({ const accountsQuery = useQuery({ queryKey: ['accounts', side], queryFn: () => get<AccountRecord[]>(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`) });
queryKey: ['accounts', side], const contactsQuery = useQuery({ queryKey: ['contacts', 'table'], queryFn: () => get<ContactRow[]>('/api/contacts') });
queryFn: () => get<AccountRecord[]>(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`),
});
const { data: contactData, isLoading: contactsLoading } = useQuery({
queryKey: ['contacts', 'table'],
queryFn: () => get<ContactRow[]>('/api/contacts'),
});
const canDemand = can(me, 'deal:write', 'demand'); const canDemand = can(me, 'deal:write', 'demand');
const canSupply = can(me, 'deal:write', 'supply'); const canSupply = can(me, 'deal:write', 'supply');
const canAny = canDemand || canSupply; const canAny = canDemand || canSupply;
const canAccount = (account: AccountRecord) => account.side === 'both' ? canAny : account.side === 'demand' ? 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<AccountRecord>[] = [ const accountColumns: ColumnDef<AccountRecord>[] = [
{ id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.name}</p>{row.original.domain ? <p className="truncate text-xs text-muted">{row.original.domain}</p> : null}</div> }, { id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.name}</p>{row.original.domain ? <p className="truncate text-xs text-muted">{row.original.domain}</p> : null}</div> },
{ accessorKey: 'side', header: ({ column }) => <DataTableColumnHeader column={column} title="Side" />, cell: ({ row }) => <Badge tone={row.original.side === 'supply' ? 'info' : row.original.side === 'both' ? 'accent' : 'neutral'}>{row.original.side}</Badge> }, { accessorKey: 'side', header: ({ column }) => <DataTableColumnHeader column={column} title="Side" />, cell: ({ row }) => <SideBadge side={row.original.side} /> },
{ id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, cell: ({ row }) => { const type = row.original.supplierType ?? row.original.customerSegment; return type ? <span className="capitalize">{type.replace(/_/g, ' ')}</span> : '—'; } }, { id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, cell: ({ row }) => { const type = accountType(row.original); return type ? <span className="capitalize">{type.replace(/_/g, ' ')}</span> : '—'; } },
{ accessorKey: 'country', header: ({ column }) => <DataTableColumnHeader column={column} title="Country" />, cell: ({ row }) => row.original.country ?? '—' }, { accessorKey: 'country', header: ({ column }) => <DataTableColumnHeader column={column} title="Country" />, cell: ({ row }) => row.original.country ?? '—' },
{ accessorKey: 'confidence', header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => row.original.confidence === 'confirmed' ? <span className="text-sm text-muted">Confirmed</span> : <ConfidenceBadge confidence={row.original.confidence} /> }, { accessorKey: 'confidence', header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => <Confidence confidence={row.original.confidence} /> },
{ accessorKey: 'lastActivityAt', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.lastActivityAt ? relativeTime(row.original.lastActivityAt) : '—' }, { accessorKey: 'lastActivityAt', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.lastActivityAt ? relativeTime(row.original.lastActivityAt) : '—' },
{ id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end gap-1"><Button size="icon" variant="ghost" title="Add contact" disabled={!canAccount(row.original)} onClick={() => setContactSheet({ open: true, accountId: row.original.id })}><UserPlus aria-hidden /><span className="sr-only">Add contact to {row.original.name}</span></Button><Button size="icon" variant="ghost" title="Edit account" disabled={!canAccount(row.original)} onClick={() => setAccountSheet({ open: true, record: row.original })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.name}</span></Button></div> }, { id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end gap-1"><Button size="icon" variant="ghost" title="Add contact" disabled={!canAccount(row.original)} onClick={() => setContactSheet({ open: true, accountId: row.original.id })}><UserPlus aria-hidden /><span className="sr-only">Add contact to {row.original.name}</span></Button><Button size="icon" variant="ghost" title="Edit account" disabled={!canAccount(row.original)} onClick={() => setAccountSheet({ open: true, record: row.original })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.name}</span></Button></div> },
]; ];
@@ -47,27 +47,28 @@ export function Accounts() {
{ accessorKey: 'accountName', header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => row.original.accountName ?? 'Unassigned' }, { accessorKey: 'accountName', header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => row.original.accountName ?? 'Unassigned' },
{ id: 'title', accessorFn: (row) => row.contact.title ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />, cell: ({ row }) => row.original.contact.title ?? '—' }, { id: 'title', accessorFn: (row) => row.contact.title ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />, cell: ({ row }) => row.original.contact.title ?? '—' },
{ id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => <DataTableColumnHeader column={column} title="Affiliation" />, cell: ({ row }) => <span className="capitalize">{row.original.contact.affiliation.replace(/_/g, ' ')}</span> }, { id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => <DataTableColumnHeader column={column} title="Affiliation" />, cell: ({ row }) => <span className="capitalize">{row.original.contact.affiliation.replace(/_/g, ' ')}</span> },
{ id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => row.original.contact.confidence === 'confirmed' ? <span className="text-sm text-muted">Confirmed</span> : <ConfidenceBadge confidence={row.original.contact.confidence} /> }, { id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => <Confidence confidence={row.original.contact.confidence} /> },
{ id: 'lastActivityAt', accessorFn: (row) => row.contact.lastActivityAt ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.contact.lastActivityAt ? relativeTime(row.original.contact.lastActivityAt) : '—' }, { id: 'lastActivityAt', accessorFn: (row) => row.contact.lastActivityAt ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, 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 <div className="flex justify-end"><Button size="icon" variant="ghost" title="Edit contact" disabled={!allowed} onClick={() => setContactSheet({ open: true, record: row.original.contact })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.contact.fullName}</span></Button></div>; } }, { id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end"><Button size="icon" variant="ghost" title="Edit contact" disabled={!canContact(row.original)} onClick={() => setContactSheet({ open: true, record: row.original.contact })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.contact.fullName}</span></Button></div> },
]; ];
const activeQuery = view === 'accounts' ? accountsQuery : contactsQuery;
const count = view === 'accounts' ? accountsQuery.data?.length ?? 0 : contactsQuery.data?.length ?? 0;
return ( return <div className="flex flex-col gap-5">
<div className="flex flex-col gap-5"> <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Accounts</h1><p className="mt-1 max-w-2xl text-sm text-muted">Providers we buy from, customers we sell to, and the people who make each relationship real.</p></div><div className="grid grid-cols-2 gap-2 sm:flex"><Button className="min-h-11" variant="outline" disabled={!canAny} onClick={() => setContactSheet({ open: true })}><UserPlus aria-hidden />New contact</Button><Button className="min-h-11" variant="primary" disabled={!canAny} onClick={() => setAccountSheet({ open: true })}><Plus aria-hidden />New account</Button></div></header>
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <section className="rounded-xl border border-border bg-surface-2/60 p-3"><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><Tabs value={view} onValueChange={(value) => { setView(value as View); setMobileSearch(''); }}><TabsList className="grid min-h-[52px] w-full grid-cols-2 border border-border bg-surface p-1 sm:w-64"><TabsTrigger className="min-h-11 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg" value="accounts">Accounts</TabsTrigger><TabsTrigger className="min-h-11 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg" value="contacts">Contacts</TabsTrigger></TabsList></Tabs><p className="text-sm text-muted"><strong className="nums text-fg">{count}</strong> {view}{view === 'accounts' && side !== 'all' ? ` · ${side}` : ''}</p></div>{view === 'accounts' ? <div className="mt-3 grid grid-cols-3 gap-1 rounded-lg bg-surface p-1 sm:ml-auto sm:w-fit">{(['all', 'supply', 'demand'] as const).map((value) => <button key={value} onClick={() => setSide(value)} aria-label={`Show ${value} accounts`} aria-pressed={side === value} className={['min-h-11 rounded-md px-4 text-sm font-medium capitalize transition-colors', side === value ? 'bg-surface-2 text-fg shadow-sm' : 'text-muted hover:text-fg'].join(' ')}>{value}</button>)}</div> : null}</section>
<div><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Accounts</h1><p className="mt-1 text-sm text-muted">Providers we buy from, customers we sell to, and the people who make each relationship real.</p></div> <label className="relative md:hidden"><span className="sr-only">Search {view}</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={mobileSearch} onChange={(event) => setMobileSearch(event.target.value)} placeholder={view === 'accounts' ? 'Search account, country or type' : 'Search contact, account or title'} /></label>
<div className="flex flex-col gap-2 sm:flex-row"> {activeQuery.isLoading ? <Skeleton className="h-64" /> : null}
<Button variant="outline" disabled={!canAny} onClick={() => setContactSheet({ open: true })}><UserPlus aria-hidden />New contact</Button> {activeQuery.isError ? <Card><EmptyState title={`${view === 'accounts' ? 'Accounts' : 'Contacts'} unavailable`} description={activeQuery.error.message} action={<Button variant="outline" onClick={() => void activeQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card> : null}
<Button variant="primary" disabled={!canAny} onClick={() => setAccountSheet({ open: true })}><Plus aria-hidden />New account</Button> {!activeQuery.isLoading && !activeQuery.isError && view === 'accounts' ? <><div className="hidden md:block"><DataTable columns={accountColumns} data={accountsQuery.data ?? []} filterColumn="account" filterPlaceholder="Search account names or domains" emptyMessage="No accounts found." /></div><div className="grid gap-3 md:hidden">{visibleAccounts.map((account) => <AccountCard key={account.id} account={account} writable={canAccount(account)} onAddContact={() => setContactSheet({ open: true, accountId: account.id })} onEdit={() => setAccountSheet({ open: true, record: account })} />)}{visibleAccounts.length === 0 ? <Card><EmptyState icon={<Building2 />} 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.'} /></Card> : null}</div></> : null}
</div> {!activeQuery.isLoading && !activeQuery.isError && view === 'contacts' ? <><div className="hidden md:block"><DataTable columns={contactColumns} data={contactsQuery.data ?? []} filterColumn="contact" filterPlaceholder="Search contact names or email" emptyMessage="No contacts found." /></div><div className="grid gap-3 md:hidden">{visibleContacts.map((row) => <ContactCard key={row.contact.id} row={row} writable={canContact(row)} onEdit={() => setContactSheet({ open: true, record: row.contact })} />)}{visibleContacts.length === 0 ? <Card><EmptyState icon={<Mail />} 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.'} /></Card> : null}</div></> : null}
</header> <AccountSheet open={accountSheet.open} onOpenChange={(open) => setAccountSheet((state) => ({ ...state, open }))} record={accountSheet.record} identity={me} /><ContactSheet open={contactSheet.open} onOpenChange={(open) => setContactSheet((state) => ({ ...state, open }))} record={contactSheet.record} defaultAccountId={contactSheet.accountId} identity={me} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> </div>;
<Tabs value={view} onValueChange={(value) => setView(value as 'accounts' | 'contacts')}><TabsList><TabsTrigger value="accounts">Accounts</TabsTrigger><TabsTrigger value="contacts">Contacts</TabsTrigger></TabsList></Tabs>
{view === 'accounts' ? <div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">{(['all', 'supply', 'demand'] as const).map((value) => <button key={value} onClick={() => setSide(value)} aria-pressed={side === value} className={['tap flex-1 rounded-md px-4 text-sm font-medium capitalize transition-colors', side === value ? 'bg-surface text-fg shadow-sm' : 'text-muted'].join(' ')}>{value}</button>)}</div> : null}
</div>
{view === 'accounts' ? accountsLoading ? <Skeleton className="h-64" /> : <DataTable columns={accountColumns} data={accountData ?? []} filterColumn="account" filterPlaceholder="Search account names or domains" emptyMessage="No accounts found." /> : contactsLoading ? <Skeleton className="h-64" /> : <DataTable columns={contactColumns} data={contactData ?? []} filterColumn="contact" filterPlaceholder="Search contact names or email" emptyMessage="No contacts found." />}
<AccountSheet open={accountSheet.open} onOpenChange={(open) => setAccountSheet((state) => ({ ...state, open }))} record={accountSheet.record} identity={me} />
<ContactSheet open={contactSheet.open} onOpenChange={(open) => setContactSheet((state) => ({ ...state, open }))} record={contactSheet.record} defaultAccountId={contactSheet.accountId} identity={me} />
</div>
);
} }
function AccountCard({ account, writable, onAddContact, onEdit }: { account: AccountRecord; writable: boolean; onAddContact(): void; onEdit(): void }) { const type = accountType(account); return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{account.name}</p><p className="mt-0.5 truncate text-sm text-muted">{account.domain ?? 'No domain recorded'}</p></div><SideBadge side={account.side} /></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Relationship" value={type ? type.replace(/_/g, ' ') : 'Not classified'} /><RecordValue label="Geography" value={account.country ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={account.confidence} />} /><RecordValue label="Last activity" value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : 'No activity'} /></div><div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3"><Button className="min-h-11" variant="outline" disabled={!writable} onClick={onAddContact}><UserPlus aria-hidden />Add contact</Button><Button className="min-h-11" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit account</Button></div></article>; }
function ContactCard({ row, writable, onEdit }: { row: ContactRow; writable: boolean; onEdit(): void }) { return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{row.contact.fullName}</p><p className="mt-0.5 truncate text-sm text-muted">{row.contact.title ?? 'No title recorded'}</p></div><Badge tone="neutral">{row.contact.affiliation.replace(/_/g, ' ')}</Badge></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Account" value={row.accountName ?? 'Unassigned'} /><RecordValue label="Email" value={row.contact.email ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={row.contact.confidence} />} /><RecordValue label="Last activity" value={row.contact.lastActivityAt ? relativeTime(row.contact.lastActivityAt) : 'No activity'} /></div><Button className="mt-3 min-h-11 w-full border-t border-border" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit contact</Button></article>; }
function RecordValue({ label, value }: { label: string; value: React.ReactNode }) { return <div className="min-w-0 rounded-lg bg-surface-2 p-2.5"><p className="text-[11px] uppercase tracking-wide text-muted">{label}</p><div className="mt-1 truncate capitalize text-xs font-medium">{value}</div></div>; }
function SideBadge({ side }: { side: AccountRecord['side'] }) { return <Badge tone={side === 'supply' ? 'info' : side === 'both' ? 'accent' : 'neutral'}>{side === 'supply' ? 'Buy-side' : side === 'demand' ? 'Sell-side' : 'Both sides'}</Badge>; }
function Confidence({ confidence }: { confidence: string }) { return confidence === 'confirmed' ? <span className="text-muted">Confirmed</span> : <ConfidenceBadge confidence={confidence} />; }
function accountType(account: AccountRecord) { return account.supplierType ?? account.customerSegment; }
+38 -25
View File
@@ -8,7 +8,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import type { PermissionGrant } from '@pig/core'; 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 { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
import { can } from '@/lib/permissions'; import { can } from '@/lib/permissions';
@@ -45,7 +45,7 @@ export function Capacity() {
const writable = can(me, 'deal:write', 'demand'); const writable = can(me, 'deal:write', 'demand');
return ( return (
<div className="space-y-5"> <div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<header> <header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
<p className="mt-1 text-sm text-muted"> <p className="mt-1 text-sm text-muted">
@@ -100,12 +100,16 @@ export function Capacity() {
} }
function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) { function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) {
const { data, isLoading } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['availability'], queryKey: ['availability'],
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'), queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
}); });
if (isLoading) return <Skeleton className="h-64" />; if (isLoading) return <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{Array.from({ length: 5 }).map((_, index) => <Skeleton key={index} className="h-60" />)}</div>;
if (error) {
return <Card><CardContent className="flex flex-col items-center gap-4 pt-6"><EmptyState icon={<AlertTriangle className="size-8" />} title="Could not load capacity" description={error instanceof Error ? error.message : 'Availability is unavailable.'} /><Button variant="outline" onClick={() => void refetch()}>Try again</Button></CardContent></Card>;
}
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
@@ -146,7 +150,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
<Card className="min-w-0"> <Card className="min-w-0">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<CardTitle className="min-w-0 truncate text-base">{row.name}</CardTitle> <CardTitle className="min-w-0 break-words text-base leading-snug">{row.name}</CardTitle>
<Badge tone={row.securityTier === 'secure_cloud' ? 'accent' : 'neutral'}> <Badge tone={row.securityTier === 'secure_cloud' ? 'accent' : 'neutral'}>
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'} {row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
</Badge> </Badge>
@@ -160,7 +164,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
{/* Sold and held are shown as separate segments, because a full-looking {/* 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. */} bar made mostly of unconverted holds is a lie a seller would act on. */}
<div> <div>
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2"> <div className="flex h-2 overflow-hidden rounded-full bg-surface-2" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours sellable`}>
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} /> <div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div <div
className="bg-primary/35" className="bg-primary/35"
@@ -170,20 +174,20 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
<div className="mt-1.5 flex justify-between text-xs text-muted"> <div className="mt-1.5 flex justify-between text-xs text-muted">
<span>{percent(soldPct)} sold</span> <span>{percent(soldPct)} sold</span>
{row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null} {row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null}
<span className="nums">{compactNumber(row.availableGpuHours)} hrs free</span> <span className="nums">{compactNumber(row.availableGpuHours)} hrs sellable</span>
</div> </div>
</div> </div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs"> <dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<dt className="text-muted">Cost</dt> <dt className="text-muted">Cost</dt>
<dd className="nums text-right">{money(row.costPerGpuHourCents)}/hr</dd> <dd className="nums text-right">{money(row.costPerGpuHourCents)}/GPU-hr</dd>
<dt className="text-muted">Break even</dt> <dt className="text-muted">Break even</dt>
<dd className="nums text-right"> <dd className="nums text-right">
{row.breakEvenPriceCents == null {row.breakEvenPriceCents == null
? 'Fully sold' ? 'Fully sold'
: row.breakEvenPriceCents === 0 : row.breakEvenPriceCents === 0
? 'Cost covered' ? 'Cost covered'
: `${money(row.breakEvenPriceCents)}/hr`} : `${money(row.breakEvenPriceCents)}/GPU-hr`}
</dd> </dd>
</dl> </dl>
<Button variant="outline" className="w-full" disabled={!writable || row.availableGpuHours <= 0} onClick={onAllocate} title={!writable ? 'Demand-team write permission is required' : undefined}> <Button variant="outline" className="w-full" disabled={!writable || row.availableGpuHours <= 0} onClick={onAllocate} title={!writable ? 'Demand-team write permission is required' : undefined}>
@@ -226,17 +230,20 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">What does the customer need?</CardTitle> <CardTitle className="text-base">What does the customer need?</CardTitle>
<p className="text-xs leading-relaxed text-muted">Match against capacity already under commitment. The allocation ledger remains the final authority when you save.</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form <form
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
aria-label="Capacity requirement"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
mutation.mutate(); mutation.mutate();
}} }}
> >
<Field label="GPU type"> <Field label="GPU type" htmlFor="match-gpu-type">
<Input <Input
id="match-gpu-type"
value={form.gpuType} value={form.gpuType}
onChange={(e) => setForm({ ...form, gpuType: e.target.value })} onChange={(e) => setForm({ ...form, gpuType: e.target.value })}
placeholder="H100_80GB" placeholder="H100_80GB"
@@ -247,8 +254,9 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
spellCheck={false} spellCheck={false}
/> />
</Field> </Field>
<Field label="GPUs"> <Field label="GPUs" htmlFor="match-gpu-count">
<Input <Input
id="match-gpu-count"
value={form.gpuCount} value={form.gpuCount}
onChange={(e) => setForm({ ...form, gpuCount: e.target.value })} onChange={(e) => setForm({ ...form, gpuCount: e.target.value })}
// A numeric keypad on phones, without the spinner arrows and // A numeric keypad on phones, without the spinner arrows and
@@ -257,31 +265,35 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
pattern="[0-9]*" pattern="[0-9]*"
/> />
</Field> </Field>
<Field label="GPU-hours"> <Field label="GPU-hours" htmlFor="match-gpu-hours">
<Input <Input
id="match-gpu-hours"
value={form.totalGpuHours} value={form.totalGpuHours}
onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })} onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })}
inputMode="numeric" inputMode="numeric"
placeholder="Optional" placeholder="Optional"
/> />
</Field> </Field>
<Field label="Max $/GPU-hr"> <Field label="Max $/GPU-hr" htmlFor="match-max-price">
<Input <Input
id="match-max-price"
value={form.maxPrice} value={form.maxPrice}
onChange={(e) => setForm({ ...form, maxPrice: e.target.value })} onChange={(e) => setForm({ ...form, maxPrice: e.target.value })}
inputMode="decimal" inputMode="decimal"
placeholder="Optional" placeholder="Optional"
/> />
</Field> </Field>
<Field label="Needed from"> <Field label="Needed from" htmlFor="match-starts-at">
<Input <Input
id="match-starts-at"
type="date" type="date"
value={form.startsAt} value={form.startsAt}
onChange={(e) => setForm({ ...form, startsAt: e.target.value })} onChange={(e) => setForm({ ...form, startsAt: e.target.value })}
/> />
</Field> </Field>
<Field label="Needed until"> <Field label="Needed until" htmlFor="match-ends-at">
<Input <Input
id="match-ends-at"
type="date" type="date"
value={form.endsAt} value={form.endsAt}
min={form.startsAt || undefined} min={form.startsAt || undefined}
@@ -304,7 +316,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
</span> </span>
</label> </label>
<Button type="submit" variant="primary" disabled={mutation.isPending} className="lg:col-start-4"> <Button type="submit" variant="primary" disabled={mutation.isPending} className="min-h-11 w-full lg:col-start-4">
<Search data-icon="inline-start" aria-hidden /> <Search data-icon="inline-start" aria-hidden />
{mutation.isPending ? 'Matching…' : 'Find capacity'} {mutation.isPending ? 'Matching…' : 'Find capacity'}
</Button> </Button>
@@ -324,7 +336,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{mutation.data.map((match) => ( {mutation.data.map((match) => (
<Card key={match.commitmentId}> <Card key={match.commitmentId}>
<CardContent className="pt-4"> <CardContent className="pt-4">
@@ -333,7 +345,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
<p className="font-medium">{match.name}</p> <p className="font-medium">{match.name}</p>
<p className="text-xs text-muted"> <p className="text-xs text-muted">
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '} {match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
{compactNumber(match.availableGpuHours)} hrs free {compactNumber(match.availableGpuHours)} GPU-hrs sellable
</p> </p>
</div> </div>
<Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}> <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>
@@ -354,7 +366,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
</ul> </ul>
<div className="mt-4 flex flex-col gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mt-4 flex flex-col gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted"> <p className="text-xs text-muted">
{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`}
</p> </p>
<Button <Button
variant="primary" variant="primary"
@@ -378,17 +390,18 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
) : null} ) : null}
{mutation.isError ? ( {mutation.isError ? (
<p role="alert" className="text-sm text-danger"> <div role="alert" className="flex flex-col gap-3 rounded-xl border border-danger/30 bg-danger/10 p-4 text-sm text-danger sm:flex-row sm:items-center sm:justify-between">
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'} <div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}</p></div>
</p> <Button variant="outline" onClick={() => mutation.mutate()}>Try again</Button>
</div>
) : null} ) : null}
</div> </div>
); );
} }
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: React.ReactNode }) {
return ( return (
<label className="block"> <label className="block" htmlFor={htmlFor}>
<span className="mb-1 block text-xs font-medium text-muted">{label}</span> <span className="mb-1 block text-xs font-medium text-muted">{label}</span>
{children} {children}
</label> </label>
+56 -15
View File
@@ -1,4 +1,4 @@
import { cloneElement, isValidElement, useId, useMemo, useState } from 'react'; import { cloneElement, isValidElement, useDeferredValue, useId, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
AlertTriangle, AlertTriangle,
@@ -10,6 +10,7 @@ import {
FilePlus2, FilePlus2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
ShieldCheck, ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -304,6 +305,7 @@ export function Contracts() {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [editor, setEditor] = useState<'create' | 'edit' | null>(null); const [editor, setEditor] = useState<'create' | 'edit' | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
const [type, setType] = useState<'all' | ContractType>('all'); const [type, setType] = useState<'all' | ContractType>('all');
const [side, setSide] = useState<'all' | ContractSide>('all'); const [side, setSide] = useState<'all' | ContractSide>('all');
@@ -322,7 +324,7 @@ export function Contracts() {
}); });
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = query.trim().toLocaleLowerCase(); const needle = deferredQuery;
return (contractsQuery.data ?? []).filter(({ contract, accountName }) => { return (contractsQuery.data ?? []).filter(({ contract, accountName }) => {
if (type !== 'all' && contract.type !== type) return false; if (type !== 'all' && contract.type !== type) return false;
if (side !== 'all' && contract.side !== side) return false; if (side !== 'all' && contract.side !== side) return false;
@@ -333,7 +335,17 @@ export function Contracts() {
contract.externalReference?.toLocaleLowerCase().includes(needle) contract.externalReference?.toLocaleLowerCase().includes(needle)
); );
}); });
}, [contractsQuery.data, query, side, type]); }, [contractsQuery.data, deferredQuery, side, type]);
const portfolio = useMemo(() => {
const rows = contractsQuery.data ?? [];
return {
due: rows.filter((row) => row.renewalState === 'due').length,
governing: rows.filter((row) => !row.contract.parentContractId).length,
executed: rows.filter((row) => row.contract.status === 'executed').length,
};
}, [contractsQuery.data]);
const filtered = Boolean(query || type !== 'all' || side !== 'all');
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
@@ -357,7 +369,15 @@ export function Contracts() {
</Button> </Button>
</header> </header>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px_140px]"> {contractsQuery.data?.length ? (
<section className="grid grid-cols-3 gap-2 rounded-xl border border-border bg-surface-2/60 p-3" aria-label="Contract portfolio summary">
<PortfolioMetric label="Governing" value={portfolio.governing} />
<PortfolioMetric label="Executed" value={portfolio.executed} />
<PortfolioMetric label="Notice due" value={portfolio.due} urgent={portfolio.due > 0} />
</section>
) : null}
<div className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_160px_140px]">
<Input <Input
aria-label="Search contracts" aria-label="Search contracts"
placeholder="Search paper, account or reference" placeholder="Search paper, account or reference"
@@ -377,8 +397,16 @@ export function Contracts() {
</EnumSelect> </EnumSelect>
</div> </div>
{!contractsQuery.isLoading && !contractsQuery.isError && contractsQuery.data?.length ? (
<div className="flex min-h-11 flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted"><strong className="nums text-fg">{visible.length}</strong> of {contractsQuery.data.length} contracts shown</p>
{filtered ? <Button className="min-h-11" variant="ghost" onClick={() => { setQuery(''); setType('all'); setSide('all'); }}>Clear filters</Button> : null}
</div>
) : null}
{contractsQuery.isLoading ? <Skeleton className="h-72" /> : null} {contractsQuery.isLoading ? <Skeleton className="h-72" /> : null}
{!contractsQuery.isLoading && visible.length === 0 ? ( {contractsQuery.isError ? <Card><CardContent className="pt-5"><EmptyState icon={<AlertTriangle />} title="Contracts unavailable" description={contractsQuery.error.message} action={<Button variant="outline" onClick={() => void contractsQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></CardContent></Card> : null}
{!contractsQuery.isLoading && !contractsQuery.isError && visible.length === 0 ? (
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<EmptyState <EmptyState
@@ -462,14 +490,15 @@ export function Contracts() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge>{TYPE_LABELS[row.contract.type]}</Badge> <Badge>{TYPE_LABELS[row.contract.type]}</Badge>
<StatusBadge status={row.contract.status} /> <StatusBadge status={row.contract.status} />
{row.contract.parentContractId ? <Badge tone="neutral">Child paper</Badge> : null}
</div> </div>
<p className="mt-2 truncate font-medium">{row.contract.title}</p> <p className="mt-2 truncate font-medium">{row.contract.title}</p>
<p className="mt-0.5 truncate text-sm text-muted">{row.accountName}</p> <p className="mt-0.5 truncate text-sm text-muted">{row.accountName}</p>
</div> </div>
<ChevronRight className="shrink-0 text-muted" aria-hidden /> <ChevronRight className="shrink-0 text-muted" aria-hidden />
</div> </div>
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted"> <div className="mt-3 grid grid-cols-[1fr_auto] items-end gap-3 border-t border-border pt-3 text-xs text-muted">
<span className="capitalize">{row.contract.side}</span> <div><p className="capitalize">{row.contract.side} · {terminationLabel(row.contract.terminationTier)}</p><p className="nums mt-1">{shortDate(row.contract.effectiveAt)} <ArrowRight className="mx-1 inline size-3" aria-hidden /> {shortDate(row.contract.expiresAt)}</p></div>
<RenewalBadge row={row} /> <RenewalBadge row={row} />
</div> </div>
</button> </button>
@@ -482,6 +511,8 @@ export function Contracts() {
contractId={selectedId} contractId={selectedId}
detail={detailQuery.data} detail={detailQuery.data}
isLoading={detailQuery.isLoading} isLoading={detailQuery.isLoading}
error={detailQuery.error instanceof Error ? detailQuery.error : null}
onRetry={() => void detailQuery.refetch()}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setSelectedId(null); setSelectedId(null);
@@ -521,6 +552,8 @@ function ContractDetailSheet({
contractId, contractId,
detail, detail,
isLoading, isLoading,
error,
onRetry,
onOpenChange, onOpenChange,
onEdit, onEdit,
editing, editing,
@@ -531,6 +564,8 @@ function ContractDetailSheet({
contractId: string | null; contractId: string | null;
detail?: ContractDetail; detail?: ContractDetail;
isLoading: boolean; isLoading: boolean;
error: Error | null;
onRetry(): void;
onOpenChange(open: boolean): void; onOpenChange(open: boolean): void;
onEdit(): void; onEdit(): void;
editing: boolean; editing: boolean;
@@ -541,7 +576,9 @@ function ContractDetailSheet({
return ( return (
<Sheet open={Boolean(contractId)} onOpenChange={onOpenChange}> <Sheet open={Boolean(contractId)} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-3xl"> <SheetContent side="right" className="w-full overflow-y-auto sm:max-w-3xl">
{isLoading || !detail ? ( {error ? (
<><SheetHeader><SheetTitle>Contract unavailable</SheetTitle><SheetDescription>The selected paper could not be loaded.</SheetDescription></SheetHeader><div className="mt-6"><EmptyState icon={<AlertTriangle />} title="Could not load contract" description={error.message} action={<Button variant="outline" onClick={onRetry}><RefreshCw aria-hidden />Try again</Button>} /></div></>
) : isLoading || !detail ? (
<> <>
<SheetHeader><SheetTitle>Contract</SheetTitle><SheetDescription>Loading contract terms.</SheetDescription></SheetHeader> <SheetHeader><SheetTitle>Contract</SheetTitle><SheetDescription>Loading contract terms.</SheetDescription></SheetHeader>
<Skeleton className="mt-6 h-72" /> <Skeleton className="mt-6 h-72" />
@@ -600,8 +637,8 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
</div> </div>
) : null} ) : null}
<div className="mt-4 flex gap-2"> <div className="mt-4 grid gap-2 sm:flex sm:flex-wrap">
<Button type="button" variant="primary" onClick={onEdit}> <Button className="min-h-11 w-full sm:w-auto" type="button" variant="primary" onClick={onEdit}>
<Pencil aria-hidden /> Edit terms <Pencil aria-hidden /> Edit terms
</Button> </Button>
<PiggyAskButton <PiggyAskButton
@@ -609,15 +646,15 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
prompt="Which terms, obligations or dates need attention?" prompt="Which terms, obligations or dates need attention?"
/> />
{detail.contract.documentUrl ? ( {detail.contract.documentUrl ? (
<Button type="button" variant="outline" onClick={() => window.open(detail.contract.documentUrl!, '_blank', 'noopener,noreferrer')}>Open document</Button> <Button className="min-h-11 w-full sm:w-auto" type="button" variant="outline" onClick={() => window.open(detail.contract.documentUrl!, '_blank', 'noopener,noreferrer')}>Open document</Button>
) : null} ) : null}
</div> </div>
<Tabs defaultValue="summary" className="mt-5"> <Tabs defaultValue="summary" className="mt-5">
<TabsList className="grid h-auto min-h-11 w-full grid-cols-3"> <TabsList className="grid h-auto min-h-11 w-full grid-cols-3 border border-border bg-surface-2 p-1">
<TabsTrigger className="min-h-11" value="summary">Summary</TabsTrigger> <TabsTrigger className="min-h-11 px-2 text-xs text-muted data-[state=active]:bg-surface data-[state=active]:text-fg sm:text-sm" value="summary">Summary</TabsTrigger>
<TabsTrigger className="min-h-11" value="sla">Service levels</TabsTrigger> <TabsTrigger className="min-h-11 px-2 text-xs text-muted data-[state=active]:bg-surface data-[state=active]:text-fg sm:text-sm" value="sla">Service levels</TabsTrigger>
<TabsTrigger className="min-h-11" value="obligations">Obligations</TabsTrigger> <TabsTrigger className="min-h-11 px-2 text-xs text-muted data-[state=active]:bg-surface data-[state=active]:text-fg sm:text-sm" value="obligations">Obligations {detail.obligations.length}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="summary" className="mt-4 flex flex-col gap-4"> <TabsContent value="summary" className="mt-4 flex flex-col gap-4">
@@ -987,6 +1024,10 @@ function Section({ title, description, children }: { title: string; description?
return <section><div className="mb-3"><h3 className="font-semibold">{title}</h3>{description ? <p className="mt-0.5 text-xs text-muted">{description}</p> : null}</div>{children}</section>; return <section><div className="mb-3"><h3 className="font-semibold">{title}</h3>{description ? <p className="mt-0.5 text-xs text-muted">{description}</p> : null}</div>{children}</section>;
} }
function PortfolioMetric({ label, value, urgent = false }: { label: string; value: number; urgent?: boolean }) {
return <div className="min-w-0 rounded-lg bg-surface px-3 py-2"><p className="truncate text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className={cn('nums mt-0.5 text-lg font-semibold', urgent && 'text-warning')}>{value}</p></div>;
}
function TermGrid({ children }: { children: React.ReactNode }) { return <dl className="grid gap-3 sm:grid-cols-2">{children}</dl>; } function TermGrid({ children }: { children: React.ReactNode }) { return <dl className="grid gap-3 sm:grid-cols-2">{children}</dl>; }
function Value({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg bg-surface-2 p-3"><dt className="text-xs text-muted">{label}</dt><dd className="mt-1 text-sm font-medium">{value}</dd></div>; } function Value({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg bg-surface-2 p-3"><dt className="text-xs text-muted">{label}</dt><dd className="mt-1 text-sm font-medium">{value}</dd></div>; }
function EffectiveValue({ label, term, suffix = '', format = String }: { label: string; term?: EffectiveTerm; suffix?: string; format?(value: unknown): React.ReactNode }) { return <div className="rounded-lg bg-surface-2 p-3"><dt className="flex items-center justify-between gap-2 text-xs text-muted"><span>{label}</span>{term?.inherited ? <span title="Inherited from governing paper">Inherited</span> : null}</dt><dd className="mt-1 text-sm font-medium">{term ? <>{format(term.value)}{suffix}</> : '—'}</dd></div>; } function EffectiveValue({ label, term, suffix = '', format = String }: { label: string; term?: EffectiveTerm; suffix?: string; format?(value: unknown): React.ReactNode }) { return <div className="rounded-lg bg-surface-2 p-3"><dt className="flex items-center justify-between gap-2 text-xs text-muted"><span>{label}</span>{term?.inherited ? <span title="Inherited from governing paper">Inherited</span> : null}</dt><dd className="mt-1 text-sm font-medium">{term ? <>{format(term.value)}{suffix}</> : '—'}</dd></div>; }
+13 -5
View File
@@ -11,6 +11,7 @@ import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
import { ApiError, post, type PublicConfig } from '@/lib/api'; import { ApiError, post, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { PiggyMark } from '@/components/PiggyMark';
import { usePageTitle } from '@/lib/title';
export function CreateProfile({ export function CreateProfile({
config, config,
@@ -19,6 +20,7 @@ export function CreateProfile({
config: PublicConfig; config: PublicConfig;
onCreated: () => void; onCreated: () => void;
}) { }) {
usePageTitle('Join workspace');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [team, setTeam] = useState<Team>('demand'); const [team, setTeam] = useState<Team>('demand');
@@ -56,7 +58,7 @@ export function CreateProfile({
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1> <h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1>
<p className="mt-1 text-sm text-muted"> <p className="mt-1 text-sm text-muted">
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.
</p> </p>
</div> </div>
</div> </div>
@@ -64,9 +66,11 @@ export function CreateProfile({
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<label className="block"> <label className="block" htmlFor="profile-name">
<span className="mb-1 block text-sm font-medium">Your name</span> <span className="mb-1 block text-sm font-medium">Your name</span>
<Input <Input
id="profile-name"
name="name"
required required
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -75,11 +79,13 @@ export function CreateProfile({
/> />
</label> </label>
<label className="block"> <label className="block" htmlFor="profile-job-title">
<span className="mb-1 block text-sm font-medium"> <span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span> Title <span className="font-normal text-muted">(optional)</span>
</span> </span>
<Input <Input
id="profile-job-title"
name="title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Growth" placeholder="Head of Growth"
@@ -122,9 +128,11 @@ export function CreateProfile({
</fieldset> </fieldset>
{config.inviteRequired ? ( {config.inviteRequired ? (
<label className="block"> <label className="block" htmlFor="profile-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span> <span className="mb-1 block text-sm font-medium">Invite code</span>
<Input <Input
id="profile-invite-code"
name="inviteCode"
value={inviteCode} value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)} onChange={(e) => setInviteCode(e.target.value)}
placeholder="Ask an administrator" placeholder="Ask an administrator"
@@ -138,7 +146,7 @@ export function CreateProfile({
</label> </label>
) : null} ) : null}
{error ? <p className="text-sm text-danger">{error}</p> : null} {error ? <p className="text-sm text-danger" role="alert">{error}</p> : null}
<Button type="submit" variant="primary" className="w-full" disabled={busy}> <Button type="submit" variant="primary" className="w-full" disabled={busy}>
{busy ? 'Creating' : 'Join the workspace'} {busy ? 'Creating' : 'Join the workspace'}
+3 -3
View File
@@ -101,10 +101,10 @@ export function FactReview() {
</div> </div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Fact review</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Fact review</h1>
<p className="mt-1 max-w-2xl text-sm text-muted"> <p className="mt-1 max-w-2xl text-sm text-muted">
Resolve Piggy&apos;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.
</p> </p>
</div> </div>
<Badge tone={items.length > 0 ? 'warning' : 'positive'}> <Badge tone={items.length > 0 ? 'warning' : 'positive'} aria-live="polite">
{items.length} awaiting review {items.length} awaiting review
</Badge> </Badge>
</header> </header>
@@ -113,7 +113,7 @@ export function FactReview() {
<CardContent className="flex gap-3 p-4 sm:p-5"> <CardContent className="flex gap-3 p-4 sm:p-5">
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden /> <FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">Approval validates the evidence, not the CRM field.</p> <p className="font-medium">Approval validates evidence only. It does not write a CRM field.</p>
<p className="mt-1 text-sm text-muted"> <p className="mt-1 text-sm text-muted">
Approved facts remain separate from accounts and contacts. No value is overwritten Approved facts remain separate from accounts and contacts. No value is overwritten
until PIG has a field-aware applicator with conflict handling. until PIG has a field-aware applicator with conflict handling.
+25 -24
View File
@@ -17,7 +17,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PiggyAskButton } from '@/components/PiggyChat'; 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 { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title'; import { usePageTitle } from '@/lib/title';
@@ -53,7 +53,7 @@ type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle';
export function Growth() { export function Growth() {
usePageTitle('Growth'); usePageTitle('Growth');
const [view, setView] = useState<GrowthView>('priority'); const [view, setView] = useState<GrowthView>('priority');
const { data, isLoading } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['growth'], queryKey: ['growth'],
queryFn: () => get<GrowthReport>('/api/growth'), queryFn: () => get<GrowthReport>('/api/growth'),
}); });
@@ -65,8 +65,8 @@ export function Growth() {
return data.customers; return data.customers;
}, [data, view]); }, [data, view]);
if (isLoading) return <Skeleton className="h-[32rem]" />; if (isLoading) return <div className="flex flex-col gap-4"><Skeleton className="h-40" /><div className="grid grid-cols-2 gap-2 xl:grid-cols-4">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}</div><Skeleton className="h-80" /></div>;
if (!data) return <EmptyState title="Growth intelligence is unavailable" description="The lifecycle projection could not be loaded." />; if (error || !data) return <Card><CardContent className="flex flex-col items-center gap-4 pt-6"><EmptyState title="Growth intelligence is unavailable" description={error instanceof Error ? error.message : 'The lifecycle projection could not be loaded.'} /><Button variant="outline" onClick={() => void refetch()}>Try again</Button></CardContent></Card>;
const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length; const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length;
const expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).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); const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0);
return ( return (
<div className="space-y-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]"> <div className="space-y-4 pb-[calc(5.5rem+var(--safe-bottom))] md:space-y-5 md:pb-0">
<header className="relative overflow-hidden rounded-2xl border border-border bg-surface px-5 py-6 sm:px-7"> <header className="relative overflow-hidden rounded-2xl border border-border bg-surface px-4 py-4 sm:px-7 sm:py-6">
<div className="absolute -right-16 -top-20 size-56 rounded-full bg-accent/10 blur-3xl" /> <div className="absolute -right-16 -top-20 size-56 rounded-full bg-accent/10 blur-3xl" />
<div className="relative max-w-3xl"> <div className="relative max-w-3xl">
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-accent-subtle px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-accent-fg"><Sparkles className="size-3.5" aria-hidden />Compute growth intelligence</div> <div className="mb-2 inline-flex items-center gap-2 rounded-full bg-accent-subtle px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-accent-fg"><Sparkles className="size-3.5" aria-hidden />Growth intelligence</div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Know who to expand, renew, or protect next.</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-3xl">Expand, renew, or protect the right account next.</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Scores rank attention; they are not win or churn probabilities.</p> <p className="mt-1.5 max-w-2xl text-sm leading-5 text-muted sm:mt-2 sm:leading-6">Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Attention scores are not win or churn probabilities.</p>
<p className="mt-3 text-xs text-muted">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p> <p className="mt-2 text-[11px] text-muted sm:mt-3 sm:text-xs">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p>
</div> </div>
</header> </header>
@@ -91,14 +91,7 @@ export function Growth() {
</div> </div>
) : null} ) : null}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div role="tablist" aria-label="Growth views" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
<Stat label="Renewal or risk" value={attention} hint="Needs a human decision" tone={attention ? 'warning' : 'default'} />
<Stat label="Idle supply cost" value={money(idleCost)} hint="Paid capacity still unsold" tone={idleCost ? 'danger' : 'default'} />
</section>
<div role="tablist" aria-label="Growth views" className="grid grid-cols-2 gap-1 rounded-xl bg-surface-2 p-1 sm:inline-grid sm:grid-cols-5">
{([ {([
['priority', 'Priority'], ['priority', 'Priority'],
['expansion', 'Expansion'], ['expansion', 'Expansion'],
@@ -106,10 +99,17 @@ export function Growth() {
['risk', 'Risk'], ['risk', 'Risk'],
['idle', 'Idle supply'], ['idle', 'Idle supply'],
] as const).map(([value, label]) => ( ] as const).map(([value, label]) => (
<button key={value} role="tab" aria-selected={view === value} onClick={() => setView(value)} className={['tap min-h-11 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm' : 'text-muted'].join(' ')}>{label}</button> <button key={value} role="tab" aria-selected={view === value} onClick={() => setView(value)} className={['tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm ring-1 ring-border' : 'text-muted hover:bg-surface-2'].join(' ')}>{label}</button>
))} ))}
</div> </div>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
<Stat label="Renewal or risk" value={attention} hint="Needs a human decision" tone={attention ? 'warning' : 'default'} />
<Stat label="Idle supply cost" value={money(idleCost)} hint="Paid capacity still unsold" tone={idleCost ? 'danger' : 'default'} />
</section>
{view === 'idle' ? <IdleSupply rows={data.idleSupply} /> : ( {view === 'idle' ? <IdleSupply rows={data.idleSupply} /> : (
customers.length ? ( customers.length ? (
<section className="grid gap-3 xl:grid-cols-2"> <section className="grid gap-3 xl:grid-cols-2">
@@ -129,29 +129,30 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
<CardHeader className="gap-3"> <CardHeader className="gap-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{account.name.slice(0, 2).toUpperCase()}</div> <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{account.name.slice(0, 2).toUpperCase()}</div>
<div className="min-w-0 flex-1"><CardTitle className="truncate text-lg">{account.name}</CardTitle><p className="truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div> <div className="min-w-0 flex-1"><CardTitle className="break-words text-lg leading-snug">{account.name}</CardTitle><p className="mt-1 truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div>
<div className="text-right"><div className="nums text-2xl font-semibold">{lifecycle.score}</div><div className="text-[10px] uppercase tracking-wide text-muted">attention</div></div> <div className="text-right" aria-label={`Attention score ${lifecycle.score}`}><div className="nums text-2xl font-semibold">{lifecycle.score}</div><div className="text-[10px] uppercase tracking-wide text-muted">attention</div></div>
</div> </div>
<div className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div> <div className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 text-center"> <div className="grid grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 text-center">
<Metric label="Open deals" value={customer.openDealCount} /> <Metric label="Open deals" value={customer.openDealCount} />
<Metric label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} /> <Metric label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} />
<Metric label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} /> <Metric label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{lifecycle.signals.slice(0, 3).map((signal) => ( {lifecycle.signals.slice(0, 2).map((signal) => (
<div key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3"> <div key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3">
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-xs font-semibold">+{signal.weight}</span> <span className="nums flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-xs font-semibold">+{signal.weight}</span>
<div className="min-w-0"><p className="text-sm leading-5">{signal.explanation}</p><p className="mt-1 text-[11px] uppercase tracking-wide text-muted">{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}</p></div> <div className="min-w-0"><p className="text-sm leading-5">{signal.explanation}</p><p className="mt-1 text-[11px] uppercase tracking-wide text-muted">{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}</p></div>
</div> </div>
))} ))}
{lifecycle.signals.length > 2 ? <p className="px-1 text-xs text-muted">+{lifecycle.signals.length - 2} more evidence signal{lifecycle.signals.length === 3 ? '' : 's'} in the account context</p> : null}
</div> </div>
{lifecycle.blockers.length ? <div className="rounded-lg bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null} {lifecycle.blockers.length ? <div className="rounded-lg bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<PiggyAskButton context={{ type: 'account', id: account.id, label: account.name }} prompt="Explain this account's lifecycle score and the highest-value next review. Distinguish facts from inference." label="Ask Piggy" variant="outline" /> <PiggyAskButton context={{ type: 'account', id: account.id, label: account.name }} prompt="Explain this account's lifecycle score and the highest-value next review. Distinguish facts from inference." label="Ask Piggy" variant="outline" />
<Link className="tap inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2" to="/accounts">Open account <ArrowUpRight className="size-4" aria-hidden /></Link> <Link className="tap inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2 sm:flex-none" to="/accounts">Open account <ArrowUpRight className="size-4" aria-hidden /></Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+2 -1
View File
@@ -133,7 +133,7 @@ export function Imports() {
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<header> <header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Import data</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Import data</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">Map a CSV or Excel table into PIG, inspect every create or update, then commit the reviewed plan atomically.</p> <p className="mt-1 max-w-2xl text-sm text-muted">Stage source data, inspect every create or update, then commit the reviewed plan atomically. Nothing writes to PIG before review.</p>
</header> </header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@@ -185,6 +185,7 @@ export function Imports() {
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
type="file" type="file"
aria-label={`Choose ${definition.label.toLocaleLowerCase()} import file`}
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
disabled={!allowed || parse.isPending} disabled={!allowed || parse.isPending}
onChange={(event) => { onChange={(event) => {
+57 -20
View File
@@ -6,8 +6,10 @@
* is the entire value of this view. * is the entire value of this view.
*/ */
import { useQuery } from '@tanstack/react-query'; 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 { 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'; import { usePageTitle } from '@/lib/title';
interface MarginReport { interface MarginReport {
@@ -38,13 +40,25 @@ interface MarginReport {
export function Margin() { export function Margin() {
usePageTitle('Margin'); usePageTitle('Margin');
const { data, isLoading } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['margin'], queryKey: ['margin'],
queryFn: () => get<MarginReport>('/api/capacity/margin'), queryFn: () => get<MarginReport>('/api/capacity/margin'),
}); });
if (isLoading) return <Skeleton className="h-96" />; if (isLoading) {
if (!data || data.blocks.length === 0) { return <div className="flex flex-col gap-4"><Skeleton className="h-16" /><div className="grid grid-cols-2 gap-2 xl:grid-cols-4">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}</div><Skeleton className="h-80" /></div>;
}
if (error || !data) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<EmptyState title="Could not load margin" description={error instanceof Error ? error.message : 'The margin ledger is unavailable.'} />
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
</CardContent>
</Card>
);
}
if (data.blocks.length === 0) {
return ( return (
<EmptyState <EmptyState
title="No capacity to report on" title="No capacity to report on"
@@ -56,7 +70,7 @@ export function Margin() {
const t = data.totals; const t = data.totals;
return ( return (
<div className="space-y-6"> <div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<header> <header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
<p className="mt-1 max-w-2xl text-sm text-muted"> <p className="mt-1 max-w-2xl text-sm text-muted">
@@ -64,7 +78,7 @@ export function Margin() {
</p> </p>
</header> </header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat label="Revenue" value={money(t.revenueCents)} /> <Stat label="Revenue" value={money(t.revenueCents)} />
<Stat label="Cost" value={money(t.costCents)} hint="Full commitment" /> <Stat label="Cost" value={money(t.costCents)} hint="Full commitment" />
<Stat <Stat
@@ -76,23 +90,29 @@ export function Margin() {
<Stat <Stat
label="Per sold GPU-hour" label="Per sold GPU-hour"
value={moneyExact(t.marginPerAllocatedGpuHourCents)} value={moneyExact(t.marginPerAllocatedGpuHourCents)}
hint={`${percent(t.utilisation, 1)} utilised`} hint={`${percent(t.utilisation, 1)} of committed hours sold`}
/> />
</section> </section>
<Card> <Card>
<CardHeader> <CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base">By commitment</CardTitle> <CardTitle className="text-base">By commitment</CardTitle>
<p className="mt-1 text-xs text-muted">Sold ratio describes contracted capacity sold, not workload utilization.</p>
</div>
<Link to="/capacity" className="tap inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface-2">
Capacity <ArrowRight className="size-4" aria-hidden />
</Link>
</CardHeader> </CardHeader>
<CardContent className="px-0 sm:px-0"> <CardContent className="px-0 sm:px-0">
<div className="scroll-x"> <div className="hidden scroll-x md:block">
<table className="w-full min-w-[720px] text-sm"> <table className="w-full min-w-[720px] text-sm">
<thead> <thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted"> <tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
<th className="px-4 pb-2 font-medium sm:px-5">Commitment</th> <th className="px-4 pb-2 font-medium sm:px-5">Commitment</th>
<th className="px-4 pb-2 text-right font-medium">Sold</th> <th className="px-4 pb-2 text-right font-medium">Sold</th>
<th className="px-4 pb-2 text-right font-medium">Free</th> <th className="px-4 pb-2 text-right font-medium">Sellable</th>
<th className="px-4 pb-2 text-right font-medium">Utilisation</th> <th className="px-4 pb-2 text-right font-medium">Sold ratio</th>
<th className="px-4 pb-2 text-right font-medium">Cost/hr</th> <th className="px-4 pb-2 text-right font-medium">Cost/hr</th>
<th className="px-4 pb-2 text-right font-medium sm:px-5">Break even</th> <th className="px-4 pb-2 text-right font-medium sm:px-5">Break even</th>
</tr> </tr>
@@ -129,22 +149,39 @@ export function Margin() {
is technically true and reads like a bug — the same fix is technically true and reads like a bug — the same fix
already applied on the capacity cards. already applied on the capacity cards.
*/} */}
<td className="px-4 py-3 text-right sm:px-5"> <td className="px-4 py-3 text-right sm:px-5"><BreakEven value={block.breakEvenPriceCents} /></td>
{block.breakEvenPriceCents == null ? (
<span className="text-muted">Sold out</span>
) : block.breakEvenPriceCents === 0 ? (
<span className="text-positive">Covered</span>
) : (
<span className="nums">{moneyExact(block.breakEvenPriceCents)}</span>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="grid gap-3 px-4 pb-4 md:hidden">
{data.blocks.map((block) => (
<article key={block.commitmentId} className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-words font-medium leading-snug">{block.name}</h3>
<p className="mt-1 text-xs text-muted">{block.gpuCount}× {block.gpuType}</p>
</div>
<span className={['nums shrink-0 text-sm font-semibold', block.utilisation < 0.5 ? 'text-warning' : ''].join(' ')}>{percent(block.utilisation)} sold</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted">Sold capacity</dt><dd className="nums text-right">{compactNumber(block.soldGpuHours)} hrs</dd>
<dt className="text-muted">Sellable capacity</dt><dd className="nums text-right">{compactNumber(block.availableGpuHours)} hrs</dd>
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{moneyExact(block.costPerGpuHourCents)}/GPU-hr</dd>
<dt className="text-muted">Break even</dt><dd className="text-right"><BreakEven value={block.breakEvenPriceCents} /></dd>
</dl>
</article>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
function BreakEven({ value }: { value: number | null }) {
if (value == null) return <span className="text-muted">Sold out</span>;
if (value === 0) return <span className="text-positive">Cost covered</span>;
return <span className="nums">{moneyExact(value)}/GPU-hr</span>;
}
+23 -10
View File
@@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react'; import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { compactNumber, get, money, percent, relativeTime } from '@/lib/api'; 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'; import { usePageTitle } from '@/lib/title';
interface Dashboard { interface Dashboard {
@@ -46,7 +46,7 @@ interface Dashboard {
export function Overview() { export function Overview() {
usePageTitle('Overview'); usePageTitle('Overview');
const { data, isLoading, error } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['dashboard'], queryKey: ['dashboard'],
queryFn: () => get<Dashboard>('/api/dashboard'), queryFn: () => get<Dashboard>('/api/dashboard'),
// The book does not change second to second, but it does change while // The book does not change second to second, but it does change while
@@ -66,19 +66,25 @@ export function Overview() {
if (error || !data) { if (error || !data) {
return ( return (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<EmptyState <EmptyState
title="Could not load the overview" title="Could not load the overview"
description={error instanceof Error ? error.message : 'Unknown error.'} description={error instanceof Error ? error.message : 'Unknown error.'}
/> />
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
</CardContent>
</Card>
); );
} }
const m = data.margin; const m = data.margin;
const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger'; const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger';
const firstName = data.me.name.split(' ')[0]; const firstName = data.me.name.split(' ')[0];
const idleExposureCents = data.idleAlerts.reduce((sum, alert) => sum + alert.idleCostCents, 0);
return ( return (
<div className="space-y-6"> <div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<header> <header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl"> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
{greeting()}, {firstName} {greeting()}, {firstName}
@@ -90,7 +96,7 @@ export function Overview() {
</p> </p>
</header> </header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat <Stat
label="Gross margin" label="Gross margin"
value={money(m.grossMarginCents)} value={money(m.grossMarginCents)}
@@ -98,7 +104,7 @@ export function Overview() {
tone={marginTone} tone={marginTone}
/> />
<Stat <Stat
label="Utilisation" label="Sold ratio"
value={percent(m.utilisation, 1)} value={percent(m.utilisation, 1)}
hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`} hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`}
tone={m.utilisation < 0.6 ? 'warning' : 'default'} tone={m.utilisation < 0.6 ? 'warning' : 'default'}
@@ -118,11 +124,17 @@ export function Overview() {
{data.idleAlerts.length > 0 ? ( {data.idleAlerts.length > 0 ? (
<Card className="border-warning/30"> <Card className="border-warning/30">
<CardHeader className="flex-row items-center gap-2 space-y-0"> <CardHeader className="flex-row items-start justify-between gap-3 space-y-0">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" aria-hidden /> <div className="flex min-w-0 items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden />
<div>
<CardTitle className="text-base">Capacity you are paying for and not selling</CardTitle> <CardTitle className="text-base">Capacity you are paying for and not selling</CardTitle>
<p className="mt-1 text-xs text-muted">Prioritized by idle cost exposure.</p>
</div>
</div>
<Badge tone="warning" className="nums shrink-0">{money(idleExposureCents)}</Badge>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
{data.idleAlerts.map((alert) => ( {data.idleAlerts.map((alert) => (
<div <div
key={alert.commitmentId} key={alert.commitmentId}
@@ -131,7 +143,7 @@ export function Overview() {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{alert.name}</p> <p className="truncate font-medium">{alert.name}</p>
<p className="text-xs text-muted"> <p className="text-xs text-muted">
{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 A zero break-even means the block's cost is already
covered, so any further sale is upside. Printing covered, so any further sale is upside. Printing
@@ -154,7 +166,8 @@ export function Overview() {
</span> </span>
<Link <Link
to="/capacity" to="/capacity"
className="tap inline-flex items-center gap-1 text-sm font-medium text-accent-fg" className="tap inline-flex min-h-11 items-center gap-1 rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface"
aria-label={`Match demand to ${alert.name}`}
> >
Match Match
<ArrowRight className="h-3.5 w-3.5" aria-hidden /> <ArrowRight className="h-3.5 w-3.5" aria-hidden />
+14 -1
View File
@@ -5,10 +5,23 @@ export function Piggy() {
usePageTitle('Piggy'); usePageTitle('Piggy');
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<header> <header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Piggy</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Piggy</h1>
<p className="mt-1 text-sm text-muted">Ask across the GPU book, then inspect the PIG records behind the answer.</p> <p className="mt-1 text-sm text-muted">Ask across the GPU book, then inspect the PIG records behind the answer.</p>
</div>
<div className="flex items-center gap-2 self-start rounded-full border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium sm:self-auto">
<span className="h-2 w-2 rounded-full bg-positive" aria-hidden />
Read-only workspace
</div>
</header> </header>
<div className="rounded-xl border border-border bg-surface-2/60 px-4 py-3 text-sm">
<span className="font-medium">Inspection boundary.</span>{' '}
<span className="text-muted">
Piggy can query scoped PIG records, but this chat cannot create or update CRM data.
Verify material terms against the cited records before acting.
</span>
</div>
<PiggyChatWorkspace /> <PiggyChatWorkspace />
</div> </div>
); );
+56 -221
View File
@@ -1,25 +1,18 @@
/** /**
* The pipeline boards, for both sides of the market. * 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 * Stages remain ordered, but wrap into a scanable desktop grid instead of
* column. A horizontally scrolling eight-column board on a 390px screen is * hiding the back half of the funnel behind a multi-screen horizontal rail.
* technically responsive and practically unusable — you cannot see where a
* card is going, which is the entire point of a board.
*/ */
import { useMemo, useState } from 'react'; import { useDeferredValue, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { PermissionGrant } from '@pig/core'; 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 { 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 { usePageTitle } from '@/lib/title';
import { can } from '@/lib/permissions'; import { can } from '@/lib/permissions';
import { import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets';
DemandDealSheet,
SupplyDealSheet,
type DemandDealRecord,
type SupplyDealRecord,
} from '@/components/RecordSheets';
interface Board<T> { interface Board<T> {
stages: string[]; stages: string[];
@@ -27,237 +20,79 @@ interface Board<T> {
} }
const STAGE_LABELS: Record<string, string> = { const STAGE_LABELS: Record<string, string> = {
qualification: 'Qualification', 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',
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() { export function DemandPipeline() {
return ( return <PipelineBoard<DemandDealRecord>
<PipelineBoard<DemandDealRecord> title="Demand" orientation="Sell-side"
title="Demand" subtitle="Selling compute and post-training. Legal sits early because paper gates delivery rather than merely closing it."
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"
endpoint="/api/deals/demand" searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.productLine}`}
team="demand" metricLabel="Visible ACV" metricValue={(deals) => money(deals.reduce((total, deal) => total + (deal.acvCents ?? 0), 0))}
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />} renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
renderCard={(deal, accountName) => ( renderCard={(deal, accountName) => <><p className="truncate font-medium">{deal.name}</p><p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p><div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.acvCents != null ? <span className="nums text-sm font-semibold">{money(deal.acvCents)}</span> : null}<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}</div></>}
<> />;
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.acvCents ? (
<span className="nums text-sm font-semibold">{money(deal.acvCents)}</span>
) : null}
<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>
{/* Contract state is surfaced on the card because shipping capacity
without executed paper is the mistake this pipeline prevents. */}
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
</div>
</>
)}
/>
);
} }
export function SupplyPipeline() { export function SupplyPipeline() {
return ( return <PipelineBoard<SupplyDealRecord>
<PipelineBoard<SupplyDealRecord> title="Supply" orientation="Buy-side"
title="Supply" subtitle="Sourcing GPU capacity. Technical and financial diligence remain separate gates because accepting capacity is a two-key decision."
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision." endpoint="/api/deals/supply" team="supply"
endpoint="/api/deals/supply" searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.gpuType ?? ''}`}
team="supply" metricLabel="GPU opportunity" metricValue={(deals) => `${deals.reduce((total, deal) => total + (deal.gpuCount ?? 0), 0).toLocaleString()} GPUs`}
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />} renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
renderCard={(deal, accountName) => ( renderCard={(deal, accountName) => <><p className="truncate font-medium">{deal.name}</p><p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p><div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.gpuCount != null && deal.gpuType ? <Badge tone="accent">{deal.gpuCount}× {deal.gpuType}</Badge> : null}{deal.targetCostPerGpuHourCents != null ? <span className="nums text-xs text-muted">{money(deal.targetCostPerGpuHourCents)}/hr target</span> : null}</div></>}
<> />;
<p className="truncate font-medium">{deal.name}</p>
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{deal.gpuCount && deal.gpuType ? (
<Badge tone="accent">
{deal.gpuCount}× {deal.gpuType}
</Badge>
) : null}
{deal.targetCostPerGpuHourCents ? (
<span className="nums text-xs text-muted">
{money(deal.targetCostPerGpuHourCents)}/hr target
</span>
) : null}
</div>
</>
)}
/>
);
} }
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
title, title: string; orientation: string; subtitle: string; endpoint: string; team: 'supply' | 'demand';
subtitle, searchText: (deal: T, accountName: string | null) => string; metricLabel: string; metricValue: (deals: T[]) => string;
endpoint,
team,
renderCard,
renderSheet,
}: {
title: string;
subtitle: string;
endpoint: string;
team: 'supply' | 'demand';
renderCard: (deal: T, accountName: string | null) => React.ReactNode; renderCard: (deal: T, accountName: string | null) => React.ReactNode;
renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode; renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode;
}) { }) {
usePageTitle(title); usePageTitle(title);
const boardQuery = useQuery({ queryKey: [endpoint], queryFn: () => get<Board<T>>(endpoint) });
const { data, isLoading } = useQuery({ const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me') });
queryKey: [endpoint],
queryFn: () => get<Board<T>>(endpoint),
});
const { data: me } = useQuery({
queryKey: ['me'],
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
});
const writable = can(me, 'deal:write', team); const writable = can(me, 'deal:write', team);
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false }); const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
const [activeStage, setActiveStage] = useState<string | null>(null); const [activeStage, setActiveStage] = useState<string | null>(null);
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
const filteredDeals = useMemo(() => !deferredQuery ? boardQuery.data?.deals ?? [] : (boardQuery.data?.deals ?? []).filter((row) => searchText(row.deal, row.accountName).toLocaleLowerCase().includes(deferredQuery)), [boardQuery.data?.deals, deferredQuery, searchText]);
const byStage = useMemo(() => { const byStage = useMemo(() => {
const map = new Map<string, { deal: T; accountName: string | null }[]>(); const map = new Map<string, { deal: T; accountName: string | null }[]>();
for (const stage of data?.stages ?? []) map.set(stage, []); for (const stage of boardQuery.data?.stages ?? []) map.set(stage, []);
for (const row of data?.deals ?? []) { for (const row of filteredDeals) map.get(row.deal.stage)?.push(row);
map.get(row.deal.stage)?.push(row);
}
return map; 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 <Skeleton className="h-96" />; if (boardQuery.isLoading) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Skeleton className="h-96" /></div>;
if (boardQuery.isError) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
if (!data || data.deals.length === 0) { return <div className="space-y-5">
return ( <Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
<div className="space-y-5"> <section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
<Header title={title} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /> <label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
<Card> <PipelineStat label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><PipelineStat label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
<EmptyState
title={`No ${title.toLowerCase()} deals yet`}
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>}
/>
</Card>
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
</div>
);
}
const stages = data.stages;
const currentStage = activeStage ?? stages[0]!;
return (
<div className="space-y-5">
<Header title={title} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
{/* Phone: pick one stage. The chips scroll; the board does not. */}
<div className="lg:hidden">
<div className="scroll-x -mx-4 flex gap-2 px-4 pb-1">
{stages.map((stage) => {
const count = byStage.get(stage)?.length ?? 0;
return (
<button
key={stage}
onClick={() => setActiveStage(stage)}
className={[
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
stage === currentStage
? 'bg-primary text-accent-on'
: 'bg-surface-2 text-muted',
].join(' ')}
>
{STAGE_LABELS[stage] ?? stage}
<span className="ml-1.5 opacity-70">{count}</span>
</button>
);
})}
</div>
<div className="mt-3 space-y-2">
{(byStage.get(currentStage) ?? []).map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
))}
{(byStage.get(currentStage) ?? []).length === 0 ? (
<p className="py-8 text-center text-sm text-muted">
Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.
</p>
) : null}
</div>
</div>
{/* Desktop: the full board, scrolling horizontally within its own pane
so the page itself never scrolls sideways. */}
<div className="scroll-x hidden lg:block">
<div className="flex gap-3 pb-2">
{stages.map((stage) => {
const rows = byStage.get(stage) ?? [];
return (
<section key={stage} className="w-72 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<h2 className="text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2>
<span className="nums text-xs text-muted">{rows.length}</span>
</div>
<div className="space-y-2">
{rows.map((row) => (
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
))}
</div>
</section> </section>
); <div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
})} <div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
</div> {sheetNode}
</div> </div>;
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
</div>
);
} }
function DealCard<T extends { id: string; updatedAt: string }>({ function DealCard<T extends { id: string; updatedAt: string }>({ row, renderCard, writable, onEdit }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; onEdit(): void }) {
row, return <article className="card relative min-w-0 p-3 pr-12 shadow-sm"><Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>{renderCard(row.deal, row.accountName)}<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p></article>;
renderCard,
writable,
onEdit,
}: {
row: { deal: T; accountName: string | null };
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
writable: boolean;
onEdit(): void;
}) {
return (
<article className="card relative p-3 pr-12">
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title="Edit deal"><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
{renderCard(row.deal, row.accountName)}
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
</article>
);
}
function Header({ title, subtitle, writable, onCreate }: { title: string; subtitle: string; writable: boolean; onCreate(): void }) {
return (
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
<Button variant="primary" disabled={!writable} onClick={onCreate}><Plus aria-hidden />New deal</Button>
</header>
);
} }
function PipelineStat({ label, value }: { label: string; value: string }) { return <div className="min-w-[7rem] rounded-lg bg-surface px-3 py-2"><p className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className="nums mt-0.5 truncate text-sm font-semibold">{value}</p></div>; }
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return <p className={compact ? 'rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted' : 'py-10 text-center text-sm text-muted'}>{filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}</p>; }
function Header({ title, orientation, subtitle, writable, onCreate }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void }) { return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div><Button className="min-h-11 sm:shrink-0" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button></header>; }
+17 -8
View File
@@ -100,7 +100,10 @@ export function Register({
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1> <h1 className="text-xl font-semibold tracking-tight">Create your account</h1>
<p className="mt-1 text-sm text-muted"> <p className="mt-1 text-sm text-muted">
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.
</p>
<p className="mt-2 text-xs text-muted">
This flow does not enable open registration on the shared identity provider.
</p> </p>
</div> </div>
</div> </div>
@@ -108,9 +111,11 @@ export function Register({
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<label className="block"> <label className="block" htmlFor="register-invite-code">
<span className="mb-1 block text-sm font-medium">Invite code</span> <span className="mb-1 block text-sm font-medium">Invite code</span>
<Input <Input
id="register-invite-code"
name="inviteCode"
required required
value={form.inviteCode} value={form.inviteCode}
onChange={set('inviteCode')} onChange={set('inviteCode')}
@@ -121,14 +126,16 @@ export function Register({
/> />
</label> </label>
<label className="block"> <label className="block" htmlFor="register-name">
<span className="mb-1 block text-sm font-medium">Your name</span> <span className="mb-1 block text-sm font-medium">Your name</span>
<Input required value={form.name} onChange={set('name')} autoComplete="name" /> <Input id="register-name" name="name" required value={form.name} onChange={set('name')} autoComplete="name" />
</label> </label>
<label className="block"> <label className="block" htmlFor="register-email">
<span className="mb-1 block text-sm font-medium">Email</span> <span className="mb-1 block text-sm font-medium">Email</span>
<Input <Input
id="register-email"
name="email"
type="email" type="email"
required required
value={form.email} value={form.email}
@@ -141,9 +148,11 @@ export function Register({
/> />
</label> </label>
<label className="block"> <label className="block" htmlFor="register-password">
<span className="mb-1 block text-sm font-medium">Password</span> <span className="mb-1 block text-sm font-medium">Password</span>
<Input <Input
id="register-password"
name="password"
type="password" type="password"
required required
minLength={8} minLength={8}
@@ -156,11 +165,11 @@ export function Register({
<span className="mt-1 block text-xs text-muted">At least 8 characters.</span> <span className="mt-1 block text-xs text-muted">At least 8 characters.</span>
</label> </label>
<label className="block"> <label className="block" htmlFor="register-title">
<span className="mb-1 block text-sm font-medium"> <span className="mb-1 block text-sm font-medium">
Title <span className="font-normal text-muted">(optional)</span> Title <span className="font-normal text-muted">(optional)</span>
</span> </span>
<Input value={form.title} onChange={set('title')} placeholder="Head of Growth" /> <Input id="register-title" name="title" value={form.title} onChange={set('title')} placeholder="Head of Growth" />
</label> </label>
<fieldset> <fieldset>
+20 -6
View File
@@ -30,17 +30,27 @@ export function Settings() {
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') }); const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
return ( return (
<div className="space-y-6"> <div className="space-y-6 pb-4">
<header> <header className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1> <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Personal preferences, workspace access, and server-managed integration readiness.
</p>
</div>
{me?.isPlatformAdmin ? <Badge tone="warning">Platform admin view</Badge> : null}
</header> </header>
<div className="grid gap-6 xl:grid-cols-2">
<Appearance /> <Appearance />
<Profile me={me} /> <Profile me={me} />
</div>
{me?.isPlatformAdmin ? <AdminSettings /> : null} {me?.isPlatformAdmin ? <AdminSettings /> : null}
<div className="grid gap-6 xl:grid-cols-2">
<ConnectAgent /> <ConnectAgent />
<SessionCard /> <SessionCard />
</div> </div>
</div>
); );
} }
@@ -64,6 +74,7 @@ function Appearance() {
return ( return (
<button <button
key={value} key={value}
type="button"
onClick={() => setMode(value as ThemeMode)} onClick={() => setMode(value as ThemeMode)}
aria-pressed={mode === value} aria-pressed={mode === value}
className={[ className={[
@@ -88,6 +99,7 @@ function Appearance() {
return ( return (
<button <button
key={option.key} key={option.key}
type="button"
onClick={() => setAccent(option.key)} onClick={() => setAccent(option.key)}
aria-pressed={selected} aria-pressed={selected}
aria-label={option.label} aria-label={option.label}
@@ -193,13 +205,15 @@ function Profile({ me }: { me: Me | undefined }) {
save.mutate(); save.mutate();
}} }}
> >
<label className="block"> <label className="block" htmlFor="profile-display-name">
<span className="mb-1 block text-xs font-medium text-muted">Display name</span> <span className="mb-1 block text-xs font-medium text-muted">Display name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} /> <Input id="profile-display-name" name="displayName" value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} />
</label> </label>
<label className="block"> <label className="block" htmlFor="profile-title">
<span className="mb-1 block text-xs font-medium text-muted">Title</span> <span className="mb-1 block text-xs font-medium text-muted">Title</span>
<Input <Input
id="profile-title"
name="title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Compute" placeholder="Head of Compute"
@@ -288,7 +302,7 @@ function SessionCard() {
</p> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button variant="outline" onClick={signOut} disabled={busy}> <Button type="button" variant="outline" onClick={signOut} disabled={busy}>
<LogOut className="h-4 w-4" aria-hidden /> <LogOut className="h-4 w-4" aria-hidden />
{busy ? 'Signing out…' : 'Sign out'} {busy ? 'Signing out…' : 'Sign out'}
</Button> </Button>
+19 -4
View File
@@ -16,6 +16,7 @@ import { KeyRound, Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api'; import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui'; import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark'; import { PiggyMark } from '@/components/PiggyMark';
import { usePageTitle } from '@/lib/title';
type Method = 'password' | 'link'; type Method = 'password' | 'link';
@@ -26,6 +27,7 @@ export function SignIn({
config: PublicConfig; config: PublicConfig;
onCreateAccount: () => void; onCreateAccount: () => void;
}) { }) {
usePageTitle('Sign in');
const [method, setMethod] = useState<Method>('password'); const [method, setMethod] = useState<Method>('password');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -105,6 +107,15 @@ export function SignIn({
</div> </div>
) : ( ) : (
<> <>
<div className="mb-5">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<h2 className="mt-1 text-lg font-semibold">Sign in to PIG</h2>
<p className="mt-1 text-sm text-muted">
Authentication stays with the deployment's identity provider. PIG never stores your password.
</p>
</div>
<div <div
role="tablist" role="tablist"
aria-label="Sign-in method" aria-label="Sign-in method"
@@ -138,9 +149,11 @@ export function SignIn({
</div> </div>
<form onSubmit={submit} className="space-y-3"> <form onSubmit={submit} className="space-y-3">
<label className="block"> <label className="block" htmlFor="sign-in-email">
<span className="mb-1 block text-sm font-medium">Email</span> <span className="mb-1 block text-sm font-medium">Email</span>
<Input <Input
id="sign-in-email"
name="email"
type="email" type="email"
required required
value={email} value={email}
@@ -154,9 +167,11 @@ export function SignIn({
</label> </label>
{method === 'password' ? ( {method === 'password' ? (
<label className="block"> <label className="block" htmlFor="sign-in-password">
<span className="mb-1 block text-sm font-medium">Password</span> <span className="mb-1 block text-sm font-medium">Password</span>
<Input <Input
id="sign-in-password"
name="password"
type="password" type="password"
required required
value={password} value={password}
@@ -192,7 +207,7 @@ export function SignIn({
{config.canSelfRegister ? ( {config.canSelfRegister ? (
<div className="border-t border-border pt-3 text-center"> <div className="border-t border-border pt-3 text-center">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Have an invite code but no account? Have a PIG invite code but no account? Registration remains closed on the shared identity provider.
</p> </p>
<button <button
type="button" type="button"
@@ -204,7 +219,7 @@ export function SignIn({
</div> </div>
) : config.inviteRequired ? ( ) : config.inviteRequired ? (
<p className="pt-1 text-center text-xs text-muted"> <p className="pt-1 text-center text-xs text-muted">
PIG is invite-only. An account alone does not grant access. This workspace is invite-only. Ask an administrator to provision access; this screen never opens self-registration.
</p> </p>
) : null} ) : null}
</form> </form>
+1 -1
View File
@@ -65,7 +65,7 @@ export default {
info: 'hsl(var(--info))', info: 'hsl(var(--info))',
}, },
fontFamily: { fontFamily: {
sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Inter', 'sans-serif'], sans: ['Manrope Variable', 'Avenir Next', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
}, },
borderRadius: { lg: '0.75rem', xl: '1rem' }, borderRadius: { lg: '0.75rem', xl: '1rem' },
+8
View File
@@ -93,6 +93,9 @@ importers:
apps/web: apps/web:
dependencies: dependencies:
'@fontsource-variable/manrope':
specifier: ^5.3.0
version: 5.3.0
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.7.1 specifier: ^5.7.1
version: 5.7.1(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@3.25.76) version: 5.7.1(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@3.25.76)
@@ -937,6 +940,9 @@ packages:
'@floating-ui/utils@0.2.12': '@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@fontsource-variable/manrope@5.3.0':
resolution: {integrity: sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==}
'@hono/node-server@1.19.17': '@hono/node-server@1.19.17':
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
@@ -3298,6 +3304,8 @@ snapshots:
'@floating-ui/utils@0.2.12': {} '@floating-ui/utils@0.2.12': {}
'@fontsource-variable/manrope@5.3.0': {}
'@hono/node-server@1.19.17(hono@4.13.1)': '@hono/node-server@1.19.17(hono@4.13.1)':
dependencies: dependencies:
hono: 4.13.1 hono: 4.13.1