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:
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@pig/core": "workspace:*",
|
||||
"@radix-ui/react-avatar": "^1.2.6",
|
||||
|
||||
+96
-23
@@ -4,6 +4,7 @@
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
|
||||
import { ThemeProvider } from '@/lib/theme';
|
||||
import { Shell } from '@/components/Shell';
|
||||
@@ -11,7 +12,8 @@ import { SignIn } from '@/pages/SignIn';
|
||||
import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { Register } from '@/pages/Register';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
@@ -230,14 +232,14 @@ function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<EmptyState
|
||||
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() {
|
||||
usePageTitle('Team');
|
||||
const { data } = useQuery({
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['team'],
|
||||
queryFn: () =>
|
||||
get<
|
||||
@@ -251,30 +253,101 @@ function Team() {
|
||||
>('/api/team'),
|
||||
});
|
||||
|
||||
const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0);
|
||||
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Team</h1>
|
||||
<p className="mt-1 text-sm text-muted">Supply, demand and research.</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
See who can operate each side of the compute business and where ownership is thin.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
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"
|
||||
>
|
||||
Manage access in Settings
|
||||
</Link>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(data ?? []).map((person) => (
|
||||
<div key={person.id} className="card min-w-0 p-4">
|
||||
<p className="font-medium">{person.name}</p>
|
||||
{person.title ? <p className="text-sm text-muted">{person.title}</p> : null}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{person.teams.map((t) => (
|
||||
<span
|
||||
key={t.team}
|
||||
className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent-fg"
|
||||
>
|
||||
{t.team}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
) : 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>
|
||||
{person.teams.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export function AllocationSheet({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaults(preferredCommitmentId, defaultGpuHours),
|
||||
});
|
||||
const { data: availability, isLoading: availabilityLoading } = useQuery({
|
||||
const { data: availability, isLoading: availabilityLoading, error: availabilityError } = useQuery({
|
||||
queryKey: ['availability'],
|
||||
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||||
enabled: open,
|
||||
@@ -208,7 +208,7 @@ export function AllocationSheet({
|
||||
queryFn: () => get<CommitmentRow[]>('/api/commitments'),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: demand } = useQuery({
|
||||
const { data: demand, isLoading: demandLoading, error: demandError } = useQuery({
|
||||
queryKey: ['/api/deals/demand'],
|
||||
queryFn: () => get<DemandBoard>('/api/deals/demand'),
|
||||
enabled: open,
|
||||
@@ -309,7 +309,7 @@ export function AllocationSheet({
|
||||
description:
|
||||
values.kind === 'hold'
|
||||
? `${hours} GPU-hours reserved. The hold releases automatically when it expires.`
|
||||
: `${hours} GPU-hours committed. Margin and utilisation have been updated.`,
|
||||
: `${hours} GPU-hours committed. Margin and sold-capacity reporting have been updated.`,
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -344,7 +344,7 @@ export function AllocationSheet({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SheetTitle>Reserve capacity</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -358,13 +358,14 @@ export function AllocationSheet({
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
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">
|
||||
{(['allocation', 'hold'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => form.setValue('kind', value)}
|
||||
aria-pressed={kind === value}
|
||||
className={
|
||||
kind === value
|
||||
? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm'
|
||||
@@ -376,6 +377,8 @@ export function AllocationSheet({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availabilityError || demandError ? <ServerError message={errorMessage(availabilityError ?? demandError)} /> : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -412,7 +415,7 @@ export function AllocationSheet({
|
||||
<FormLabel>Demand deal</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -496,7 +499,7 @@ export function AllocationSheet({
|
||||
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
|
||||
</p>
|
||||
</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
|
||||
</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">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<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>
|
||||
</div>
|
||||
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
|
||||
</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/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
@@ -15,6 +17,7 @@ export interface CommandDestination {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
shortcut?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
@@ -27,30 +30,39 @@ export function CommandPalette({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Go to a page…" />
|
||||
<CommandList>
|
||||
<CommandInput placeholder="Search pages and workflows…" aria-label="Search pages and workflows" />
|
||||
<CommandList className="max-h-[min(70dvh,32rem)] p-1">
|
||||
<CommandEmpty>No pages found.</CommandEmpty>
|
||||
<CommandGroup heading="Navigate">
|
||||
{destinations.map((destination) => (
|
||||
<CommandItem
|
||||
key={destination.to}
|
||||
value={destination.label}
|
||||
onSelect={() => {
|
||||
navigate(destination.to);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<destination.icon aria-hidden />
|
||||
<span>{destination.label}</span>
|
||||
{destination.shortcut ? (
|
||||
<CommandShortcut>{destination.shortcut}</CommandShortcut>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{groups.map((group, index) => (
|
||||
<Fragment key={group}>
|
||||
{index > 0 ? <CommandSeparator /> : null}
|
||||
<CommandGroup heading={group}>
|
||||
{destinations
|
||||
.filter((destination) => (destination.group ?? 'Navigate') === group)
|
||||
.map((destination) => (
|
||||
<CommandItem
|
||||
key={destination.to}
|
||||
value={`${destination.label} ${group}`}
|
||||
className="min-h-11 rounded-lg"
|
||||
onSelect={() => {
|
||||
navigate(destination.to);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<destination.icon aria-hidden />
|
||||
<span>{destination.label}</span>
|
||||
{destination.shortcut ? (
|
||||
<CommandShortcut>{destination.shortcut}</CommandShortcut>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
|
||||
@@ -225,7 +225,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3"
|
||||
className="-ml-3 min-h-11"
|
||||
onClick={() => column.toggleSorting(direction === 'asc')}
|
||||
aria-label={`Sort by ${title}${direction ? `, currently ${direction}ending` : ''}`}
|
||||
>
|
||||
|
||||
@@ -102,8 +102,8 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Connect Google Sheets</CardTitle></CardHeader>
|
||||
<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>
|
||||
<Button variant="primary" disabled={connect.isPending} onClick={() => connect.mutate()}>
|
||||
<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 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 Google
|
||||
</Button>
|
||||
@@ -117,7 +117,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
return (
|
||||
<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><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>
|
||||
</div>
|
||||
{disconnect.isError ? <ErrorText error={disconnect.error} /> : null}
|
||||
@@ -131,13 +131,13 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
setPageToken(null);
|
||||
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>
|
||||
</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." /> : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{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="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
|
||||
</button>
|
||||
@@ -174,7 +174,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
|
||||
</Select>
|
||||
</label>
|
||||
<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>
|
||||
{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}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function PiggyChatWorkspace() {
|
||||
description={
|
||||
status.data?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable Piggy and connect the internal service.'
|
||||
: 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -216,7 +216,7 @@ function PiggyChatPanel({
|
||||
<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>
|
||||
<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">
|
||||
{(context
|
||||
? ['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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LoaderCircle } from 'lucide-react';
|
||||
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui';
|
||||
import { Badge, Input } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -306,7 +306,7 @@ export function AccountSheet({ open, onOpenChange, record, identity }: SheetProp
|
||||
|
||||
const side = form.watch('side');
|
||||
return (
|
||||
<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 className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -395,7 +395,7 @@ export function ContactSheet({ open, onOpenChange, record, identity, defaultAcco
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} title={record ? 'Edit contact' : 'New contact'} description="Record only what is known. PIG never guesses a real person’s 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 person’s address or employment relationship.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -469,7 +469,7 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
|
||||
});
|
||||
|
||||
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 className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
@@ -545,7 +545,7 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
|
||||
});
|
||||
|
||||
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 className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||||
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
|
||||
<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 pr-14 text-left sm:px-6 sm:pr-14">
|
||||
<Badge className="mb-1 w-fit" tone="neutral">{category}</Badge>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
@@ -598,7 +599,7 @@ function RecordSheet({ open, onOpenChange, title, description, children }: { ope
|
||||
}
|
||||
|
||||
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 }) {
|
||||
@@ -622,7 +623,7 @@ function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
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">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description ? <p className="text-xs leading-relaxed text-muted-foreground">{description}</p> : null}
|
||||
|
||||
@@ -36,24 +36,27 @@ import { Button, cn } from './ui';
|
||||
interface NavItem extends CommandDestination {
|
||||
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
|
||||
primary?: boolean;
|
||||
group: 'Intelligence' | 'Marketplace' | 'Records' | 'Control';
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
|
||||
{ to: '/growth', label: 'Growth', icon: Target },
|
||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText },
|
||||
{ to: '/imports', label: 'Import', icon: FileSpreadsheet },
|
||||
{ to: '/team', label: 'Team', icon: Users },
|
||||
{ to: '/facts', label: 'Fact review', icon: ShieldCheck },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, group: 'Intelligence', primary: true },
|
||||
{ to: '/growth', label: 'Growth', icon: Target, group: 'Intelligence' },
|
||||
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
|
||||
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' },
|
||||
{ to: '/imports', label: 'Import', icon: FileSpreadsheet, group: 'Records' },
|
||||
{ to: '/team', label: 'Team', icon: Users, group: 'Control' },
|
||||
{ to: '/facts', label: 'Fact review', icon: ShieldCheck, group: 'Control' },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, group: 'Control' },
|
||||
];
|
||||
|
||||
const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
|
||||
|
||||
export function Shell() {
|
||||
const location = useLocation();
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
@@ -72,36 +75,45 @@ export function Shell() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-bg">
|
||||
<div className="app-canvas min-h-dvh bg-bg">
|
||||
{/* ------------------------------------------------- desktop sidebar */}
|
||||
<aside
|
||||
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.
|
||||
'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 />
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-subtle text-accent-fg'
|
||||
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="Workspace">
|
||||
{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
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'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
|
||||
? 'bg-accent-subtle text-accent-fg shadow-sm'
|
||||
: 'text-muted hover:bg-surface-2 hover:text-fg active:translate-x-0.5',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<Button
|
||||
@@ -117,8 +129,9 @@ export function Shell() {
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
<div className="border-t border-border px-5 py-3 text-xs text-muted">
|
||||
Prime Intellect Growth
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
@@ -128,7 +141,7 @@ export function Shell() {
|
||||
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border',
|
||||
// A translucent bar with a blur reads as native on iOS; the opaque
|
||||
// fallback keeps text legible where backdrop-filter is unsupported.
|
||||
'bg-surface/85 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-surface/70 lg:hidden',
|
||||
'bg-surface/90 px-4 backdrop-blur-xl supports-[backdrop-filter]:bg-surface/75 lg:hidden',
|
||||
'pt-[var(--safe-top)]',
|
||||
)}
|
||||
style={{ height: 'calc(3.5rem + var(--safe-top))' }}
|
||||
@@ -166,7 +179,7 @@ export function Shell() {
|
||||
{/* ------------------------------------------------- mobile tab bar */}
|
||||
<nav
|
||||
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',
|
||||
)}
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
@@ -178,15 +191,21 @@ export function Shell() {
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
)
|
||||
}
|
||||
className="tap flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
|
||||
>
|
||||
<item.icon className="h-5 w-5" aria-hidden />
|
||||
{item.label}
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FactBand, FactStatus } from '@pig/core';
|
||||
import { ExternalLink, Link2, ScanSearch } from 'lucide-react';
|
||||
import { Clock3, ExternalLink, Link2, ScanSearch } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui';
|
||||
import {
|
||||
@@ -66,6 +66,9 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
const summary = evidenceSummary(fact.evidence);
|
||||
const score = Number(fact.score);
|
||||
const confidence = Number.isFinite(score) ? `${Math.round(score * 100)}%` : 'Not scored';
|
||||
const observedDate = new Date(fact.observedAt);
|
||||
const observedLabel = Number.isNaN(observedDate.getTime()) ? 'Observation date unavailable' : observedDate.toLocaleDateString(undefined, { dateStyle: 'medium' });
|
||||
const sourceHost = sourceUrl ? new URL(sourceUrl).hostname.replace(/^www\./, '') : null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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"
|
||||
aria-label={`View evidence for ${fact.field}`}
|
||||
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 ${fact.band} evidence for ${fact.field}`}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden />
|
||||
</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="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
{humanise(fact.field)}
|
||||
Source evidence · {humanise(fact.field)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-sm font-medium">{fact.value}</p>
|
||||
</div>
|
||||
@@ -109,6 +112,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
<Badge tone="neutral">{humanise(fact.status)}</Badge>
|
||||
{fact.method ? <span>via {humanise(fact.method)}</span> : null}
|
||||
</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 ? (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
@@ -116,7 +120,7 @@ export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
rel="noreferrer"
|
||||
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 />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-accent text-accent-on hover:opacity-90 active:opacity-80',
|
||||
primary: 'bg-primary text-primary-foreground shadow-sm hover:opacity-90 active:opacity-80',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border',
|
||||
outline: 'border border-border bg-transparent hover:bg-surface-2',
|
||||
ghost: 'bg-transparent hover:bg-surface-2',
|
||||
@@ -41,7 +41,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
// min-h keeps the target tappable even when the label is short.
|
||||
sm: 'h-9 min-h-[36px] px-3 text-xs',
|
||||
sm: 'h-11 min-h-[44px] px-3 text-xs',
|
||||
md: 'h-11 min-h-[44px] px-4',
|
||||
lg: 'h-12 min-h-[48px] px-6 text-base',
|
||||
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
|
||||
@@ -113,10 +113,14 @@ export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivEleme
|
||||
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
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
|
||||
+25
-1
@@ -2,6 +2,14 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope Variable';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 200 800;
|
||||
src: url('@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2') format('woff2-variations');
|
||||
}
|
||||
|
||||
/*
|
||||
* Surfaces and neutrals.
|
||||
*
|
||||
@@ -21,6 +29,7 @@
|
||||
--warning: 32 95% 44%;
|
||||
--danger: 0 72% 45%;
|
||||
--info: 201 90% 40%;
|
||||
--shadow: 240 10% 4%;
|
||||
|
||||
/* Safe-area insets, so layout can reference them even at zero. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
@@ -41,6 +50,7 @@
|
||||
--warning: 38 92% 60%;
|
||||
--danger: 0 84% 65%;
|
||||
--info: 199 89% 60%;
|
||||
--shadow: 0 0% 0%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -62,6 +72,11 @@
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--accent-subtle));
|
||||
color: hsl(var(--accent-fg));
|
||||
}
|
||||
|
||||
/*
|
||||
* Mobile Safari zooms the viewport when a focused input has a font size
|
||||
* below 16px, and never zooms back out. This is the fix — not
|
||||
@@ -117,7 +132,16 @@
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-xl border border-border bg-surface;
|
||||
@apply rounded-2xl border border-border/90 bg-surface;
|
||||
box-shadow: 0 1px 2px hsl(var(--shadow) / 0.035), 0 12px 36px hsl(var(--shadow) / 0.025);
|
||||
}
|
||||
|
||||
.app-canvas {
|
||||
background-color: hsl(var(--bg));
|
||||
background-image:
|
||||
radial-gradient(circle at 78% -12%, hsl(var(--accent-subtle) / 0.7), transparent 34rem),
|
||||
linear-gradient(to bottom, hsl(var(--surface) / 0.25), transparent 28rem);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Tabular figures keep money and percentages from jittering as they
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus, UserPlus } from 'lucide-react';
|
||||
import { Building2, Mail, Pencil, Plus, RefreshCw, Search, UserPlus } from 'lucide-react';
|
||||
import { AccountSheet, ContactSheet, type AccountRecord, type ContactRecord, type ContactRow } from '@/components/RecordSheets';
|
||||
import { DataTable, DataTableColumnHeader } from '@/components/DataTable';
|
||||
import { Badge, Button, ConfidenceBadge, Skeleton } from '@/components/ui';
|
||||
import { Badge, Button, Card, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { get, relativeTime } from '@/lib/api';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface Me { permissions: PermissionGrant[] }
|
||||
type View = 'accounts' | 'contacts';
|
||||
|
||||
export function Accounts() {
|
||||
usePageTitle('Accounts');
|
||||
const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all');
|
||||
const [view, setView] = useState<'accounts' | 'contacts'>('accounts');
|
||||
const [view, setView] = useState<View>('accounts');
|
||||
const [mobileSearch, setMobileSearch] = useState('');
|
||||
const deferredSearch = useDeferredValue(mobileSearch.trim().toLocaleLowerCase());
|
||||
const [accountSheet, setAccountSheet] = useState<{ open: boolean; record?: AccountRecord }>({ open: false });
|
||||
const [contactSheet, setContactSheet] = useState<{ open: boolean; record?: ContactRecord; accountId?: string }>({ open: false });
|
||||
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
|
||||
const { data: accountData, isLoading: accountsLoading } = useQuery({
|
||||
queryKey: ['accounts', side],
|
||||
queryFn: () => get<AccountRecord[]>(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`),
|
||||
});
|
||||
const { data: contactData, isLoading: contactsLoading } = useQuery({
|
||||
queryKey: ['contacts', 'table'],
|
||||
queryFn: () => get<ContactRow[]>('/api/contacts'),
|
||||
});
|
||||
const accountsQuery = useQuery({ queryKey: ['accounts', side], queryFn: () => get<AccountRecord[]>(`/api/accounts${side === 'all' ? '' : `?side=${side}`}`) });
|
||||
const contactsQuery = useQuery({ queryKey: ['contacts', 'table'], queryFn: () => get<ContactRow[]>('/api/contacts') });
|
||||
const canDemand = can(me, 'deal:write', 'demand');
|
||||
const canSupply = can(me, 'deal:write', 'supply');
|
||||
const canAny = canDemand || canSupply;
|
||||
const canAccount = (account: AccountRecord) => account.side === 'both' ? canAny : account.side === 'demand' ? canDemand : canSupply;
|
||||
const canContact = (row: ContactRow) => row.accountSide === 'both' ? canAny : row.accountSide === 'demand' ? canDemand : row.accountSide === 'supply' ? canSupply : false;
|
||||
const visibleAccounts = useMemo(() => !deferredSearch ? accountsQuery.data ?? [] : (accountsQuery.data ?? []).filter((account) => [account.name, account.domain, account.country, account.supplierType, account.customerSegment].some((value) => value?.toLocaleLowerCase().includes(deferredSearch))), [accountsQuery.data, deferredSearch]);
|
||||
const visibleContacts = useMemo(() => !deferredSearch ? contactsQuery.data ?? [] : (contactsQuery.data ?? []).filter((row) => [row.contact.fullName, row.contact.email, row.contact.title, row.accountName].some((value) => value?.toLocaleLowerCase().includes(deferredSearch))), [contactsQuery.data, deferredSearch]);
|
||||
|
||||
const accountColumns: ColumnDef<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> },
|
||||
{ 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> },
|
||||
{ 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> : '—'; } },
|
||||
{ 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 = 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: '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) : '—' },
|
||||
{ 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' },
|
||||
{ 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: '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: '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 (
|
||||
<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 text-sm text-muted">Providers we buy from, customers we sell to, and the people who make each relationship real.</p></div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" disabled={!canAny} onClick={() => setContactSheet({ open: true })}><UserPlus aria-hidden />New contact</Button>
|
||||
<Button variant="primary" disabled={!canAny} onClick={() => setAccountSheet({ open: true })}><Plus aria-hidden />New account</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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>
|
||||
);
|
||||
return <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>
|
||||
<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>
|
||||
<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>
|
||||
{activeQuery.isLoading ? <Skeleton className="h-64" /> : null}
|
||||
{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}
|
||||
{!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}
|
||||
{!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}
|
||||
<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; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Search, Server, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { AlertTriangle, Search, Server, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { can } from '@/lib/permissions';
|
||||
@@ -45,7 +45,7 @@ export function Capacity() {
|
||||
const writable = can(me, 'deal:write', 'demand');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
|
||||
<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 }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['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) {
|
||||
return (
|
||||
@@ -146,7 +150,7 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<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'}>
|
||||
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
|
||||
</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
|
||||
bar made mostly of unconverted holds is a lie a seller would act on. */}
|
||||
<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/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">
|
||||
<span>{percent(soldPct)} sold</span>
|
||||
{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>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<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>
|
||||
<dd className="nums text-right">
|
||||
{row.breakEvenPriceCents == null
|
||||
? 'Fully sold'
|
||||
: row.breakEvenPriceCents === 0
|
||||
? 'Cost covered'
|
||||
: `${money(row.breakEvenPriceCents)}/hr`}
|
||||
: `${money(row.breakEvenPriceCents)}/GPU-hr`}
|
||||
</dd>
|
||||
</dl>
|
||||
<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>
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardContent>
|
||||
<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) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Field label="GPU type">
|
||||
<Field label="GPU type" htmlFor="match-gpu-type">
|
||||
<Input
|
||||
id="match-gpu-type"
|
||||
value={form.gpuType}
|
||||
onChange={(e) => setForm({ ...form, gpuType: e.target.value })}
|
||||
placeholder="H100_80GB"
|
||||
@@ -247,8 +254,9 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="GPUs">
|
||||
<Field label="GPUs" htmlFor="match-gpu-count">
|
||||
<Input
|
||||
id="match-gpu-count"
|
||||
value={form.gpuCount}
|
||||
onChange={(e) => setForm({ ...form, gpuCount: e.target.value })}
|
||||
// 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]*"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="GPU-hours">
|
||||
<Field label="GPU-hours" htmlFor="match-gpu-hours">
|
||||
<Input
|
||||
id="match-gpu-hours"
|
||||
value={form.totalGpuHours}
|
||||
onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })}
|
||||
inputMode="numeric"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max $/GPU-hr">
|
||||
<Field label="Max $/GPU-hr" htmlFor="match-max-price">
|
||||
<Input
|
||||
id="match-max-price"
|
||||
value={form.maxPrice}
|
||||
onChange={(e) => setForm({ ...form, maxPrice: e.target.value })}
|
||||
inputMode="decimal"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Needed from">
|
||||
<Field label="Needed from" htmlFor="match-starts-at">
|
||||
<Input
|
||||
id="match-starts-at"
|
||||
type="date"
|
||||
value={form.startsAt}
|
||||
onChange={(e) => setForm({ ...form, startsAt: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Needed until">
|
||||
<Field label="Needed until" htmlFor="match-ends-at">
|
||||
<Input
|
||||
id="match-ends-at"
|
||||
type="date"
|
||||
value={form.endsAt}
|
||||
min={form.startsAt || undefined}
|
||||
@@ -304,7 +316,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
|
||||
</span>
|
||||
</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 />
|
||||
{mutation.isPending ? 'Matching…' : 'Find capacity'}
|
||||
</Button>
|
||||
@@ -324,7 +336,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{mutation.data.map((match) => (
|
||||
<Card key={match.commitmentId}>
|
||||
<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="text-xs text-muted">
|
||||
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
|
||||
{compactNumber(match.availableGpuHours)} hrs free
|
||||
{compactNumber(match.availableGpuHours)} GPU-hrs sellable
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>
|
||||
@@ -354,7 +366,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
|
||||
</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">
|
||||
<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>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -378,17 +390,18 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
|
||||
) : null}
|
||||
|
||||
{mutation.isError ? (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
|
||||
</p>
|
||||
<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">
|
||||
<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>
|
||||
<Button variant="outline" onClick={() => mutation.mutate()}>Try again</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<label className="block" htmlFor={htmlFor}>
|
||||
<span className="mb-1 block text-xs font-medium text-muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
@@ -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 {
|
||||
AlertTriangle,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FilePlus2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -304,6 +305,7 @@ export function Contracts() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editor, setEditor] = useState<'create' | 'edit' | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
|
||||
const [type, setType] = useState<'all' | ContractType>('all');
|
||||
const [side, setSide] = useState<'all' | ContractSide>('all');
|
||||
|
||||
@@ -322,7 +324,7 @@ export function Contracts() {
|
||||
});
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const needle = deferredQuery;
|
||||
return (contractsQuery.data ?? []).filter(({ contract, accountName }) => {
|
||||
if (type !== 'all' && contract.type !== type) return false;
|
||||
if (side !== 'all' && contract.side !== side) return false;
|
||||
@@ -333,7 +335,17 @@ export function Contracts() {
|
||||
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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -357,7 +369,15 @@ export function Contracts() {
|
||||
</Button>
|
||||
</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
|
||||
aria-label="Search contracts"
|
||||
placeholder="Search paper, account or reference"
|
||||
@@ -377,8 +397,16 @@ export function Contracts() {
|
||||
</EnumSelect>
|
||||
</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 && 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>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
@@ -462,14 +490,15 @@ export function Contracts() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{TYPE_LABELS[row.contract.type]}</Badge>
|
||||
<StatusBadge status={row.contract.status} />
|
||||
{row.contract.parentContractId ? <Badge tone="neutral">Child paper</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-2 truncate font-medium">{row.contract.title}</p>
|
||||
<p className="mt-0.5 truncate text-sm text-muted">{row.accountName}</p>
|
||||
</div>
|
||||
<ChevronRight className="shrink-0 text-muted" aria-hidden />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span className="capitalize">{row.contract.side}</span>
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] items-end gap-3 border-t border-border pt-3 text-xs text-muted">
|
||||
<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} />
|
||||
</div>
|
||||
</button>
|
||||
@@ -482,6 +511,8 @@ export function Contracts() {
|
||||
contractId={selectedId}
|
||||
detail={detailQuery.data}
|
||||
isLoading={detailQuery.isLoading}
|
||||
error={detailQuery.error instanceof Error ? detailQuery.error : null}
|
||||
onRetry={() => void detailQuery.refetch()}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedId(null);
|
||||
@@ -521,6 +552,8 @@ function ContractDetailSheet({
|
||||
contractId,
|
||||
detail,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
onOpenChange,
|
||||
onEdit,
|
||||
editing,
|
||||
@@ -531,6 +564,8 @@ function ContractDetailSheet({
|
||||
contractId: string | null;
|
||||
detail?: ContractDetail;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
onRetry(): void;
|
||||
onOpenChange(open: boolean): void;
|
||||
onEdit(): void;
|
||||
editing: boolean;
|
||||
@@ -541,7 +576,9 @@ function ContractDetailSheet({
|
||||
return (
|
||||
<Sheet open={Boolean(contractId)} onOpenChange={onOpenChange}>
|
||||
<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>
|
||||
<Skeleton className="mt-6 h-72" />
|
||||
@@ -600,8 +637,8 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button type="button" variant="primary" onClick={onEdit}>
|
||||
<div className="mt-4 grid gap-2 sm:flex sm:flex-wrap">
|
||||
<Button className="min-h-11 w-full sm:w-auto" type="button" variant="primary" onClick={onEdit}>
|
||||
<Pencil aria-hidden /> Edit terms
|
||||
</Button>
|
||||
<PiggyAskButton
|
||||
@@ -609,15 +646,15 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
|
||||
prompt="Which terms, obligations or dates need attention?"
|
||||
/>
|
||||
{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}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="summary" className="mt-5">
|
||||
<TabsList className="grid h-auto min-h-11 w-full grid-cols-3">
|
||||
<TabsTrigger className="min-h-11" value="summary">Summary</TabsTrigger>
|
||||
<TabsTrigger className="min-h-11" value="sla">Service levels</TabsTrigger>
|
||||
<TabsTrigger className="min-h-11" value="obligations">Obligations</TabsTrigger>
|
||||
<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 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 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 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>
|
||||
|
||||
<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>;
|
||||
}
|
||||
|
||||
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 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>; }
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TEAMS, TEAM_DESCRIPTIONS, TEAM_LABELS, type Team } from '@pig/core';
|
||||
import { ApiError, post, type PublicConfig } from '@/lib/api';
|
||||
import { Button, Card, CardContent, Input } from '@/components/ui';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
export function CreateProfile({
|
||||
config,
|
||||
@@ -19,6 +20,7 @@ export function CreateProfile({
|
||||
config: PublicConfig;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
usePageTitle('Join workspace');
|
||||
const [name, setName] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [team, setTeam] = useState<Team>('demand');
|
||||
@@ -56,7 +58,7 @@ export function CreateProfile({
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Set up your profile</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,9 +66,11 @@ export function CreateProfile({
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<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>
|
||||
<Input
|
||||
id="profile-name"
|
||||
name="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -75,11 +79,13 @@ export function CreateProfile({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<label className="block" htmlFor="profile-job-title">
|
||||
<span className="mb-1 block text-sm font-medium">
|
||||
Title <span className="font-normal text-muted">(optional)</span>
|
||||
</span>
|
||||
<Input
|
||||
id="profile-job-title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Head of Growth"
|
||||
@@ -122,9 +128,11 @@ export function CreateProfile({
|
||||
</fieldset>
|
||||
|
||||
{config.inviteRequired ? (
|
||||
<label className="block">
|
||||
<label className="block" htmlFor="profile-invite-code">
|
||||
<span className="mb-1 block text-sm font-medium">Invite code</span>
|
||||
<Input
|
||||
id="profile-invite-code"
|
||||
name="inviteCode"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
placeholder="Ask an administrator"
|
||||
@@ -138,7 +146,7 @@ export function CreateProfile({
|
||||
</label>
|
||||
) : 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}>
|
||||
{busy ? 'Creating…' : 'Join the workspace'}
|
||||
|
||||
@@ -101,10 +101,10 @@ export function FactReview() {
|
||||
</div>
|
||||
<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">
|
||||
Resolve Piggy's lower-confidence claims before anyone treats them as record truth.
|
||||
Inspect provenance, decide whether the evidence is credible, and keep uncertain claims out of record truth.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={items.length > 0 ? 'warning' : 'positive'}>
|
||||
<Badge tone={items.length > 0 ? 'warning' : 'positive'} aria-live="polite">
|
||||
{items.length} awaiting review
|
||||
</Badge>
|
||||
</header>
|
||||
@@ -113,7 +113,7 @@ export function FactReview() {
|
||||
<CardContent className="flex gap-3 p-4 sm:p-5">
|
||||
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
|
||||
<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">
|
||||
Approved facts remain separate from accounts and contacts. No value is overwritten
|
||||
until PIG has a field-aware applicator with conflict handling.
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
@@ -53,7 +53,7 @@ type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle';
|
||||
export function Growth() {
|
||||
usePageTitle('Growth');
|
||||
const [view, setView] = useState<GrowthView>('priority');
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['growth'],
|
||||
queryFn: () => get<GrowthReport>('/api/growth'),
|
||||
});
|
||||
@@ -65,8 +65,8 @@ export function Growth() {
|
||||
return data.customers;
|
||||
}, [data, view]);
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[32rem]" />;
|
||||
if (!data) return <EmptyState title="Growth intelligence is unavailable" description="The lifecycle projection could not be loaded." />;
|
||||
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 (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 expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length;
|
||||
@@ -74,14 +74,14 @@ export function Growth() {
|
||||
const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]">
|
||||
<header className="relative overflow-hidden rounded-2xl border border-border bg-surface px-5 py-6 sm:px-7">
|
||||
<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-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="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>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Know who to expand, renew, or protect 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-3 text-xs text-muted">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p>
|
||||
<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-xl font-semibold tracking-tight sm:text-3xl">Expand, renew, or protect the right account next.</h1>
|
||||
<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-2 text-[11px] text-muted sm:mt-3 sm:text-xs">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -91,14 +91,7 @@ export function Growth() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 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>
|
||||
|
||||
<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">
|
||||
<div role="tablist" aria-label="Growth views" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||
{([
|
||||
['priority', 'Priority'],
|
||||
['expansion', 'Expansion'],
|
||||
@@ -106,10 +99,17 @@ export function Growth() {
|
||||
['risk', 'Risk'],
|
||||
['idle', 'Idle supply'],
|
||||
] 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>
|
||||
|
||||
<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} /> : (
|
||||
customers.length ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
@@ -129,29 +129,30 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
|
||||
<CardHeader className="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="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="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="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" 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 className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div>
|
||||
</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">
|
||||
<Metric label="Open deals" value={customer.openDealCount} />
|
||||
<Metric label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} />
|
||||
<Metric label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} />
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
{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">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -133,7 +133,7 @@ export function Imports() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<header>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<Input
|
||||
type="file"
|
||||
aria-label={`Choose ${definition.label.toLocaleLowerCase()} import file`}
|
||||
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
disabled={!allowed || parse.isPending}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* is the entire value of this view.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { compactNumber, get, money, moneyExact, percent } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface MarginReport {
|
||||
@@ -38,13 +40,25 @@ interface MarginReport {
|
||||
|
||||
export function Margin() {
|
||||
usePageTitle('Margin');
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['margin'],
|
||||
queryFn: () => get<MarginReport>('/api/capacity/margin'),
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-96" />;
|
||||
if (!data || data.blocks.length === 0) {
|
||||
if (isLoading) {
|
||||
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 (
|
||||
<EmptyState
|
||||
title="No capacity to report on"
|
||||
@@ -56,7 +70,7 @@ export function Margin() {
|
||||
const t = data.totals;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||||
@@ -64,7 +78,7 @@ export function Margin() {
|
||||
</p>
|
||||
</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="Cost" value={money(t.costCents)} hint="Full commitment" />
|
||||
<Stat
|
||||
@@ -76,23 +90,29 @@ export function Margin() {
|
||||
<Stat
|
||||
label="Per sold GPU-hour"
|
||||
value={moneyExact(t.marginPerAllocatedGpuHourCents)}
|
||||
hint={`${percent(t.utilisation, 1)} utilised`}
|
||||
hint={`${percent(t.utilisation, 1)} of committed hours sold`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">By commitment</CardTitle>
|
||||
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<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>
|
||||
<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">
|
||||
<thead>
|
||||
<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 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">Utilisation</th>
|
||||
<th className="px-4 pb-2 text-right font-medium">Sellable</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 sm:px-5">Break even</th>
|
||||
</tr>
|
||||
@@ -129,22 +149,39 @@ export function Margin() {
|
||||
is technically true and reads like a bug — the same fix
|
||||
already applied on the capacity cards.
|
||||
*/}
|
||||
<td className="px-4 py-3 text-right sm:px-5">
|
||||
{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>
|
||||
<td className="px-4 py-3 text-right sm:px-5"><BreakEven value={block.breakEvenPriceCents} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</Card>
|
||||
</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>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { compactNumber, get, money, percent, relativeTime } from '@/lib/api';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface Dashboard {
|
||||
@@ -46,7 +46,7 @@ interface Dashboard {
|
||||
|
||||
export function Overview() {
|
||||
usePageTitle('Overview');
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => get<Dashboard>('/api/dashboard'),
|
||||
// The book does not change second to second, but it does change while
|
||||
@@ -66,19 +66,25 @@ export function Overview() {
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Could not load the overview"
|
||||
description={error instanceof Error ? error.message : 'Unknown error.'}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 pt-6">
|
||||
<EmptyState
|
||||
title="Could not load the overview"
|
||||
description={error instanceof Error ? error.message : 'Unknown error.'}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const m = data.margin;
|
||||
const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger';
|
||||
const firstName = data.me.name.split(' ')[0];
|
||||
const idleExposureCents = data.idleAlerts.reduce((sum, alert) => sum + alert.idleCostCents, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
|
||||
{greeting()}, {firstName}
|
||||
@@ -90,7 +96,7 @@ export function Overview() {
|
||||
</p>
|
||||
</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="Gross margin"
|
||||
value={money(m.grossMarginCents)}
|
||||
@@ -98,7 +104,7 @@ export function Overview() {
|
||||
tone={marginTone}
|
||||
/>
|
||||
<Stat
|
||||
label="Utilisation"
|
||||
label="Sold ratio"
|
||||
value={percent(m.utilisation, 1)}
|
||||
hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`}
|
||||
tone={m.utilisation < 0.6 ? 'warning' : 'default'}
|
||||
@@ -118,11 +124,17 @@ export function Overview() {
|
||||
|
||||
{data.idleAlerts.length > 0 ? (
|
||||
<Card className="border-warning/30">
|
||||
<CardHeader className="flex-row items-center gap-2 space-y-0">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" aria-hidden />
|
||||
<CardTitle className="text-base">Capacity you are paying for and not selling</CardTitle>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3 space-y-0">
|
||||
<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>
|
||||
<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>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-2">
|
||||
{data.idleAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.commitmentId}
|
||||
@@ -131,7 +143,7 @@ export function Overview() {
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{alert.name}</p>
|
||||
<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
|
||||
covered, so any further sale is upside. Printing
|
||||
@@ -154,7 +166,8 @@ export function Overview() {
|
||||
</span>
|
||||
<Link
|
||||
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
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
|
||||
|
||||
@@ -5,10 +5,23 @@ export function Piggy() {
|
||||
usePageTitle('Piggy');
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
+59
-224
@@ -1,25 +1,18 @@
|
||||
/**
|
||||
* The pipeline boards, for both sides of the market.
|
||||
*
|
||||
* A column-per-stage board on desktop; on a phone, a stage picker and a single
|
||||
* column. A horizontally scrolling eight-column board on a 390px screen is
|
||||
* technically responsive and practically unusable — you cannot see where a
|
||||
* card is going, which is the entire point of a board.
|
||||
* Stages remain ordered, but wrap into a scanable desktop grid instead of
|
||||
* hiding the back half of the funnel behind a multi-screen horizontal rail.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { Pencil, Plus, RefreshCw, Search } from 'lucide-react';
|
||||
import { get, money, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { Badge, Button, Card, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { can } from '@/lib/permissions';
|
||||
import {
|
||||
DemandDealSheet,
|
||||
SupplyDealSheet,
|
||||
type DemandDealRecord,
|
||||
type SupplyDealRecord,
|
||||
} from '@/components/RecordSheets';
|
||||
import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets';
|
||||
|
||||
interface Board<T> {
|
||||
stages: string[];
|
||||
@@ -27,237 +20,79 @@ interface Board<T> {
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
qualification: 'Qualification',
|
||||
legal: 'Legal',
|
||||
scoping: 'Scoping',
|
||||
proposal: 'Proposal',
|
||||
procurement: 'Procurement',
|
||||
poc: 'POC',
|
||||
deployment: 'Deployment',
|
||||
expansion: 'Expansion',
|
||||
closed_won: 'Closed won',
|
||||
closed_lost: 'Closed lost',
|
||||
sourced: 'Sourced',
|
||||
qualifying: 'Qualifying',
|
||||
technical_diligence: 'Technical diligence',
|
||||
financial_diligence: 'Financial diligence',
|
||||
pricing: 'Pricing',
|
||||
contracting: 'Contracting',
|
||||
onboarding: 'Onboarding',
|
||||
live: 'Live',
|
||||
renewal: 'Renewal',
|
||||
churned: 'Churned',
|
||||
rejected: 'Rejected',
|
||||
qualification: 'Qualification', legal: 'Legal', scoping: 'Scoping', proposal: 'Proposal', procurement: 'Procurement', poc: 'POC', deployment: 'Deployment', expansion: 'Expansion', closed_won: 'Closed won', closed_lost: 'Closed lost', sourced: 'Sourced', qualifying: 'Qualifying', technical_diligence: 'Technical diligence', financial_diligence: 'Financial diligence', pricing: 'Pricing', contracting: 'Contracting', onboarding: 'Onboarding', live: 'Live', renewal: 'Renewal', churned: 'Churned', rejected: 'Rejected',
|
||||
};
|
||||
|
||||
export function DemandPipeline() {
|
||||
return (
|
||||
<PipelineBoard<DemandDealRecord>
|
||||
title="Demand"
|
||||
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
|
||||
endpoint="/api/deals/demand"
|
||||
team="demand"
|
||||
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
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 ? (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <PipelineBoard<DemandDealRecord>
|
||||
title="Demand" orientation="Sell-side"
|
||||
subtitle="Selling compute and post-training. Legal sits early because paper gates delivery rather than merely closing it."
|
||||
endpoint="/api/deals/demand" team="demand"
|
||||
searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.productLine}`}
|
||||
metricLabel="Visible ACV" metricValue={(deals) => money(deals.reduce((total, deal) => total + (deal.acvCents ?? 0), 0))}
|
||||
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, 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></>}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function SupplyPipeline() {
|
||||
return (
|
||||
<PipelineBoard<SupplyDealRecord>
|
||||
title="Supply"
|
||||
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
|
||||
endpoint="/api/deals/supply"
|
||||
team="supply"
|
||||
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
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 && 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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <PipelineBoard<SupplyDealRecord>
|
||||
title="Supply" orientation="Buy-side"
|
||||
subtitle="Sourcing GPU capacity. Technical and financial diligence remain separate gates because accepting capacity is a two-key decision."
|
||||
endpoint="/api/deals/supply" team="supply"
|
||||
searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.gpuType ?? ''}`}
|
||||
metricLabel="GPU opportunity" metricValue={(deals) => `${deals.reduce((total, deal) => total + (deal.gpuCount ?? 0), 0).toLocaleString()} GPUs`}
|
||||
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, 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></>}
|
||||
/>;
|
||||
}
|
||||
|
||||
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({
|
||||
title,
|
||||
subtitle,
|
||||
endpoint,
|
||||
team,
|
||||
renderCard,
|
||||
renderSheet,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
endpoint: string;
|
||||
team: 'supply' | 'demand';
|
||||
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
|
||||
title: string; orientation: string; subtitle: string; endpoint: string; team: 'supply' | 'demand';
|
||||
searchText: (deal: T, accountName: string | null) => string; metricLabel: string; metricValue: (deals: T[]) => string;
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode;
|
||||
}) {
|
||||
usePageTitle(title);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [endpoint],
|
||||
queryFn: () => get<Board<T>>(endpoint),
|
||||
});
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
|
||||
});
|
||||
const boardQuery = useQuery({ queryKey: [endpoint], queryFn: () => get<Board<T>>(endpoint) });
|
||||
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me') });
|
||||
const writable = can(me, 'deal:write', team);
|
||||
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
|
||||
|
||||
const [activeStage, setActiveStage] = useState<string | null>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
|
||||
const filteredDeals = useMemo(() => !deferredQuery ? boardQuery.data?.deals ?? [] : (boardQuery.data?.deals ?? []).filter((row) => searchText(row.deal, row.accountName).toLocaleLowerCase().includes(deferredQuery)), [boardQuery.data?.deals, deferredQuery, searchText]);
|
||||
const byStage = useMemo(() => {
|
||||
const map = new Map<string, { deal: T; accountName: string | null }[]>();
|
||||
for (const stage of data?.stages ?? []) map.set(stage, []);
|
||||
for (const row of data?.deals ?? []) {
|
||||
map.get(row.deal.stage)?.push(row);
|
||||
}
|
||||
for (const stage of boardQuery.data?.stages ?? []) map.set(stage, []);
|
||||
for (const row of filteredDeals) map.get(row.deal.stage)?.push(row);
|
||||
return map;
|
||||
}, [data]);
|
||||
}, [boardQuery.data?.stages, filteredDeals]);
|
||||
const stages = boardQuery.data?.stages ?? [];
|
||||
const populatedStage = stages.find((stage) => (byStage.get(stage)?.length ?? 0) > 0);
|
||||
const currentStage = activeStage && stages.includes(activeStage) ? activeStage : populatedStage ?? stages[0] ?? '';
|
||||
const activeStageCount = stages.filter((stage) => (byStage.get(stage)?.length ?? 0) > 0).length;
|
||||
const sheetNode = renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record });
|
||||
|
||||
if (isLoading) return <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">
|
||||
<Header title={title} 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>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
|
||||
</div>
|
||||
);
|
||||
return <div className="space-y-5">
|
||||
<Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
|
||||
<section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
|
||||
<label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
|
||||
<PipelineStat label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><PipelineStat label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
|
||||
</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>
|
||||
{sheetNode}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
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 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 }) {
|
||||
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>;
|
||||
}
|
||||
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>; }
|
||||
|
||||
@@ -100,7 +100,10 @@ export function Register({
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Create your account</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,9 +111,11 @@ export function Register({
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<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>
|
||||
<Input
|
||||
id="register-invite-code"
|
||||
name="inviteCode"
|
||||
required
|
||||
value={form.inviteCode}
|
||||
onChange={set('inviteCode')}
|
||||
@@ -121,14 +126,16 @@ export function Register({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<label className="block" htmlFor="register-name">
|
||||
<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 className="block">
|
||||
<label className="block" htmlFor="register-email">
|
||||
<span className="mb-1 block text-sm font-medium">Email</span>
|
||||
<Input
|
||||
id="register-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
@@ -141,9 +148,11 @@ export function Register({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<label className="block" htmlFor="register-password">
|
||||
<span className="mb-1 block text-sm font-medium">Password</span>
|
||||
<Input
|
||||
id="register-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
@@ -156,11 +165,11 @@ export function Register({
|
||||
<span className="mt-1 block text-xs text-muted">At least 8 characters.</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<label className="block" htmlFor="register-title">
|
||||
<span className="mb-1 block text-sm font-medium">
|
||||
Title <span className="font-normal text-muted">(optional)</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>
|
||||
|
||||
<fieldset>
|
||||
|
||||
@@ -30,16 +30,26 @@ export function Settings() {
|
||||
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
|
||||
<div className="space-y-6 pb-4">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<Appearance />
|
||||
<Profile me={me} />
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Appearance />
|
||||
<Profile me={me} />
|
||||
</div>
|
||||
{me?.isPlatformAdmin ? <AdminSettings /> : null}
|
||||
<ConnectAgent />
|
||||
<SessionCard />
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ConnectAgent />
|
||||
<SessionCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +74,7 @@ function Appearance() {
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setMode(value as ThemeMode)}
|
||||
aria-pressed={mode === value}
|
||||
className={[
|
||||
@@ -88,6 +99,7 @@ function Appearance() {
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => setAccent(option.key)}
|
||||
aria-pressed={selected}
|
||||
aria-label={option.label}
|
||||
@@ -193,13 +205,15 @@ function Profile({ me }: { me: Me | undefined }) {
|
||||
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>
|
||||
<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 className="block">
|
||||
<label className="block" htmlFor="profile-title">
|
||||
<span className="mb-1 block text-xs font-medium text-muted">Title</span>
|
||||
<Input
|
||||
id="profile-title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Head of Compute"
|
||||
@@ -288,7 +302,7 @@ function SessionCard() {
|
||||
</p>
|
||||
</CardHeader>
|
||||
<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 />
|
||||
{busy ? 'Signing out…' : 'Sign out'}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { KeyRound, Mail } from 'lucide-react';
|
||||
import { getSupabase, type PublicConfig } from '@/lib/api';
|
||||
import { Button, Card, CardContent, Input } from '@/components/ui';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
type Method = 'password' | 'link';
|
||||
|
||||
@@ -26,6 +27,7 @@ export function SignIn({
|
||||
config: PublicConfig;
|
||||
onCreateAccount: () => void;
|
||||
}) {
|
||||
usePageTitle('Sign in');
|
||||
const [method, setMethod] = useState<Method>('password');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -105,6 +107,15 @@ export function SignIn({
|
||||
</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
|
||||
role="tablist"
|
||||
aria-label="Sign-in method"
|
||||
@@ -138,9 +149,11 @@ export function SignIn({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
id="sign-in-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
@@ -154,9 +167,11 @@ export function SignIn({
|
||||
</label>
|
||||
|
||||
{method === 'password' ? (
|
||||
<label className="block">
|
||||
<label className="block" htmlFor="sign-in-password">
|
||||
<span className="mb-1 block text-sm font-medium">Password</span>
|
||||
<Input
|
||||
id="sign-in-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
@@ -192,7 +207,7 @@ export function SignIn({
|
||||
{config.canSelfRegister ? (
|
||||
<div className="border-t border-border pt-3 text-center">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -204,7 +219,7 @@ export function SignIn({
|
||||
</div>
|
||||
) : config.inviteRequired ? (
|
||||
<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>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
info: 'hsl(var(--info))',
|
||||
},
|
||||
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'],
|
||||
},
|
||||
borderRadius: { lg: '0.75rem', xl: '1rem' },
|
||||
|
||||
Generated
+8
@@ -93,6 +93,9 @@ importers:
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@fontsource-variable/manrope':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@hookform/resolvers':
|
||||
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)
|
||||
@@ -937,6 +940,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@fontsource-variable/manrope@5.3.0':
|
||||
resolution: {integrity: sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==}
|
||||
|
||||
'@hono/node-server@1.19.17':
|
||||
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
@@ -3298,6 +3304,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@fontsource-variable/manrope@5.3.0': {}
|
||||
|
||||
'@hono/node-server@1.19.17(hono@4.13.1)':
|
||||
dependencies:
|
||||
hono: 4.13.1
|
||||
|
||||
Reference in New Issue
Block a user