This commit is contained in:
+47
-15
@@ -1,18 +1,12 @@
|
||||
/**
|
||||
* Application root: routing, data fetching, and the auth gate.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Route, Routes } 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';
|
||||
import { Overview } from '@/pages/Overview';
|
||||
import { Capacity } from '@/pages/Capacity';
|
||||
import { DemandPipeline, SupplyPipeline } from '@/pages/Pipeline';
|
||||
import { Settings } from '@/pages/Settings';
|
||||
import { Accounts } from '@/pages/Accounts';
|
||||
import { Margin } from '@/pages/Margin';
|
||||
import { SignIn } from '@/pages/SignIn';
|
||||
import { CreateProfile } from '@/pages/CreateProfile';
|
||||
import { Register } from '@/pages/Register';
|
||||
@@ -20,6 +14,18 @@ import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
||||
const Capacity = lazy(() => import('@/pages/Capacity').then(({ Capacity }) => ({ default: Capacity })));
|
||||
const DemandPipeline = lazy(() => import('@/pages/Pipeline').then(({ DemandPipeline }) => ({ default: DemandPipeline })));
|
||||
const SupplyPipeline = lazy(() => import('@/pages/Pipeline').then(({ SupplyPipeline }) => ({ default: SupplyPipeline })));
|
||||
const Settings = lazy(() => import('@/pages/Settings').then(({ Settings }) => ({ default: Settings })));
|
||||
const Accounts = lazy(() => import('@/pages/Accounts').then(({ Accounts }) => ({ default: Accounts })));
|
||||
const Margin = lazy(() => import('@/pages/Margin').then(({ Margin }) => ({ default: Margin })));
|
||||
const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) => ({ default: FactReview })));
|
||||
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
|
||||
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
|
||||
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -157,21 +163,47 @@ function AuthGate({ config }: { config: PublicConfig }) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Shell />}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="margin" element={<Margin />} />
|
||||
<Route path="capacity" element={<Capacity />} />
|
||||
<Route path="demand" element={<DemandPipeline />} />
|
||||
<Route path="supply" element={<SupplyPipeline />} />
|
||||
<Route path="accounts" element={<Accounts />} />
|
||||
<Route path="contracts" element={<Placeholder title="Contracts" />} />
|
||||
<Route index element={<RoutePage><Overview /></RoutePage>} />
|
||||
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
||||
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
||||
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
||||
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
||||
<Route path="accounts" element={<RoutePage><Accounts /></RoutePage>} />
|
||||
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
|
||||
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
|
||||
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
|
||||
<Route path="team" element={<Team />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="facts" element={<RoutePage><FactReview /></RoutePage>} />
|
||||
<Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
|
||||
<Route path="*" element={<Placeholder title="Not found" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutePage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteLoading() {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[50dvh] items-center justify-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
||||
<PiggyMark className="h-9 w-9 animate-pulse text-fg" aria-hidden />
|
||||
<span>Loading view…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Splash() {
|
||||
return (
|
||||
<Centered>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { TEAM_LABELS, TEAM_ROLES, TEAMS, type Team, type TeamRole } from '@pig/core';
|
||||
import { api, get, patch, post, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { IntegrationSettings } from './IntegrationSettings';
|
||||
|
||||
interface AdminRuntimeSettings {
|
||||
piggyModel: string;
|
||||
piggyInferenceBase: string;
|
||||
piggyEnabled: boolean;
|
||||
primeComputeBase: string;
|
||||
primeApiKey: {
|
||||
configured: boolean;
|
||||
source: 'database' | 'environment' | null;
|
||||
updatedAt: string | null;
|
||||
encryptionReady: boolean;
|
||||
};
|
||||
primeSyncEnabled: boolean;
|
||||
primeSyncIntervalMinutes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
id: string;
|
||||
email: string | null;
|
||||
team: Team | null;
|
||||
role: TeamRole;
|
||||
usesRemaining: number;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
status: 'active' | 'used' | 'expired' | 'revoked';
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
title: string | null;
|
||||
isPlatformAdmin: boolean;
|
||||
adminSource: 'environment' | 'database' | null;
|
||||
memberships: { team: Team; role: TeamRole }[];
|
||||
}
|
||||
|
||||
export function AdminSettings() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => get<AdminRuntimeSettings>('/api/admin/settings'),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
|
||||
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-on">
|
||||
<ShieldCheck aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Platform control plane</h2>
|
||||
<Badge tone="warning">Admin only</Badge>
|
||||
</div>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||||
Configure intelligence, inventory sync, workspace entry, and team authority.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="runtime" className="p-4 sm:p-6">
|
||||
<TabsList className="scroll-x flex h-auto w-full justify-start bg-surface-2 p-1 sm:w-auto sm:inline-flex">
|
||||
<TabsTrigger value="runtime" className="tap flex-1 sm:flex-none">Runtime</TabsTrigger>
|
||||
<TabsTrigger value="invites" className="tap flex-1 sm:flex-none">Invites</TabsTrigger>
|
||||
<TabsTrigger value="access" className="tap flex-1 sm:flex-none">Access</TabsTrigger>
|
||||
<TabsTrigger value="integrations" className="tap flex-1 sm:flex-none">Integrations</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="runtime" className="mt-5">
|
||||
{isLoading || !data ? <p className="text-sm text-muted">Loading runtime settings…</p> : <RuntimeForm key={data.updatedAt} settings={data} />}
|
||||
</TabsContent>
|
||||
<TabsContent value="invites" className="mt-5"><InviteManager /></TabsContent>
|
||||
<TabsContent value="access" className="mt-5"><MemberManager /></TabsContent>
|
||||
<TabsContent value="integrations" className="mt-5"><IntegrationSettings /></TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [model, setModel] = useState(settings.piggyModel);
|
||||
const [inferenceBase, setInferenceBase] = useState(settings.piggyInferenceBase);
|
||||
const [piggyEnabled, setPiggyEnabled] = useState(settings.piggyEnabled);
|
||||
const [syncEnabled, setSyncEnabled] = useState(settings.primeSyncEnabled);
|
||||
const [interval, setIntervalValue] = useState(String(settings.primeSyncIntervalMinutes));
|
||||
const [primeApiKey, setPrimeApiKey] = useState('');
|
||||
const [clearKey, setClearKey] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
patch<AdminRuntimeSettings>('/api/admin/settings', {
|
||||
piggyModel: model,
|
||||
piggyInferenceBase: inferenceBase,
|
||||
piggyEnabled,
|
||||
primeSyncEnabled: syncEnabled,
|
||||
primeSyncIntervalMinutes: Number(interval),
|
||||
...(primeApiKey ? { primeApiKey } : {}),
|
||||
...(clearKey ? { clearPrimeApiKey: true } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setPrimeApiKey('');
|
||||
setClearKey(false);
|
||||
setMessage('Runtime settings saved. Prime sync is reloading in the background.');
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-5" onSubmit={(event) => { event.preventDefault(); setMessage(null); save.mutate(); }}>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2"><Bot className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Piggy intelligence</CardTitle></div>
|
||||
<p className="text-sm text-muted">Inference is deliberately isolated from the compute API.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5" htmlFor="piggy-model">
|
||||
<span className="text-sm font-medium">Model</span>
|
||||
<Input id="piggy-model" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
<span className="text-xs text-muted">Nemotron runs tool calls with reasoning disabled to prevent think-aloud truncation.</span>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5" htmlFor="inference-base">
|
||||
<span className="text-sm font-medium">Inference endpoint</span>
|
||||
<Input id="inference-base" type="url" value={inferenceBase} onChange={(event) => setInferenceBase(event.target.value)} />
|
||||
</label>
|
||||
<ToggleRow id="piggy-enabled" label="Piggy worker" description="Allow the configured worker to process queued tasks." checked={piggyEnabled} onCheckedChange={setPiggyEnabled} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2"><RefreshCw className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Prime inventory</CardTitle></div>
|
||||
<p className="break-all text-xs text-muted">Compute endpoint: {settings.primeComputeBase}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={settings.primeApiKey.configured ? 'positive' : 'warning'}>{settings.primeApiKey.configured ? 'Credential configured' : 'Credential missing'}</Badge>
|
||||
{settings.primeApiKey.source ? <Badge>{settings.primeApiKey.source} source</Badge> : null}
|
||||
{settings.primeApiKey.updatedAt ? <span className="text-xs text-muted">updated {relativeTime(settings.primeApiKey.updatedAt)}</span> : null}
|
||||
</div>
|
||||
<label className="flex flex-col gap-1.5" htmlFor="prime-api-key">
|
||||
<span className="text-sm font-medium">Replace Prime API key</span>
|
||||
<Input id="prime-api-key" type="password" autoComplete="new-password" value={primeApiKey} onChange={(event) => { setPrimeApiKey(event.target.value); setClearKey(false); }} placeholder="Enter a new key; existing material is never shown" disabled={!settings.primeApiKey.encryptionReady} />
|
||||
<span className="text-xs text-muted">{settings.primeApiKey.encryptionReady ? 'Encrypted with AES-256-GCM before it reaches the database.' : 'Set PIG_SETTINGS_ENCRYPTION_KEY on the server to enable credential writes.'}</span>
|
||||
</label>
|
||||
{settings.primeApiKey.source === 'database' ? <Button type="button" variant={clearKey ? 'danger' : 'outline'} size="sm" onClick={() => { setClearKey((value) => !value); setPrimeApiKey(''); }}>{clearKey ? 'Credential will be cleared' : 'Clear stored credential'}</Button> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_9rem] sm:items-end">
|
||||
<ToggleRow id="prime-sync" label="Inventory sync" description="Continuously refresh Prime availability and pricing." checked={syncEnabled} onCheckedChange={setSyncEnabled} />
|
||||
<label className="flex flex-col gap-1.5" htmlFor="sync-interval"><span className="text-sm font-medium">Every (minutes)</span><Input id="sync-interval" type="number" min="1" max="1440" value={interval} onChange={(event) => setIntervalValue(event.target.value)} /></label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{save.error ? <p role="alert" className="text-sm text-danger">{save.error.message}</p> : null}
|
||||
{message ? <p className="flex items-center gap-2 text-sm text-positive"><Check aria-hidden />{message}</p> : null}
|
||||
<div><Button type="submit" variant="primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : 'Save runtime settings'}</Button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ id, label, description, checked, onCheckedChange }: { id: string; label: string; description: string; checked: boolean; onCheckedChange(value: boolean): void }) {
|
||||
return <div className="flex min-w-0 items-center justify-between gap-4 rounded-xl border border-border p-3"><div className="min-w-0"><Label htmlFor={id}>{label}</Label><p className="mt-1 text-xs text-muted">{description}</p></div><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
|
||||
}
|
||||
|
||||
function InviteManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data = [] } = useQuery({ queryKey: ['admin-invites'], queryFn: () => get<Invite[]>('/api/admin/invites') });
|
||||
const [email, setEmail] = useState('');
|
||||
const [team, setTeam] = useState<Team | 'any'>('any');
|
||||
const [role, setRole] = useState<TeamRole>('member');
|
||||
const [uses, setUses] = useState('1');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [issuedCode, setIssuedCode] = useState<string | null>(null);
|
||||
const create = useMutation({
|
||||
mutationFn: () => post<Invite & { code: string }>('/api/admin/invites', { ...(email ? { email } : {}), ...(team !== 'any' ? { team } : {}), role, usesRemaining: Number(uses), ...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}) }),
|
||||
onSuccess: (invite) => { setIssuedCode(invite.code); setEmail(''); void queryClient.invalidateQueries({ queryKey: ['admin-invites'] }); },
|
||||
});
|
||||
const revoke = useMutation({ mutationFn: (id: string) => api(`/api/admin/invites/${id}`, { method: 'DELETE' }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-invites'] }) });
|
||||
|
||||
return <div className="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]">
|
||||
<Card><CardHeader><div className="flex items-center gap-2"><UserPlus className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Issue an invite</CardTitle></div><p className="text-sm text-muted">Codes gate PIG registration. They never open registration on the shared identity provider.</p></CardHeader><CardContent><form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); setIssuedCode(null); create.mutate(); }}>
|
||||
<label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Email, optional</span><Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Pin to a known address" /></label>
|
||||
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Team</span><Select value={team} onValueChange={(value) => setTeam(value as Team | 'any')}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="any">Choose at signup</SelectItem>{TEAMS.map((value) => <SelectItem key={value} value={value}>{TEAM_LABELS[value]}</SelectItem>)}</SelectGroup></SelectContent></Select></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Role</span><Select value={role} onValueChange={(value) => setRole(value as TeamRole)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{TEAM_ROLES.map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectGroup></SelectContent></Select></label></div>
|
||||
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Uses</span><Input type="number" min="1" max="100" value={uses} onChange={(event) => setUses(event.target.value)} /></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Expires, optional</span><Input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></label></div>
|
||||
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}<Button type="submit" variant="primary" disabled={create.isPending}>{create.isPending ? 'Issuing…' : 'Issue invite'}</Button>
|
||||
{issuedCode ? <div className="rounded-xl border border-warning bg-warning/10 p-3"><p className="text-xs font-medium text-warning">Shown once. Send it through a secure channel.</p><div className="mt-2 flex min-w-0 items-center gap-2"><code className="min-w-0 flex-1 break-all text-xs">{issuedCode}</code><Button type="button" size="icon" variant="ghost" aria-label="Copy invite code" onClick={() => void navigator.clipboard.writeText(issuedCode)}><Copy aria-hidden /></Button></div></div> : null}
|
||||
</form></CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader><CardContent className="flex flex-col gap-2">{data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MemberManager() {
|
||||
const { data = [] } = useQuery({ queryKey: ['admin-members'], queryFn: () => get<Member[]>('/api/admin/members') });
|
||||
return <div className="flex flex-col gap-3"><div className="flex items-center gap-2"><Users className="text-accent-fg" aria-hidden /><div><h3 className="font-semibold">Team and role administration</h3><p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p></div></div>{data.map((member) => <MemberAccess key={`${member.id}:${JSON.stringify(member.memberships)}:${member.isPlatformAdmin}`} member={member} />)}</div>;
|
||||
}
|
||||
|
||||
function MemberAccess({ member }: { member: Member }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isPlatformAdmin, setIsPlatformAdmin] = useState(member.isPlatformAdmin);
|
||||
const [roles, setRoles] = useState<Partial<Record<Team, TeamRole>>>(() => Object.fromEntries(member.memberships.map(({ team, role }) => [team, role])));
|
||||
const save = useMutation({ mutationFn: () => patch(`/api/admin/members/${member.id}/access`, { isPlatformAdmin, memberships: TEAMS.flatMap((team) => roles[team] ? [{ team, role: roles[team] }] : []) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-members'] }) });
|
||||
return <Card><CardContent className="p-4 sm:p-5"><div className="flex flex-col gap-4 xl:flex-row xl:items-center"><div className="min-w-0 xl:w-64"><div className="flex flex-wrap items-center gap-2"><p className="truncate font-medium">{member.name}</p>{member.isPlatformAdmin ? <Badge tone="warning"><KeyRound aria-hidden />Platform admin</Badge> : null}</div><p className="truncate text-sm text-muted">{member.email}</p></div><div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">{TEAMS.map((team) => <label key={team} className="flex flex-col gap-1"><span className="text-xs font-medium text-muted">{TEAM_LABELS[team]}</span><Select value={roles[team] ?? 'none'} onValueChange={(value) => setRoles((current) => ({ ...current, [team]: value === 'none' ? undefined : value as TeamRole }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">No access</SelectItem>{TEAM_ROLES.map((role) => <SelectItem key={role} value={role}>{role}</SelectItem>)}</SelectGroup></SelectContent></Select></label>)}</div><div className="flex items-center justify-between gap-3 xl:w-52"><div><Label htmlFor={`admin-${member.id}`}>Platform admin</Label>{member.adminSource === 'environment' ? <p className="text-xs text-muted">Pinned by environment</p> : null}</div><Switch id={`admin-${member.id}`} checked={isPlatformAdmin} disabled={member.adminSource === 'environment'} onCheckedChange={setIsPlatformAdmin} /></div><Button type="button" size="sm" variant="primary" disabled={save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save access'}</Button></div>{save.error ? <p role="alert" className="mt-3 text-sm text-danger">{save.error.message}</p> : null}</CardContent></Card>;
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
CONSUMING_ALLOCATION_STATUSES,
|
||||
GUARANTEE_TYPES,
|
||||
RESERVING_ALLOCATION_STATUSES,
|
||||
} from '@pig/core';
|
||||
import { AlertTriangle, Clock3, LoaderCircle, RotateCcw, ShieldCheck } from 'lucide-react';
|
||||
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Badge, Button, Input } from '@/components/ui';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
|
||||
export interface AvailabilityRow {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
interconnectType: string;
|
||||
securityTier: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
totalGpuHours: number;
|
||||
soldGpuHours: number;
|
||||
heldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
costPerGpuHourCents: number;
|
||||
utilisation: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
export type MatchRow = AvailabilityRow & { score: number; rationale: string[] };
|
||||
|
||||
interface CommitmentRecord {
|
||||
id: string;
|
||||
shape: { intervals: string[]; quantities: number[] } | null;
|
||||
isContiguous: boolean;
|
||||
oversubscriptionPct: string | number;
|
||||
}
|
||||
|
||||
interface CommitmentRow {
|
||||
commitment: CommitmentRecord;
|
||||
accountName: string | null;
|
||||
}
|
||||
|
||||
interface DealRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
interface DemandBoard {
|
||||
deals: { deal: DealRecord; accountName: string | null }[];
|
||||
}
|
||||
|
||||
interface AllocationRecord {
|
||||
id: string;
|
||||
capacityCommitmentId: string;
|
||||
demandDealId: string | null;
|
||||
gpuHours: string | number;
|
||||
pricePerGpuHourCents: number;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
status: string;
|
||||
holdExpiresAt: string | null;
|
||||
guaranteeType: string;
|
||||
}
|
||||
|
||||
const activeReleaseStatuses = RESERVING_ALLOCATION_STATUSES.filter(
|
||||
(status) => status !== 'completed',
|
||||
);
|
||||
const createStatuses = CONSUMING_ALLOCATION_STATUSES.filter((status) => status !== 'completed');
|
||||
const numeric = z.string().refine(
|
||||
(value) => Number.isFinite(Number(value)) && Number(value) > 0,
|
||||
'Enter a number greater than zero.',
|
||||
);
|
||||
const moneyValue = z.string().refine(
|
||||
(value) => value === '' || (Number.isFinite(Number(value)) && Number(value) >= 0),
|
||||
'Enter zero or a positive amount.',
|
||||
);
|
||||
const formSchema = z
|
||||
.object({
|
||||
kind: z.enum(['allocation', 'hold']),
|
||||
capacityCommitmentId: z.string().uuid('Select a commitment.'),
|
||||
demandDealId: z.string().uuid('Select a demand deal.'),
|
||||
gpuHours: numeric,
|
||||
price: moneyValue,
|
||||
startsAt: z.string().min(1, 'Start is required.'),
|
||||
endsAt: z.string().min(1, 'End is required.'),
|
||||
holdExpiresAt: z.string(),
|
||||
status: z.enum(CONSUMING_ALLOCATION_STATUSES),
|
||||
guaranteeType: z.enum(GUARANTEE_TYPES),
|
||||
notes: z.string().max(10_000, 'Keep notes under 10,000 characters.'),
|
||||
})
|
||||
.superRefine((values, context) => {
|
||||
const startsAt = new Date(values.startsAt);
|
||||
const endsAt = new Date(values.endsAt);
|
||||
if (endsAt <= startsAt) {
|
||||
context.addIssue({ code: 'custom', path: ['endsAt'], message: 'End must be after start.' });
|
||||
}
|
||||
if (values.kind === 'allocation' && values.price === '') {
|
||||
context.addIssue({ code: 'custom', path: ['price'], message: 'Sell price is required.' });
|
||||
}
|
||||
if (values.kind === 'hold') {
|
||||
const expiresAt = new Date(values.holdExpiresAt);
|
||||
if (!values.holdExpiresAt || expiresAt <= new Date()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['holdExpiresAt'],
|
||||
message: 'A hold must expire in the future.',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
type AllocationForm = z.infer<typeof formSchema>;
|
||||
|
||||
function localDateTime(value: string | Date): string {
|
||||
const date = new Date(value);
|
||||
const offset = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function localCommitmentBound(value: string | Date, bound: 'start' | 'end'): string {
|
||||
const timestamp = new Date(value).getTime();
|
||||
const minute = 60_000;
|
||||
const rounded = bound === 'start'
|
||||
? Math.ceil(timestamp / minute) * minute
|
||||
: Math.floor(timestamp / minute) * minute;
|
||||
return localDateTime(new Date(rounded));
|
||||
}
|
||||
|
||||
function defaults(
|
||||
preferredCommitmentId?: string,
|
||||
defaultGpuHours?: number,
|
||||
): AllocationForm {
|
||||
return {
|
||||
kind: 'allocation',
|
||||
capacityCommitmentId: preferredCommitmentId ?? '',
|
||||
demandDealId: '',
|
||||
gpuHours: defaultGpuHours == null ? '' : String(defaultGpuHours),
|
||||
price: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
holdExpiresAt: localDateTime(new Date(Date.now() + 24 * 60 * 60 * 1_000)),
|
||||
status: 'committed',
|
||||
guaranteeType: 'committed',
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AllocationSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
preferredCommitmentId,
|
||||
matches,
|
||||
defaultGpuHours,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
preferredCommitmentId?: string;
|
||||
matches?: MatchRow[];
|
||||
defaultGpuHours?: number;
|
||||
onChanged?(): void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [releaseReason, setReleaseReason] = useState('');
|
||||
const [releaseError, setReleaseError] = useState<string | null>(null);
|
||||
const form = useForm<AllocationForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaults(preferredCommitmentId, defaultGpuHours),
|
||||
});
|
||||
const { data: availability, isLoading: availabilityLoading } = useQuery({
|
||||
queryKey: ['availability'],
|
||||
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: commitments } = useQuery({
|
||||
queryKey: ['commitments', 'allocation-context'],
|
||||
queryFn: () => get<CommitmentRow[]>('/api/commitments'),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: demand } = useQuery({
|
||||
queryKey: ['/api/deals/demand'],
|
||||
queryFn: () => get<DemandBoard>('/api/deals/demand'),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: allocations } = useQuery({
|
||||
queryKey: ['allocations'],
|
||||
queryFn: () => get<AllocationRecord[]>('/api/allocations'),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(defaults(preferredCommitmentId, defaultGpuHours));
|
||||
setReleaseReason('');
|
||||
setReleaseError(null);
|
||||
}, [defaultGpuHours, form, open, preferredCommitmentId]);
|
||||
|
||||
const contextIds = useMemo(
|
||||
() => (matches ? new Set(matches.map((match) => match.commitmentId)) : null),
|
||||
[matches],
|
||||
);
|
||||
const options = useMemo(
|
||||
() => (availability ?? []).filter((row) => !contextIds || contextIds.has(row.commitmentId)),
|
||||
[availability, contextIds],
|
||||
);
|
||||
const selectedId = form.watch('capacityCommitmentId');
|
||||
const selected = options.find((row) => row.commitmentId === selectedId);
|
||||
const detail = commitments?.find((row) => row.commitment.id === selectedId);
|
||||
const match = matches?.find((row) => row.commitmentId === selectedId);
|
||||
const kind = form.watch('kind');
|
||||
const quotedPriceValue = form.watch('price');
|
||||
const quotedPrice = quotedPriceValue === '' ? null : Number(quotedPriceValue);
|
||||
const dealsById = useMemo(
|
||||
() => new Map((demand?.deals ?? []).map((row) => [row.deal.id, row])),
|
||||
[demand],
|
||||
);
|
||||
const reserving = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return (allocations ?? []).filter(
|
||||
(allocation) =>
|
||||
allocation.capacityCommitmentId === selectedId &&
|
||||
activeReleaseStatuses.some((status) => status === allocation.status) &&
|
||||
!(
|
||||
allocation.status === 'planned' &&
|
||||
allocation.holdExpiresAt &&
|
||||
new Date(allocation.holdExpiresAt).getTime() <= now
|
||||
),
|
||||
);
|
||||
}, [allocations, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selected || form.getValues('startsAt')) return;
|
||||
form.setValue('startsAt', localCommitmentBound(selected.startsAt, 'start'));
|
||||
form.setValue('endsAt', localCommitmentBound(selected.endsAt, 'end'));
|
||||
}, [form, open, selected]);
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['availability'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['allocations'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['margin'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deals/demand'] }),
|
||||
]);
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: AllocationForm) => {
|
||||
const price = values.price === '' ? undefined : Number(values.price);
|
||||
const body = {
|
||||
capacityCommitmentId: values.capacityCommitmentId,
|
||||
demandDealId: values.demandDealId,
|
||||
gpuHours: Number(values.gpuHours),
|
||||
pricePerGpuHourCents: price === undefined ? undefined : Math.round(price * 100),
|
||||
startsAt: new Date(values.startsAt).toISOString(),
|
||||
endsAt: new Date(values.endsAt).toISOString(),
|
||||
guaranteeType: values.guaranteeType,
|
||||
notes: values.notes.trim() || null,
|
||||
};
|
||||
return values.kind === 'hold'
|
||||
? post<AllocationRecord>('/api/allocations/holds', {
|
||||
...body,
|
||||
holdExpiresAt: new Date(values.holdExpiresAt).toISOString(),
|
||||
})
|
||||
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await refresh();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
const release = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
post<AllocationRecord>(`/api/allocations/${id}/release`, {
|
||||
reason: releaseReason.trim() || undefined,
|
||||
}),
|
||||
onMutate: () => setReleaseError(null),
|
||||
onSuccess: refresh,
|
||||
onError: (error) => setReleaseError(errorMessage(error)),
|
||||
});
|
||||
|
||||
const chooseCommitment = (id: string) => {
|
||||
form.setValue('capacityCommitmentId', id, { shouldValidate: true });
|
||||
const row = options.find((option) => option.commitmentId === id);
|
||||
if (row) {
|
||||
form.setValue('startsAt', localCommitmentBound(row.startsAt, 'start'), { shouldValidate: true });
|
||||
form.setValue('endsAt', localCommitmentBound(row.endsAt, 'end'), { shouldValidate: true });
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
|
||||
<SheetTitle>Reserve capacity</SheetTitle>
|
||||
<SheetDescription>
|
||||
Join committed supply to a demand deal. Availability is re-checked by the server when you save.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
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="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)}
|
||||
className={
|
||||
kind === value
|
||||
? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm'
|
||||
: 'tap rounded-md px-3 text-sm font-medium text-muted'
|
||||
}
|
||||
>
|
||||
{value === 'allocation' ? 'Sell allocation' : 'Timed hold'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="capacityCommitmentId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-2">
|
||||
<FormLabel>Capacity commitment</FormLabel>
|
||||
<Select value={field.value} onValueChange={chooseCommitment}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder={availabilityLoading ? 'Loading capacity…' : 'Select capacity'} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((row) => (
|
||||
<SelectItem key={row.commitmentId} value={row.commitmentId}>
|
||||
{row.name} · {compactNumber(row.availableGpuHours)} GPU-hrs free
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{matches ? <FormDescription>Limited to the capacity returned by this match.</FormDescription> : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="demandDealId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-2">
|
||||
<FormLabel>Demand deal</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-11"><SelectValue placeholder="Select the customer deal" /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(demand?.deals ?? [])
|
||||
.filter((row) => row.deal.stage !== 'closed_lost')
|
||||
.map((row) => (
|
||||
<SelectItem key={row.deal.id} value={row.deal.id}>
|
||||
{row.deal.name} · {row.accountName ?? 'No account'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<CommitmentContext row={selected} detail={detail} match={match} quotedPrice={quotedPrice} />
|
||||
) : options.length === 0 && !availabilityLoading ? (
|
||||
<div role="status" className="rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
|
||||
No currently available commitment remains in this context. Run the matcher again before promising capacity.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Commercial reservation</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted">
|
||||
GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<TextField control={form.control} name="gpuHours" label="GPU-hours" inputMode="decimal" placeholder="2048" />
|
||||
<TextField control={form.control} name="price" label={kind === 'hold' ? 'Expected $/GPU-hr' : 'Sell $/GPU-hr'} inputMode="decimal" placeholder={kind === 'hold' ? 'Optional' : '2.75'} />
|
||||
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
|
||||
<TextField control={form.control} name="endsAt" label="Ends" type="datetime-local" />
|
||||
{kind === 'hold' ? (
|
||||
<TextField control={form.control} name="holdExpiresAt" label="Hold expires" type="datetime-local" className="sm:col-span-2" />
|
||||
) : (
|
||||
<SelectField control={form.control} name="status" label="Allocation status" options={createStatuses} />
|
||||
)}
|
||||
<SelectField control={form.control} name="guaranteeType" label="Service guarantee" options={GUARANTEE_TYPES} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-2">
|
||||
<FormLabel>Reservation notes</FormLabel>
|
||||
<FormControl><Textarea {...field} className="min-h-24 resize-y" placeholder="Commercial assumptions, caveats, or approval context." /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected ? (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Reservations on this commitment</h3>
|
||||
<p className="mt-1 text-xs text-muted">Live holds reserve capacity but remain separate from sold allocations.</p>
|
||||
</div>
|
||||
{reserving.length === 0 ? (
|
||||
<p className="rounded-lg bg-surface-2 p-4 text-sm text-muted">No live reserving allocations.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reserving.map((allocation) => {
|
||||
const deal = allocation.demandDealId ? dealsById.get(allocation.demandDealId) : undefined;
|
||||
return (
|
||||
<div key={allocation.id} className="flex flex-col gap-3 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{deal?.deal.name ?? 'Internal allocation'}</p>
|
||||
<Badge tone={allocation.status === 'planned' ? 'warning' : 'positive'}>{allocation.status === 'planned' ? 'Held' : allocation.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{compactNumber(Number(allocation.gpuHours))} GPU-hrs · {shortDate(allocation.startsAt)}–{shortDate(allocation.endsAt)}
|
||||
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="shrink-0" 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-muted">
|
||||
Release reason <span className="font-normal">Optional; recorded in the audit trail</span>
|
||||
<Input value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder="Deal changed, hold lapsed…" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{releaseError ? <ServerError message={releaseError} /> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{save.isError ? <ServerError message={errorMessage(save.error)} /> : null}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending || options.length === 0}>
|
||||
{save.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : kind === 'hold' ? <Clock3 data-icon="inline-start" aria-hidden /> : <ShieldCheck data-icon="inline-start" aria-hidden />}
|
||||
{save.isPending ? 'Checking capacity…' : kind === 'hold' ? 'Place timed hold' : 'Create allocation'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitmentContext({ row, detail, match, quotedPrice }: { row: AvailabilityRow; detail?: CommitmentRow; match?: MatchRow; quotedPrice: number | null }) {
|
||||
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
|
||||
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
|
||||
const breakEvenDollars = row.breakEvenPriceCents == null ? null : row.breakEvenPriceCents / 100;
|
||||
const delta = quotedPrice != null && Number.isFinite(quotedPrice) && quotedPrice >= 0 && breakEvenDollars != null
|
||||
? quotedPrice - breakEvenDollars
|
||||
: null;
|
||||
const shape = detail?.commitment.shape;
|
||||
return (
|
||||
<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="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="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||
<div className="bg-accent/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
|
||||
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
|
||||
<div><p className="text-muted">Held</p><p className="nums mt-0.5 font-medium">{compactNumber(row.heldGpuHours)} hrs</p></div>
|
||||
<div><p className="text-muted">Available</p><p className="nums mt-0.5 font-medium">{compactNumber(row.availableGpuHours)} hrs</p></div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted">Contract window</dt><dd className="text-right">{shortDate(row.startsAt)}–{shortDate(row.endsAt)}</dd>
|
||||
<dt className="text-muted">Capacity shape</dt><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{money(row.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${money(row.breakEvenPriceCents)}/GPU-hr`}</dd>
|
||||
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><dt className="text-muted">Recorded oversubscription</dt><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
|
||||
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{money(Math.round(delta * 100))}/GPU-hr</dd></> : null}
|
||||
</dl>
|
||||
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-xs text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
|
||||
<p className="mt-4 text-[11px] leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField<T extends FieldValues>({ control, name, label, className, ...props }: { control: Control<T>; name: FieldPath<T>; label: string; className?: string } & Omit<React.ComponentProps<typeof Input>, 'name' | 'value' | 'defaultValue'>) {
|
||||
return <FormField control={control} name={name} render={({ field }) => <FormItem className={className}><FormLabel>{label}</FormLabel><FormControl><Input {...field} {...props} value={String(field.value ?? '')} /></FormControl><FormMessage /></FormItem>} />;
|
||||
}
|
||||
|
||||
function SelectField<T extends FieldValues>({ control, name, label, options }: { control: Control<T>; name: FieldPath<T>; label: string; options: readonly string[] }) {
|
||||
return <FormField control={control} name={name} render={({ field }) => <FormItem><FormLabel>{label}</FormLabel><Select value={String(field.value)} onValueChange={field.onChange}><FormControl><SelectTrigger className="h-11"><SelectValue /></SelectTrigger></FormControl><SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option}>{option.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase())}</SelectItem>)}</SelectGroup></SelectContent></Select><FormMessage /></FormItem>} />;
|
||||
}
|
||||
|
||||
function ServerError({ message }: { message: string }) {
|
||||
return <div role="alert" className="flex gap-3 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) return error.message;
|
||||
return error instanceof Error ? error.message : 'The reservation could not be saved.';
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
export interface CommandDestination {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
destinations,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
destinations: readonly CommandDestination[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Go to a page…" />
|
||||
<CommandList>
|
||||
<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>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
emptyMessage?: string;
|
||||
filterColumn?: string;
|
||||
filterPlaceholder?: string;
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage = 'No results.',
|
||||
filterColumn,
|
||||
filterPlaceholder = 'Filter results',
|
||||
initialColumnVisibility = {},
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility,
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { sorting, columnFilters, columnVisibility },
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
const activeFilter = filterColumn ? table.getColumn(filterColumn) : undefined;
|
||||
const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{activeFilter ? (
|
||||
<Input
|
||||
type="search"
|
||||
value={(activeFilter.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => activeFilter.setFilterValue(event.target.value)}
|
||||
placeholder={filterPlaceholder}
|
||||
aria-label={filterPlaceholder}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="tap sm:ml-auto">
|
||||
<SlidersHorizontal data-icon="inline-start" aria-hidden />
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Visible columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{hideableColumns.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(visible) => column.toggleVisibility(Boolean(visible))}
|
||||
>
|
||||
{columnLabel(column.id)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="scroll-x rounded-xl border border-border bg-surface">
|
||||
<Table className="min-w-[44rem]">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={Math.max(table.getVisibleLeafColumns().length, 1)} className="h-28 text-center text-muted">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted" aria-live="polite">
|
||||
{table.getFilteredRowModel().rows.length} result
|
||||
{table.getFilteredRowModel().rows.length === 1 ? '' : 's'} · Page{' '}
|
||||
{table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
||||
<Select
|
||||
value={String(table.getState().pagination.pageSize)}
|
||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-[7.5rem]" aria-label="Rows per page">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{[10, 20, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={String(pageSize)}>
|
||||
{pageSize} rows
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="tap"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="tap"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}) {
|
||||
if (!column.getCanSort()) return <span>{title}</span>;
|
||||
|
||||
const direction = column.getIsSorted();
|
||||
const SortIcon = direction === 'asc' ? ArrowUp : direction === 'desc' ? ArrowDown : ArrowUpDown;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3"
|
||||
onClick={() => column.toggleSorting(direction === 'asc')}
|
||||
aria-label={`Sort by ${title}${direction ? `, currently ${direction}ending` : ''}`}
|
||||
>
|
||||
{title}
|
||||
<SortIcon data-icon="inline-end" aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function columnLabel(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^./, (character) => character.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, FileSpreadsheet, LoaderCircle, Search, Unplug } from 'lucide-react';
|
||||
import { ApiError, api, get, post, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface GoogleParsedTable {
|
||||
fileName: string;
|
||||
sheetName: string | null;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface ConnectionStatus {
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
connectedAt: string | null;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
interface DriveFile {
|
||||
id: string;
|
||||
name: string;
|
||||
modifiedTime: string | null;
|
||||
}
|
||||
|
||||
interface FilePage {
|
||||
files: DriveFile[];
|
||||
nextPageToken: string | null;
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
interface SpreadsheetMetadata {
|
||||
title: string;
|
||||
sheets: { sheetId: number; title: string; rowCount: number; columnCount: number }[];
|
||||
}
|
||||
|
||||
export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedTable): void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageToken, setPageToken] = useState<string | null>(null);
|
||||
const [previousTokens, setPreviousTokens] = useState<(string | null)[]>([]);
|
||||
const [spreadsheetId, setSpreadsheetId] = useState('');
|
||||
const [sheetId, setSheetId] = useState('');
|
||||
const [range, setRange] = useState('A1:Z2001');
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['google-sheets', 'status'],
|
||||
queryFn: () => get<ConnectionStatus>('/api/imports/google/status'),
|
||||
});
|
||||
const connect = useMutation({
|
||||
mutationFn: () => post<{ authorizationUrl: string }>('/api/imports/google/connect', {}),
|
||||
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => api<void>('/api/imports/google/connection', { method: 'DELETE' }),
|
||||
onSuccess: async () => {
|
||||
setSpreadsheetId('');
|
||||
setSheetId('');
|
||||
await queryClient.invalidateQueries({ queryKey: ['google-sheets'] });
|
||||
},
|
||||
});
|
||||
const files = useQuery({
|
||||
queryKey: ['google-sheets', 'files', search, pageToken],
|
||||
queryFn: () => {
|
||||
const parameters = new URLSearchParams();
|
||||
if (search) parameters.set('search', search);
|
||||
if (pageToken) parameters.set('pageToken', pageToken);
|
||||
return get<FilePage>(`/api/imports/google/files?${parameters}`);
|
||||
},
|
||||
enabled: status?.connected === true,
|
||||
});
|
||||
const metadata = useQuery({
|
||||
queryKey: ['google-sheets', 'metadata', spreadsheetId],
|
||||
queryFn: () => get<SpreadsheetMetadata>(`/api/imports/google/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets`),
|
||||
enabled: Boolean(spreadsheetId),
|
||||
});
|
||||
const load = useMutation({
|
||||
mutationFn: () => post<GoogleParsedTable>('/api/imports/google/table', {
|
||||
spreadsheetId,
|
||||
sheetId: Number(sheetId),
|
||||
range,
|
||||
}),
|
||||
onSuccess: onLoaded,
|
||||
});
|
||||
|
||||
if (statusLoading) return <Skeleton className="h-44" />;
|
||||
if (!status?.configured) {
|
||||
return <Card><EmptyState icon={<FileSpreadsheet className="size-8" />} title="Google Sheets is not configured" description="An operator must configure the Google OAuth client and PIG settings encryption key before connecting." /></Card>;
|
||||
}
|
||||
if (!status.connected) {
|
||||
return (
|
||||
<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()}>
|
||||
{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <ExternalLink data-icon="inline-start" aria-hidden />}
|
||||
Connect Google
|
||||
</Button>
|
||||
{connect.isError ? <ErrorText error={connect.error} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSheet = metadata.data?.sheets.find((sheet) => String(sheet.sheetId) === sheetId);
|
||||
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>
|
||||
<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}
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Choose a spreadsheet</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<form className="flex gap-2" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSearch(searchDraft.trim());
|
||||
setPageToken(null);
|
||||
setPreviousTokens([]);
|
||||
}}>
|
||||
<Input 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-accent 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{files.data?.incomplete ? <p role="alert" className="text-xs text-warning">Google reported an incomplete Drive search. Narrow the spreadsheet name and search again.</p> : null}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" disabled={previousTokens.length === 0} onClick={() => {
|
||||
setPreviousTokens((tokens) => {
|
||||
const next = [...tokens];
|
||||
setPageToken(next.pop() ?? null);
|
||||
return next;
|
||||
});
|
||||
}}><ChevronLeft data-icon="inline-start" aria-hidden />Previous</Button>
|
||||
<Badge tone="neutral">Up to 50 per page</Badge>
|
||||
<Button variant="outline" disabled={!files.data?.nextPageToken} onClick={() => {
|
||||
if (!files.data?.nextPageToken) return;
|
||||
setPreviousTokens((tokens) => [...tokens, pageToken]);
|
||||
setPageToken(files.data.nextPageToken);
|
||||
}}>Next<ChevronRight data-icon="inline-end" aria-hidden /></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{spreadsheetId ? (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Choose a sheet and bounded range</CardTitle><p className="text-xs text-muted">A rectangular A1 range must include one header row and at most 2,000 data rows by 100 columns.</p></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm font-medium">Sheet
|
||||
<Select value={sheetId} onValueChange={setSheetId} disabled={metadata.isLoading}>
|
||||
<SelectTrigger className="h-11"><SelectValue placeholder={metadata.isLoading ? 'Loading sheets…' : 'Select visible sheet'} /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>{metadata.data?.sheets.map((sheet) => <SelectItem key={sheet.sheetId} value={String(sheet.sheetId)}>{sheet.title} · {sheet.rowCount}×{sheet.columnCount}</SelectItem>)}</SelectGroup></SelectContent>
|
||||
</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} />
|
||||
</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}
|
||||
<Button className="sm:col-span-2" variant="primary" disabled={!sheetId || !range.trim() || load.isPending} onClick={() => load.mutate()}>
|
||||
{load.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <FileSpreadsheet data-icon="inline-start" aria-hidden />}
|
||||
{load.isPending ? 'Reading bounded cells…' : 'Use this range'}
|
||||
</Button>
|
||||
{load.isError ? <div className="sm:col-span-2"><ErrorText error={load.error} /></div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorText({ error }: { error: unknown }) {
|
||||
return <p role="alert" className="text-sm text-danger">{error instanceof ApiError || error instanceof Error ? error.message : 'Google Sheets request failed.'}</p>;
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
BellRing,
|
||||
Check,
|
||||
CircleAlert,
|
||||
Hash,
|
||||
Link2,
|
||||
RadioTower,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { NOTIFICATION_KINDS, type NotificationKind } from '@pig/core';
|
||||
import { api, get, post } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface IntegrationReadiness {
|
||||
slack: {
|
||||
source: 'environment';
|
||||
configured: boolean;
|
||||
deliveryReady: boolean;
|
||||
commandsReady: boolean;
|
||||
};
|
||||
buzz: {
|
||||
source: 'environment';
|
||||
configured: boolean;
|
||||
deliveryReady: boolean;
|
||||
relayUrl: string | null;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
side: 'supply' | 'demand' | 'both';
|
||||
}
|
||||
|
||||
interface ChannelLink {
|
||||
id: string;
|
||||
platform: 'slack' | 'buzz';
|
||||
workspaceId: string;
|
||||
channelId: string;
|
||||
channelName: string | null;
|
||||
accountId: string | null;
|
||||
notifyOn: NotificationKind[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface LinkedChannel {
|
||||
link: ChannelLink;
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
type Provider = 'slack' | 'buzz';
|
||||
|
||||
export function IntegrationSettings() {
|
||||
const readiness = useQuery({
|
||||
queryKey: ['integration-readiness'],
|
||||
queryFn: () => get<IntegrationReadiness>('/api/admin/integrations'),
|
||||
});
|
||||
const accounts = useQuery({
|
||||
queryKey: ['accounts', 'integration-links'],
|
||||
queryFn: () => get<Account[]>('/api/accounts'),
|
||||
});
|
||||
const slackLinks = useQuery({
|
||||
queryKey: ['integration-links', 'slack'],
|
||||
queryFn: () => get<LinkedChannel[]>('/api/integrations/slack/channel-links'),
|
||||
});
|
||||
const buzzLinks = useQuery({
|
||||
queryKey: ['integration-links', 'buzz'],
|
||||
queryFn: () => get<LinkedChannel[]>('/api/integrations/buzz/channel-links'),
|
||||
enabled: Boolean(readiness.data?.buzz.configured),
|
||||
});
|
||||
|
||||
if (readiness.isLoading || !readiness.data) {
|
||||
return <p className="text-sm text-muted">Loading integration readiness…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ProviderCard
|
||||
provider="slack"
|
||||
title="Slack"
|
||||
description="Pipeline movement and idle spend, delivered where the account is discussed."
|
||||
configured={readiness.data.slack.configured}
|
||||
details={[
|
||||
['Outbound delivery', readiness.data.slack.deliveryReady],
|
||||
['Signed capacity command', readiness.data.slack.commandsReady],
|
||||
]}
|
||||
/>
|
||||
<ProviderCard
|
||||
provider="buzz"
|
||||
title="Buzz"
|
||||
description="Signed PIG events published as the configured workspace identity."
|
||||
configured={readiness.data.buzz.configured}
|
||||
details={[["Signed relay delivery", readiness.data.buzz.deliveryReady]]}
|
||||
metadata={readiness.data.buzz.relayUrl ?? 'Set BUZZ_RELAY_URL on the server'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ChannelLinkManager
|
||||
provider="slack"
|
||||
configured={readiness.data.slack.configured}
|
||||
accounts={accounts.data ?? []}
|
||||
links={slackLinks.data ?? []}
|
||||
linksLoading={slackLinks.isLoading}
|
||||
/>
|
||||
<ChannelLinkManager
|
||||
provider="buzz"
|
||||
configured={readiness.data.buzz.configured}
|
||||
workspaceId={readiness.data.buzz.workspaceId ?? undefined}
|
||||
accounts={accounts.data ?? []}
|
||||
links={buzzLinks.data ?? []}
|
||||
linksLoading={buzzLinks.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="flex items-start gap-2 text-xs text-muted">
|
||||
<CircleAlert className="mt-0.5 shrink-0" aria-hidden />
|
||||
Credentials are environment-only. This page receives readiness signals, never token,
|
||||
signing-secret, private-key, or owner-attestation material.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
title,
|
||||
description,
|
||||
configured,
|
||||
details,
|
||||
metadata,
|
||||
}: {
|
||||
provider: Provider;
|
||||
title: string;
|
||||
description: string;
|
||||
configured: boolean;
|
||||
details: [string, boolean][];
|
||||
metadata?: string;
|
||||
}) {
|
||||
const Icon = provider === 'slack' ? Hash : RadioTower;
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b border-border bg-surface-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg">
|
||||
<Icon aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={configured ? 'positive' : 'warning'}>
|
||||
{configured ? 'Ready' : 'Needs server config'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-5">
|
||||
{details.map(([label, ready]) => (
|
||||
<div key={label} className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
||||
<span>{label}</span>
|
||||
<span className={ready ? 'text-positive' : 'text-muted'}>
|
||||
{ready ? 'Configured' : 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{metadata ? <p className="break-all text-xs text-muted">{metadata}</p> : null}
|
||||
<p className="text-xs text-muted">Source: environment</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelLinkManager({
|
||||
provider,
|
||||
configured,
|
||||
workspaceId,
|
||||
accounts,
|
||||
links,
|
||||
linksLoading,
|
||||
}: {
|
||||
provider: Provider;
|
||||
configured: boolean;
|
||||
workspaceId?: string;
|
||||
accounts: Account[];
|
||||
links: LinkedChannel[];
|
||||
linksLoading: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [workspace, setWorkspace] = useState(workspaceId ?? '');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [accountId, setAccountId] = useState('');
|
||||
const [notifyOn, setNotifyOn] = useState<NotificationKind[]>([...NOTIFICATION_KINDS]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
post<ChannelLink>(`/api/integrations/${provider}/channel-links`, {
|
||||
...(provider === 'slack' ? { workspaceId: workspace } : {}),
|
||||
channelId,
|
||||
channelName: channelName.trim() || null,
|
||||
accountId,
|
||||
notifyOn,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setChannelId('');
|
||||
setChannelName('');
|
||||
setMessage('Channel linked.');
|
||||
void queryClient.invalidateQueries({ queryKey: ['integration-links', provider] });
|
||||
},
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api<{ deleted: true }>(`/api/integrations/${provider}/channel-links/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setMessage('Channel unlinked. Pending deliveries were cancelled.');
|
||||
void queryClient.invalidateQueries({ queryKey: ['integration-links', provider] });
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setMessage(null);
|
||||
create.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="text-accent-fg" aria-hidden />
|
||||
<CardTitle className="text-base">
|
||||
{provider === 'slack' ? 'Slack channel links' : 'Buzz channel links'}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Link an account to the room where its commercial decisions happen.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{!configured ? (
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-sm text-muted">
|
||||
Configure this provider on the server before creating links.
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={submit}>
|
||||
{provider === 'slack' ? (
|
||||
<label className="flex flex-col gap-1.5" htmlFor="slack-workspace">
|
||||
<span className="text-sm font-medium">Workspace ID</span>
|
||||
<Input
|
||||
id="slack-workspace"
|
||||
value={workspace}
|
||||
onChange={(event) => setWorkspace(event.target.value)}
|
||||
placeholder="T0123456789"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Relay workspace</span>
|
||||
<div className="flex min-h-11 items-center rounded-lg border border-border bg-surface-2 px-3 text-sm text-muted">
|
||||
{workspaceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex min-w-0 flex-col gap-1.5" htmlFor={`${provider}-channel-id`}>
|
||||
<span className="text-sm font-medium">
|
||||
{provider === 'slack' ? 'Channel ID' : 'Channel UUID'}
|
||||
</span>
|
||||
<Input
|
||||
id={`${provider}-channel-id`}
|
||||
value={channelId}
|
||||
onChange={(event) => setChannelId(event.target.value)}
|
||||
placeholder={provider === 'slack' ? 'C0123456789' : '00000000-0000-…'}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-col gap-1.5" htmlFor={`${provider}-channel-name`}>
|
||||
<span className="text-sm font-medium">Display name</span>
|
||||
<Input
|
||||
id={`${provider}-channel-name`}
|
||||
value={channelName}
|
||||
onChange={(event) => setChannelName(event.target.value)}
|
||||
placeholder="gpu-sales"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`${provider}-account`}>Account</Label>
|
||||
<Select value={accountId} onValueChange={setAccountId} required>
|
||||
<SelectTrigger id={`${provider}-account`} className="h-11">
|
||||
<SelectValue placeholder="Select an account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
{account.name} · {account.side}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<legend className="text-sm font-medium">Notify this channel</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{NOTIFICATION_KINDS.map((kind) => {
|
||||
const checked = notifyOn.includes(kind);
|
||||
return (
|
||||
<Label
|
||||
key={kind}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-3 rounded-lg border border-border px-3"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => {
|
||||
setNotifyOn((current) =>
|
||||
value ? [...current, kind] : current.filter((item) => item !== kind),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{kind === 'stage_change' ? 'Stage changes' : 'Idle-capacity alerts'}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}
|
||||
{message ? <p className="flex items-center gap-2 text-sm text-positive"><Check aria-hidden />{message}</p> : null}
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={create.isPending || !accountId || notifyOn.length === 0}
|
||||
>
|
||||
<BellRing aria-hidden />
|
||||
{create.isPending ? 'Linking…' : 'Link channel'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted">Linked channels</h4>
|
||||
{linksLoading ? <p className="text-sm text-muted">Loading links…</p> : null}
|
||||
{!linksLoading && links.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted">
|
||||
No {provider === 'slack' ? 'Slack' : 'Buzz'} channels linked yet.
|
||||
</p>
|
||||
) : null}
|
||||
{links.map(({ link, accountName }) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{link.channelName ? `#${link.channelName}` : link.channelId}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted">{accountName}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{link.notifyOn.map((kind) => (
|
||||
<Badge key={kind}>{kind === 'stage_change' ? 'Stages' : 'Idle capacity'}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="icon"
|
||||
aria-label={`Unlink ${link.channelName ?? link.channelId}`}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(link.id)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, Link2, LoaderCircle, Unplug } from 'lucide-react';
|
||||
import { api, get, post } from '@/lib/api';
|
||||
import { Badge, Button } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface ImportedTable {
|
||||
fileName: string;
|
||||
sheetName: string | null;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
warnings: string[];
|
||||
unsupportedProperties?: { name: string; type: string; reason: string }[];
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
workspaceIcon: string | null;
|
||||
connectedAt: string;
|
||||
}
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
connections: Connection[];
|
||||
}
|
||||
|
||||
interface DataSource {
|
||||
id: string;
|
||||
databaseId: string | null;
|
||||
name: string;
|
||||
url: string | null;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export function NotionImportSource({
|
||||
disabled,
|
||||
onTable,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onTable(table: ImportedTable): void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [connectionId, setConnectionId] = useState('');
|
||||
const [dataSourceId, setDataSourceId] = useState('');
|
||||
const status = useQuery({
|
||||
queryKey: ['notion-import-status'],
|
||||
queryFn: () => get<Status>('/api/imports/notion/status'),
|
||||
enabled: !disabled,
|
||||
});
|
||||
const selectedConnection = connectionId || status.data?.connections[0]?.id || '';
|
||||
const dataSources = useQuery({
|
||||
queryKey: ['notion-data-sources', selectedConnection],
|
||||
queryFn: () => get<{ dataSources: DataSource[] }>(
|
||||
`/api/imports/notion/connections/${selectedConnection}/data-sources`,
|
||||
),
|
||||
enabled: Boolean(selectedConnection),
|
||||
});
|
||||
const connect = useMutation({
|
||||
mutationFn: () => post<{ authorizationUrl: string }>('/api/imports/notion/oauth/start', {}),
|
||||
onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl),
|
||||
});
|
||||
const materialize = useMutation({
|
||||
mutationFn: () => post<ImportedTable>(
|
||||
`/api/imports/notion/connections/${selectedConnection}/materialize`,
|
||||
{ dataSourceId },
|
||||
),
|
||||
onSuccess: onTable,
|
||||
});
|
||||
const disconnect = useMutation({
|
||||
mutationFn: (id: string) => api(`/api/imports/notion/connections/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
setConnectionId('');
|
||||
setDataSourceId('');
|
||||
void queryClient.invalidateQueries({ queryKey: ['notion-import-status'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (status.data && !status.data.configured) {
|
||||
return <div className="rounded-xl border border-dashed border-border p-4"><p className="text-sm font-medium">Notion is not configured</p><p className="mt-1 text-xs text-muted">An operator must set the Notion OAuth environment variables and encryption key on the API server.</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-2/40 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-border bg-surface"><Database className="size-5" aria-hidden /></span>
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><p className="font-medium">Notion database</p>{status.data?.connected ? <Badge tone="positive">Connected</Badge> : null}</div><p className="mt-0.5 text-xs text-muted">Choose a shared data source, then map it through the same dry run as a spreadsheet.</p></div>
|
||||
</div>
|
||||
{!status.data?.connected ? <Button className="min-h-11" type="button" variant="outline" disabled={disabled || connect.isPending || !status.data?.configured} onClick={() => connect.mutate()}>{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Link2 data-icon="inline-start" aria-hidden />}Connect Notion</Button> : null}
|
||||
</div>
|
||||
{status.data?.connected ? <div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_auto_auto] lg:items-end">
|
||||
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Workspace<Select value={selectedConnection} onValueChange={(value) => { setConnectionId(value); setDataSourceId(''); }}><SelectTrigger className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{status.data.connections.map((connection) => <SelectItem key={connection.id} value={connection.id}>{connection.workspaceIcon ? `${connection.workspaceIcon} ` : ''}{connection.workspaceName ?? connection.workspaceId}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
|
||||
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Database<Select value={dataSourceId} onValueChange={setDataSourceId} disabled={dataSources.isLoading}><SelectTrigger className="h-11"><SelectValue placeholder={dataSources.isLoading ? 'Loading databases…' : 'Choose a database'} /></SelectTrigger><SelectContent><SelectGroup>{(dataSources.data?.dataSources ?? []).map((source) => <SelectItem key={source.id} value={source.id}>{source.icon ? `${source.icon} ` : ''}{source.name}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
|
||||
<Button className="min-h-11" type="button" variant="primary" disabled={!dataSourceId || materialize.isPending} onClick={() => materialize.mutate()}>{materialize.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Database data-icon="inline-start" aria-hidden />}{materialize.isPending ? 'Reading…' : 'Use database'}</Button>
|
||||
<Button className="min-h-11" type="button" variant="ghost" disabled={disconnect.isPending} onClick={() => disconnect.mutate(selectedConnection)}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
|
||||
</div> : null}
|
||||
{connect.error || dataSources.error || materialize.error || disconnect.error ? <p role="alert" className="mt-3 text-sm text-danger">{errorMessage(connect.error ?? dataSources.error ?? materialize.error ?? disconnect.error)}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'The Notion request failed.';
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
CircleStop,
|
||||
Database,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
Send,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { get } from '@/lib/api';
|
||||
import {
|
||||
streamPiggyChat,
|
||||
type PiggyChatContext,
|
||||
type PiggyChatEvent,
|
||||
type PiggyChatTurn,
|
||||
type PiggyStatus,
|
||||
} from '@/lib/piggy-chat';
|
||||
import { Badge, Button, EmptyState, cn } from './ui';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from './ui/drawer';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from './ui/sheet';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
state: 'running' | 'succeeded' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TranscriptMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
tools?: ToolStep[];
|
||||
error?: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export function PiggyAskButton({
|
||||
context,
|
||||
prompt,
|
||||
label = 'Ask Piggy',
|
||||
variant = 'outline',
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
prompt?: string;
|
||||
label?: string;
|
||||
variant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = usePiggyStatus();
|
||||
const unavailable = status.data && !status.data.canUse;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
disabled={Boolean(unavailable)}
|
||||
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<MessageCircleMore aria-hidden />
|
||||
{label}
|
||||
</Button>
|
||||
<ResponsivePiggyChat
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
context={context}
|
||||
initialPrompt={prompt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PiggyChatWorkspace() {
|
||||
const status = usePiggyStatus();
|
||||
if (status.isLoading) return <div className="h-96 animate-pulse rounded-xl bg-surface-2" />;
|
||||
if (!status.data?.canUse) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Bot />}
|
||||
title="Piggy is unavailable"
|
||||
description={
|
||||
status.data?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable Piggy and connect the internal service.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
|
||||
}
|
||||
|
||||
function ResponsivePiggyChat({
|
||||
open,
|
||||
onOpenChange,
|
||||
context,
|
||||
initialPrompt,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
}) {
|
||||
const desktop = useDesktop();
|
||||
if (desktop) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b border-border px-5 py-4">
|
||||
<SheetTitle>Ask Piggy</SheetTitle>
|
||||
<SheetDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[92dvh]">
|
||||
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
|
||||
<DrawerTitle>Ask Piggy</DrawerTitle>
|
||||
<DrawerDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function PiggyChatPanel({
|
||||
context,
|
||||
initialPrompt = '',
|
||||
className,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
const [running, setRunning] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: running ? 'auto' : 'smooth' }), [messages, running]);
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || running) return;
|
||||
const user: TranscriptMessage = { id: crypto.randomUUID(), role: 'user', content: message };
|
||||
const assistantId = crypto.randomUUID();
|
||||
const history: PiggyChatTurn[] = messages
|
||||
.filter((entry) => entry.content.trim())
|
||||
.slice(-20)
|
||||
.map((entry) => ({ role: entry.role, content: entry.content }));
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
user,
|
||||
{ id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
|
||||
]);
|
||||
setDraft('');
|
||||
setRunning(true);
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId ? applyEvent(entry, event) : entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId
|
||||
? { ...entry, pending: false, error: error instanceof Error ? error.message : 'Piggy chat failed.' }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">
|
||||
{messages.length === 0 ? (
|
||||
<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>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(context
|
||||
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
|
||||
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
|
||||
).map((suggestion) => (
|
||||
<button key={suggestion} type="button" className="min-h-11 rounded-lg border border-border px-3 text-left text-sm hover:bg-surface-2" onClick={() => setDraft(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => <ChatMessage key={message.id} message={message} />)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="border-t border-border bg-surface p-3 sm:p-4" onSubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {context.label ?? context.type.replaceAll('_', ' ')}</Badge> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 max-h-36 resize-none"
|
||||
placeholder="Ask about capacity, margin, paper or next actions…"
|
||||
aria-label="Message Piggy"
|
||||
/>
|
||||
{running ? (
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={() => abortRef.current?.abort()}><CircleStop aria-hidden /></Button>
|
||||
) : (
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-accent px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg"><Bot aria-hidden /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.reasoning ? (
|
||||
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2 font-medium"><Brain aria-hidden /> Reasoning</summary>
|
||||
<p className="whitespace-pre-wrap px-3 pb-3">{message.reasoning}</p>
|
||||
</details>
|
||||
) : null}
|
||||
{message.tools?.length ? <ToolTimeline tools={message.tools} /> : null}
|
||||
{message.content ? <p className="whitespace-pre-wrap text-sm leading-6">{message.content}</p> : null}
|
||||
{message.pending && !message.content ? <div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div> : null}
|
||||
{message.error ? <div className="mt-2 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolTimeline({ tools }: { tools: ToolStep[] }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
{tools.map((tool) => (
|
||||
<details key={tool.id} className="rounded-lg border border-border text-xs">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2">
|
||||
{tool.state === 'running' ? <Loader2 className="animate-spin text-muted" aria-hidden /> : tool.state === 'succeeded' ? <CheckCircle2 className="text-positive" aria-hidden /> : <XCircle className="text-danger" aria-hidden />}
|
||||
<span className="font-medium">{toolLabel(tool.name)}</span>
|
||||
<span className="ml-auto text-muted">{tool.state === 'running' ? 'Running' : tool.state === 'succeeded' ? 'Complete' : 'Failed'}</span>
|
||||
</summary>
|
||||
<pre className="overflow-x-auto border-t border-border p-3 text-[11px] text-muted">{tool.error ?? JSON.stringify(tool.arguments, null, 2)}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
|
||||
if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
|
||||
if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
|
||||
if (event.type === 'tool_call') return { ...message, tools: [...(message.tools ?? []), { id: event.id, name: event.name, arguments: event.arguments, state: 'running' }] };
|
||||
if (event.type === 'tool_result') return { ...message, tools: (message.tools ?? []).map((tool) => tool.id === event.id ? { ...tool, state: event.ok ? 'succeeded' : 'failed', error: event.error } : tool) };
|
||||
if (event.type === 'done') return { ...message, pending: false };
|
||||
if (event.type === 'error') return { ...message, pending: false, error: event.message };
|
||||
return message;
|
||||
}
|
||||
|
||||
function usePiggyStatus() {
|
||||
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
|
||||
}
|
||||
|
||||
function useDesktop(): boolean {
|
||||
const [desktop, setDesktop] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(min-width: 768px)');
|
||||
const update = () => setDesktop(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
return desktop;
|
||||
}
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ACCOUNT_SIDES,
|
||||
AFFILIATION_KINDS,
|
||||
CUSTOMER_SEGMENTS,
|
||||
DEMAND_STAGE_LABELS,
|
||||
DEMAND_STAGES,
|
||||
INTERCONNECT_TYPES,
|
||||
PRODUCT_LINES,
|
||||
SUPPLIER_TYPES,
|
||||
SUPPLY_STAGE_LABELS,
|
||||
SUPPLY_STAGES,
|
||||
type AccountSide,
|
||||
} from '@pig/core';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, get, patch, post } from '@/lib/api';
|
||||
import { can, type PermissionIdentity } from '@/lib/permissions';
|
||||
|
||||
export interface AccountRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
website: string | null;
|
||||
description: string | null;
|
||||
side: AccountSide;
|
||||
supplierType: (typeof SUPPLIER_TYPES)[number] | null;
|
||||
customerSegment: (typeof CUSTOMER_SEGMENTS)[number] | null;
|
||||
country: string | null;
|
||||
region: string | null;
|
||||
jurisdiction: string | null;
|
||||
ultimateParentName: string | null;
|
||||
ultimateParentCountry: string | null;
|
||||
confidence: string;
|
||||
lastActivityAt: string | null;
|
||||
}
|
||||
|
||||
export interface ContactRecord {
|
||||
id: string;
|
||||
accountId: string | null;
|
||||
fullName: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
title: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
linkedinUrl: string | null;
|
||||
twitterHandle: string | null;
|
||||
githubHandle: string | null;
|
||||
websiteUrl: string | null;
|
||||
affiliation: (typeof AFFILIATION_KINDS)[number];
|
||||
isDecisionMaker: boolean;
|
||||
confidence: string;
|
||||
confidenceNote: string | null;
|
||||
lastActivityAt: string | null;
|
||||
}
|
||||
|
||||
export interface ContactRow {
|
||||
contact: ContactRecord;
|
||||
accountName: string | null;
|
||||
accountSide: AccountSide | null;
|
||||
}
|
||||
|
||||
export interface DemandDealRecord {
|
||||
id: string;
|
||||
accountId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
productLine: (typeof PRODUCT_LINES)[number];
|
||||
stage: (typeof DEMAND_STAGES)[number];
|
||||
primaryContactId: string | null;
|
||||
acvCents: number | null;
|
||||
tcvCents: number | null;
|
||||
currency: string;
|
||||
termMonths: number | null;
|
||||
probability: string | number | null;
|
||||
expectedCloseDate: string | null;
|
||||
closedReason: string | null;
|
||||
msaExecuted: boolean;
|
||||
dpaExecuted: boolean;
|
||||
parentDealId: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupplyDealRecord {
|
||||
id: string;
|
||||
accountId: string;
|
||||
siteId: string | null;
|
||||
name: string;
|
||||
stage: (typeof SUPPLY_STAGES)[number];
|
||||
primaryContactId: string | null;
|
||||
gpuType: string | null;
|
||||
gpuCount: number | null;
|
||||
interconnectType: (typeof INTERCONNECT_TYPES)[number] | null;
|
||||
targetCostPerGpuHourCents: number | null;
|
||||
termMonths: number | null;
|
||||
availableFrom: string | null;
|
||||
technicalVerdict: string | null;
|
||||
technicalNotes: string | null;
|
||||
financialVerdict: string | null;
|
||||
financialNotes: string | null;
|
||||
rejectionReason: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SheetProps<Record> {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
record?: Record | null;
|
||||
identity?: PermissionIdentity;
|
||||
}
|
||||
|
||||
const optionalEmail = z.string().refine(
|
||||
(value) => value === '' || z.string().email().safeParse(value).success,
|
||||
'Enter a complete email address or leave it blank.',
|
||||
);
|
||||
const optionalUrl = z.string().refine(
|
||||
(value) => value === '' || z.string().url().safeParse(value).success,
|
||||
'Include the full URL, including https://.',
|
||||
);
|
||||
const optionalPositiveNumber = z.string().refine(
|
||||
(value) => value === '' || (Number.isFinite(Number(value)) && Number(value) > 0),
|
||||
'Enter a number greater than zero or leave it blank.',
|
||||
);
|
||||
const optionalNonnegativeNumber = z.string().refine(
|
||||
(value) => value === '' || (Number.isFinite(Number(value)) && Number(value) >= 0),
|
||||
'Enter zero or a positive number.',
|
||||
);
|
||||
const optionalProbability = z.string().refine(
|
||||
(value) => value === '' || (Number(value) >= 0 && Number(value) <= 100),
|
||||
'Use a percentage from 0 to 100.',
|
||||
);
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required.'),
|
||||
domain: z.string(),
|
||||
website: optionalUrl,
|
||||
description: z.string(),
|
||||
side: z.enum(ACCOUNT_SIDES),
|
||||
supplierType: z.string(),
|
||||
customerSegment: z.string(),
|
||||
country: z.string(),
|
||||
region: z.string(),
|
||||
jurisdiction: z.string(),
|
||||
ultimateParentName: z.string(),
|
||||
ultimateParentCountry: z.string(),
|
||||
});
|
||||
type AccountForm = z.infer<typeof accountFormSchema>;
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
accountId: z.string().uuid('Select an account.'),
|
||||
fullName: z.string().trim().min(1, 'Full name is required.'),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
title: z.string(),
|
||||
email: optionalEmail,
|
||||
phone: z.string(),
|
||||
linkedinUrl: optionalUrl,
|
||||
twitterHandle: z.string(),
|
||||
githubHandle: z.string(),
|
||||
websiteUrl: optionalUrl,
|
||||
affiliation: z.enum(AFFILIATION_KINDS),
|
||||
isDecisionMaker: z.boolean(),
|
||||
confidenceNote: z.string(),
|
||||
});
|
||||
type ContactForm = z.infer<typeof contactFormSchema>;
|
||||
|
||||
const demandFormSchema = z.object({
|
||||
accountId: z.string().uuid('Select a customer account.'),
|
||||
name: z.string().trim().min(1, 'Deal name is required.'),
|
||||
description: z.string(),
|
||||
productLine: z.enum(PRODUCT_LINES),
|
||||
stage: z.enum(DEMAND_STAGES),
|
||||
primaryContactId: z.string(),
|
||||
acv: optionalNonnegativeNumber,
|
||||
tcv: optionalNonnegativeNumber,
|
||||
currency: z.string().trim().length(3, 'Use a three-letter currency code.'),
|
||||
termMonths: optionalPositiveNumber,
|
||||
probability: optionalProbability,
|
||||
expectedCloseDate: z.string(),
|
||||
closedReason: z.string(),
|
||||
msaExecuted: z.boolean(),
|
||||
dpaExecuted: z.boolean(),
|
||||
parentDealId: z.string(),
|
||||
});
|
||||
type DemandForm = z.infer<typeof demandFormSchema>;
|
||||
|
||||
const supplyFormSchema = z.object({
|
||||
accountId: z.string().uuid('Select a supplier account.'),
|
||||
name: z.string().trim().min(1, 'Deal name is required.'),
|
||||
stage: z.enum(SUPPLY_STAGES),
|
||||
primaryContactId: z.string(),
|
||||
gpuType: z.string(),
|
||||
gpuCount: optionalPositiveNumber,
|
||||
interconnectType: z.string(),
|
||||
targetCost: optionalNonnegativeNumber,
|
||||
termMonths: optionalPositiveNumber,
|
||||
availableFrom: z.string(),
|
||||
technicalVerdict: z.string(),
|
||||
technicalNotes: z.string(),
|
||||
financialVerdict: z.string(),
|
||||
financialNotes: z.string(),
|
||||
rejectionReason: z.string(),
|
||||
});
|
||||
type SupplyForm = z.infer<typeof supplyFormSchema>;
|
||||
|
||||
const label = (value: string) => value.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase());
|
||||
const blankToNull = (value: string) => value.trim() || null;
|
||||
const optionalNumber = (value: string) => value === '' ? null : Number(value);
|
||||
const cents = (value: string) => value === '' ? null : Math.round(Number(value) * 100);
|
||||
const dollars = (value: number | null | undefined) => value == null ? '' : String(value / 100);
|
||||
const dateInput = (value: string | null | undefined) => value ? value.slice(0, 10) : '';
|
||||
|
||||
function canWriteSide(identity: PermissionIdentity | undefined, side: AccountSide): boolean {
|
||||
return (
|
||||
(side !== 'supply' && can(identity, 'deal:write', 'demand')) ||
|
||||
(side !== 'demand' && can(identity, 'deal:write', 'supply'))
|
||||
);
|
||||
}
|
||||
|
||||
function accountDefaults(record?: AccountRecord | null): AccountForm {
|
||||
return {
|
||||
name: record?.name ?? '',
|
||||
domain: record?.domain ?? '',
|
||||
website: record?.website ?? '',
|
||||
description: record?.description ?? '',
|
||||
side: record?.side ?? 'demand',
|
||||
supplierType: record?.supplierType ?? '',
|
||||
customerSegment: record?.customerSegment ?? '',
|
||||
country: record?.country ?? '',
|
||||
region: record?.region ?? '',
|
||||
jurisdiction: record?.jurisdiction ?? '',
|
||||
ultimateParentName: record?.ultimateParentName ?? '',
|
||||
ultimateParentCountry: record?.ultimateParentCountry ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AccountSheet({ open, onOpenChange, record, identity }: SheetProps<AccountRecord>) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<AccountForm>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
defaultValues: accountDefaults(record),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) form.reset(accountDefaults(record));
|
||||
}, [form, open, record]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: AccountForm) => {
|
||||
const body = {
|
||||
...values,
|
||||
domain: blankToNull(values.domain),
|
||||
website: blankToNull(values.website),
|
||||
description: blankToNull(values.description),
|
||||
supplierType: values.side === 'demand' ? null : blankToNull(values.supplierType),
|
||||
customerSegment: values.side === 'supply' ? null : blankToNull(values.customerSegment),
|
||||
country: blankToNull(values.country),
|
||||
region: blankToNull(values.region),
|
||||
jurisdiction: blankToNull(values.jurisdiction),
|
||||
ultimateParentName: blankToNull(values.ultimateParentName),
|
||||
ultimateParentCountry: blankToNull(values.ultimateParentCountry),
|
||||
};
|
||||
return record ? patch<AccountRecord>(`/api/accounts/${record.id}`, body) : post<AccountRecord>('/api/accounts', body);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
toast.success(record ? 'Account updated' : 'Account created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
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.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="name" label="Account name" placeholder="Acme AI" className="sm:col-span-2" />
|
||||
<SelectField control={form.control} name="side" label="Commercial side" options={ACCOUNT_SIDES.map((value) => ({ value, label: label(value), disabled: !canWriteSide(identity, value) }))} />
|
||||
<TextField control={form.control} name="domain" label="Domain" placeholder="acme.ai" />
|
||||
{side !== 'demand' ? <SelectField control={form.control} name="supplierType" label="Supplier type" optional options={SUPPLIER_TYPES.map((value) => ({ value, label: label(value) }))} /> : null}
|
||||
{side !== 'supply' ? <SelectField control={form.control} name="customerSegment" label="Customer segment" optional options={CUSTOMER_SEGMENTS.map((value) => ({ value, label: label(value) }))} /> : null}
|
||||
<TextField control={form.control} name="website" label="Website" placeholder="https://acme.ai" className="sm:col-span-2" />
|
||||
<TextAreaField control={form.control} name="description" label="Relationship context" placeholder="What they build, what they buy or sell, and why the relationship matters." className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
<Section title="Commercial geography" description="Headquarters and legal jurisdiction are separate because export controls and data residency attach differently.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="country" label="Headquarters country" />
|
||||
<TextField control={form.control} name="region" label="Region" />
|
||||
<TextField control={form.control} name="jurisdiction" label="Legal jurisdiction" className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<Section title="Ultimate ownership" description="Only enter ownership you can substantiate; the compliance engine must not infer it from headquarters.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="ultimateParentName" label="Ultimate parent" />
|
||||
<TextField control={form.control} name="ultimateParentCountry" label="Parent country" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save account' : 'Create account'} />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function contactDefaults(record?: ContactRecord | null, accountId?: string): ContactForm {
|
||||
return {
|
||||
accountId: record?.accountId ?? accountId ?? '',
|
||||
fullName: record?.fullName ?? '',
|
||||
firstName: record?.firstName ?? '',
|
||||
lastName: record?.lastName ?? '',
|
||||
title: record?.title ?? '',
|
||||
email: record?.email ?? '',
|
||||
phone: record?.phone ?? '',
|
||||
linkedinUrl: record?.linkedinUrl ?? '',
|
||||
twitterHandle: record?.twitterHandle ?? '',
|
||||
githubHandle: record?.githubHandle ?? '',
|
||||
websiteUrl: record?.websiteUrl ?? '',
|
||||
affiliation: record?.affiliation ?? 'unknown',
|
||||
isDecisionMaker: record?.isDecisionMaker ?? false,
|
||||
confidenceNote: record?.confidenceNote ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function ContactSheet({ open, onOpenChange, record, identity, defaultAccountId }: SheetProps<ContactRecord> & { defaultAccountId?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: accountsData } = useQuery({
|
||||
queryKey: ['accounts', 'all-record-options'],
|
||||
queryFn: () => get<AccountRecord[]>('/api/accounts'),
|
||||
enabled: open,
|
||||
});
|
||||
const writableAccounts = useMemo(() => (accountsData ?? []).filter((account) => canWriteSide(identity, account.side)), [accountsData, identity]);
|
||||
const form = useForm<ContactForm>({ resolver: zodResolver(contactFormSchema), defaultValues: contactDefaults(record, defaultAccountId) });
|
||||
useEffect(() => {
|
||||
if (open) form.reset(contactDefaults(record, defaultAccountId));
|
||||
}, [defaultAccountId, form, open, record]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: ContactForm) => {
|
||||
const body = {
|
||||
...values,
|
||||
firstName: blankToNull(values.firstName), lastName: blankToNull(values.lastName), title: blankToNull(values.title),
|
||||
email: blankToNull(values.email), phone: blankToNull(values.phone), linkedinUrl: blankToNull(values.linkedinUrl),
|
||||
twitterHandle: blankToNull(values.twitterHandle), githubHandle: blankToNull(values.githubHandle), websiteUrl: blankToNull(values.websiteUrl),
|
||||
confidenceNote: blankToNull(values.confidenceNote),
|
||||
};
|
||||
return record ? patch<ContactRecord>(`/api/contacts/${record.id}`, body) : post<ContactRecord>('/api/contacts', body);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] }),
|
||||
]);
|
||||
toast.success(record ? 'Contact updated' : 'Contact created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
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.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="accountId" label="Account" className="sm:col-span-2" options={writableAccounts.map((account) => ({ value: account.id, label: `${account.name} · ${label(account.side)}` }))} />
|
||||
<TextField control={form.control} name="fullName" label="Full name" className="sm:col-span-2" />
|
||||
<TextField control={form.control} name="firstName" label="First name" />
|
||||
<TextField control={form.control} name="lastName" label="Last name" />
|
||||
<TextField control={form.control} name="title" label="Title" />
|
||||
<SelectField control={form.control} name="affiliation" label="Affiliation" options={AFFILIATION_KINDS.map((value) => ({ value, label: label(value) }))} />
|
||||
<TextField control={form.control} name="email" label="Email" type="email" description="Leave blank unless the address is sourced or provided. Never infer it from a name and domain." className="sm:col-span-2" />
|
||||
<TextField control={form.control} name="phone" label="Phone" />
|
||||
<SwitchField control={form.control} name="isDecisionMaker" label="Decision maker" description="They can materially approve or block this relationship." />
|
||||
</FieldGrid>
|
||||
<Section title="Public profiles">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="linkedinUrl" label="LinkedIn URL" className="sm:col-span-2" />
|
||||
<TextField control={form.control} name="twitterHandle" label="X / Twitter handle" />
|
||||
<TextField control={form.control} name="githubHandle" label="GitHub handle" />
|
||||
<TextField control={form.control} name="websiteUrl" label="Website URL" className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<TextAreaField control={form.control} name="confidenceNote" label="Provenance note" description="Use this when the relationship or details need qualification." />
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save contact' : 'Create contact'} />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function demandDefaults(record?: DemandDealRecord | null): DemandForm {
|
||||
return {
|
||||
accountId: record?.accountId ?? '', name: record?.name ?? '', description: record?.description ?? '',
|
||||
productLine: record?.productLine ?? 'compute_reserved', stage: record?.stage ?? 'qualification',
|
||||
primaryContactId: record?.primaryContactId ?? '', acv: dollars(record?.acvCents), tcv: dollars(record?.tcvCents),
|
||||
currency: record?.currency ?? 'USD', termMonths: record?.termMonths == null ? '' : String(record.termMonths),
|
||||
probability: record?.probability == null ? '' : String(Number(record.probability) * 100), expectedCloseDate: dateInput(record?.expectedCloseDate),
|
||||
closedReason: record?.closedReason ?? '', msaExecuted: record?.msaExecuted ?? false, dpaExecuted: record?.dpaExecuted ?? false,
|
||||
parentDealId: record?.parentDealId ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<DemandDealRecord>) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: accountData } = useQuery({ queryKey: ['accounts', 'demand-record-options'], queryFn: () => get<AccountRecord[]>('/api/accounts?side=demand'), enabled: open });
|
||||
const { data: contactRows } = useQuery({ queryKey: ['contacts', 'demand-record-options'], queryFn: () => get<ContactRow[]>('/api/contacts'), enabled: open });
|
||||
const { data: board } = useQuery({ queryKey: ['/api/deals/demand'], queryFn: () => get<{ deals: { deal: DemandDealRecord }[] }>('/api/deals/demand'), enabled: open });
|
||||
const form = useForm<DemandForm>({ resolver: zodResolver(demandFormSchema), defaultValues: demandDefaults(record) });
|
||||
useEffect(() => { if (open) form.reset(demandDefaults(record)); }, [form, open, record]);
|
||||
const accountId = form.watch('accountId');
|
||||
const contactOptions = (contactRows ?? []).filter((row) => row.contact.accountId === accountId);
|
||||
const parentOptions = (board?.deals ?? []).filter((row) => row.deal.accountId === accountId && row.deal.id !== record?.id);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: DemandForm) => {
|
||||
const body = {
|
||||
accountId: values.accountId, name: values.name, description: blankToNull(values.description), productLine: values.productLine, stage: values.stage,
|
||||
primaryContactId: blankToNull(values.primaryContactId), acvCents: cents(values.acv), tcvCents: cents(values.tcv), currency: values.currency.toUpperCase(),
|
||||
termMonths: optionalNumber(values.termMonths), probability: values.probability === '' ? null : Number(values.probability) / 100,
|
||||
expectedCloseDate: blankToNull(values.expectedCloseDate), closedReason: blankToNull(values.closedReason), msaExecuted: values.msaExecuted,
|
||||
dpaExecuted: values.dpaExecuted, parentDealId: blankToNull(values.parentDealId),
|
||||
};
|
||||
return record ? patch<DemandDealRecord>(`/api/deals/demand/${record.id}`, body) : post<DemandDealRecord>('/api/deals/demand', body);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: ['/api/deals/demand'] }), queryClient.invalidateQueries({ queryKey: ['accounts'] })]);
|
||||
toast.success(record ? 'Demand deal updated' : 'Demand deal created'); onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
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.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="accountId" label="Customer account" className="sm:col-span-2" options={(accountData ?? []).map((account) => ({ value: account.id, label: account.name }))} />
|
||||
<TextField control={form.control} name="name" label="Deal name" className="sm:col-span-2" />
|
||||
<SelectField control={form.control} name="productLine" label="Product line" options={PRODUCT_LINES.map((value) => ({ value, label: label(value) }))} />
|
||||
<SelectField control={form.control} name="stage" label="Stage" options={DEMAND_STAGES.map((value) => ({ value, label: DEMAND_STAGE_LABELS[value] }))} />
|
||||
<SelectField control={form.control} name="primaryContactId" label="Primary contact" optional className="sm:col-span-2" options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
|
||||
<TextAreaField control={form.control} name="description" label="Deal context" className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
<Section title="Commercials" description="Money is converted to integer cents at the API boundary; probability stays independent of stage.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="acv" label="ACV" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="tcv" label="TCV" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="currency" label="Currency" maxLength={3} />
|
||||
<TextField control={form.control} name="termMonths" label="Term (months)" inputMode="numeric" />
|
||||
<TextField control={form.control} name="probability" label="Probability (%)" inputMode="decimal" />
|
||||
<TextField control={form.control} name="expectedCloseDate" label="Expected close" type="date" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<Section title="Paper and continuity" description="Legal clears early in this market. These flags remain visible after the deal advances.">
|
||||
<FieldGrid>
|
||||
<SwitchField control={form.control} name="msaExecuted" label="MSA executed" />
|
||||
<SwitchField control={form.control} name="dpaExecuted" label="DPA executed" />
|
||||
<SelectField control={form.control} name="parentDealId" label="Parent deal" optional className="sm:col-span-2" options={parentOptions.map((row) => ({ value: row.deal.id, label: row.deal.name }))} />
|
||||
<TextAreaField control={form.control} name="closedReason" label="Closed reason" description="Record why a deal was won or lost; leave blank while it is open." className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save demand deal' : 'Create demand deal'} />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function supplyDefaults(record?: SupplyDealRecord | null): SupplyForm {
|
||||
return {
|
||||
accountId: record?.accountId ?? '', name: record?.name ?? '', stage: record?.stage ?? 'sourced', primaryContactId: record?.primaryContactId ?? '',
|
||||
gpuType: record?.gpuType ?? '', gpuCount: record?.gpuCount == null ? '' : String(record.gpuCount), interconnectType: record?.interconnectType ?? '',
|
||||
targetCost: dollars(record?.targetCostPerGpuHourCents), termMonths: record?.termMonths == null ? '' : String(record.termMonths), availableFrom: dateInput(record?.availableFrom),
|
||||
technicalVerdict: record?.technicalVerdict ?? '', technicalNotes: record?.technicalNotes ?? '', financialVerdict: record?.financialVerdict ?? '',
|
||||
financialNotes: record?.financialNotes ?? '', rejectionReason: record?.rejectionReason ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<SupplyDealRecord>) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: accountData } = useQuery({ queryKey: ['accounts', 'supply-record-options'], queryFn: () => get<AccountRecord[]>('/api/accounts?side=supply'), enabled: open });
|
||||
const { data: contactRows } = useQuery({ queryKey: ['contacts', 'supply-record-options'], queryFn: () => get<ContactRow[]>('/api/contacts'), enabled: open });
|
||||
const form = useForm<SupplyForm>({ resolver: zodResolver(supplyFormSchema), defaultValues: supplyDefaults(record) });
|
||||
useEffect(() => { if (open) form.reset(supplyDefaults(record)); }, [form, open, record]);
|
||||
const accountId = form.watch('accountId');
|
||||
const contactOptions = (contactRows ?? []).filter((row) => row.contact.accountId === accountId);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: SupplyForm) => {
|
||||
const body = {
|
||||
accountId: values.accountId, name: values.name, stage: values.stage, primaryContactId: blankToNull(values.primaryContactId), siteId: record?.siteId ?? null,
|
||||
gpuType: blankToNull(values.gpuType), gpuCount: optionalNumber(values.gpuCount), interconnectType: blankToNull(values.interconnectType),
|
||||
targetCostPerGpuHourCents: cents(values.targetCost), termMonths: optionalNumber(values.termMonths), availableFrom: blankToNull(values.availableFrom),
|
||||
technicalVerdict: blankToNull(values.technicalVerdict), technicalNotes: blankToNull(values.technicalNotes), financialVerdict: blankToNull(values.financialVerdict),
|
||||
financialNotes: blankToNull(values.financialNotes), rejectionReason: blankToNull(values.rejectionReason),
|
||||
};
|
||||
return record ? patch<SupplyDealRecord>(`/api/deals/supply/${record.id}`, body) : post<SupplyDealRecord>('/api/deals/supply', body);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: ['/api/deals/supply'] }), queryClient.invalidateQueries({ queryKey: ['accounts'] })]);
|
||||
toast.success(record ? 'Supply deal updated' : 'Supply deal created'); onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
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.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="accountId" label="Supplier account" className="sm:col-span-2" options={(accountData ?? []).map((account) => ({ value: account.id, label: account.name }))} />
|
||||
<TextField control={form.control} name="name" label="Deal name" className="sm:col-span-2" />
|
||||
<SelectField control={form.control} name="stage" label="Stage" options={SUPPLY_STAGES.map((value) => ({ value, label: SUPPLY_STAGE_LABELS[value] }))} />
|
||||
<SelectField control={form.control} name="primaryContactId" label="Primary contact" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: row.contact.fullName }))} />
|
||||
</FieldGrid>
|
||||
<Section title="Capacity on offer" description="These terms describe the opportunity, not booked inventory. A commitment is created only after paper is executed.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="gpuType" label="GPU type" placeholder="H100_80GB" />
|
||||
<TextField control={form.control} name="gpuCount" label="GPU count" inputMode="numeric" />
|
||||
<SelectField control={form.control} name="interconnectType" label="Interconnect" optional options={INTERCONNECT_TYPES.map((value) => ({ value, label: value }))} />
|
||||
<TextField control={form.control} name="targetCost" label="Target $ / GPU-hr" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="termMonths" label="Term (months)" inputMode="numeric" />
|
||||
<TextField control={form.control} name="availableFrom" label="Available from" type="date" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<Section title="Two-key diligence" description="Technical fitness and financial clearance are independent decisions. Record each verdict in its own voice.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="technicalVerdict" label="Technical verdict" placeholder="Passed, conditional, blocked…" />
|
||||
<TextField control={form.control} name="financialVerdict" label="Financial verdict" placeholder="Passed, conditional, blocked…" />
|
||||
<TextAreaField control={form.control} name="technicalNotes" label="Technical notes" />
|
||||
<TextAreaField control={form.control} name="financialNotes" label="Financial notes" />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<TextAreaField control={form.control} name="rejectionReason" label="Rejection reason" description="Rejections teach the sourcing team. Leave blank unless the deal is rejected." />
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save supply deal' : 'Create supply deal'} />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordSheet({ open, onOpenChange, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; 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">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
function SheetActions({ pending, onCancel, label: actionLabel }: { pending: boolean; onCancel(): void; label: string }) {
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
|
||||
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" className="h-11" disabled={pending}>
|
||||
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : null}
|
||||
{pending ? 'Saving…' : actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<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}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField<T extends FieldValues>({ control, name, label: fieldLabel, description, className, prefix, ...inputProps }: { control: Control<T>; name: FieldPath<T>; label: string; description?: string; className?: string; prefix?: string } & Omit<React.ComponentProps<typeof Input>, 'name' | 'value' | 'defaultValue'>) {
|
||||
return (
|
||||
<FormField control={control} name={name} render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>{fieldLabel}</FormLabel>
|
||||
<FormControl><Input {...field} {...inputProps} value={String(field.value ?? '')} placeholder={inputProps.placeholder ?? prefix} /></FormControl>
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
function TextAreaField<T extends FieldValues>({ control, name, label: fieldLabel, description, className, placeholder }: { control: Control<T>; name: FieldPath<T>; label: string; description?: string; className?: string; placeholder?: string }) {
|
||||
return (
|
||||
<FormField control={control} name={name} render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>{fieldLabel}</FormLabel>
|
||||
<FormControl><Textarea {...field} value={String(field.value ?? '')} placeholder={placeholder} className="min-h-24 resize-y" /></FormControl>
|
||||
{description ? <FormDescription>{description}</FormDescription> : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField<T extends FieldValues>({ control, name, label: fieldLabel, options, optional = false, className }: { control: Control<T>; name: FieldPath<T>; label: string; options: { value: string; label: string; disabled?: boolean }[]; optional?: boolean; className?: string }) {
|
||||
return (
|
||||
<FormField control={control} name={name} render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
<FormLabel>{fieldLabel}</FormLabel>
|
||||
<Select value={String(field.value || (optional ? 'none' : ''))} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
|
||||
<FormControl><SelectTrigger className="h-11"><SelectValue placeholder={`Select ${fieldLabel.toLowerCase()}`} /></SelectTrigger></FormControl>
|
||||
<SelectContent><SelectGroup>
|
||||
{optional ? <SelectItem value="none">None</SelectItem> : null}
|
||||
{options.map((option) => <SelectItem key={option.value} value={option.value} disabled={option.disabled}>{option.label}</SelectItem>)}
|
||||
</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchField<T extends FieldValues>({ control, name, label: fieldLabel, description }: { control: Control<T>; name: FieldPath<T>; label: string; description?: string }) {
|
||||
return (
|
||||
<FormField control={control} name={name} render={({ field }) => (
|
||||
<FormItem className="flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="flex flex-col gap-1"><FormLabel>{fieldLabel}</FormLabel>{description ? <FormDescription>{description}</FormDescription> : null}</div>
|
||||
<FormControl><Switch checked={Boolean(field.value)} onCheckedChange={field.onChange} /></FormControl>
|
||||
</FormItem>
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) return error.message;
|
||||
return error instanceof Error ? error.message : 'The record could not be saved.';
|
||||
}
|
||||
@@ -12,46 +12,63 @@
|
||||
* The breakpoint is `lg`, chosen so that an iPad in portrait gets the sidebar
|
||||
* — it has the width, and the bottom bar looks lost across a tablet.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Boxes,
|
||||
Building2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Search,
|
||||
MessageCircleMore,
|
||||
ShieldCheck,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { PiggyLogo, PiggyMark } from './PiggyMark';
|
||||
import { cn } from './ui';
|
||||
import { CommandPalette, type CommandDestination } from './CommandPalette';
|
||||
import { Button, cn } from './ui';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
interface NavItem extends CommandDestination {
|
||||
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
export function Shell() {
|
||||
const location = useLocation();
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const current = NAV.find((item) =>
|
||||
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return;
|
||||
event.preventDefault();
|
||||
setCommandOpen((open) => !open);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-bg">
|
||||
{/* ------------------------------------------------- desktop sidebar */}
|
||||
@@ -85,6 +102,19 @@ export function Shell() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mx-3 mb-3 justify-start text-muted"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" aria-hidden />
|
||||
Search
|
||||
<kbd className="ml-auto rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
<div className="border-t border-border px-5 py-3 text-xs text-muted">
|
||||
Prime Intellect Growth
|
||||
</div>
|
||||
@@ -105,6 +135,16 @@ export function Shell() {
|
||||
<span className="font-semibold lowercase tracking-tight">
|
||||
{current?.label ?? 'pig'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
aria-label="Search and navigate"
|
||||
>
|
||||
<Search className="h-5 w-5" aria-hidden />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* ---------------------------------------------------------- content */}
|
||||
@@ -149,6 +189,7 @@ export function Shell() {
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<CommandPalette destinations={NAV} open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { FactBand, FactStatus } from '@pig/core';
|
||||
import { ExternalLink, Link2, ScanSearch } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SourcedFact {
|
||||
id: string;
|
||||
field: string;
|
||||
value: string;
|
||||
score: string | number;
|
||||
band: FactBand;
|
||||
status: FactStatus;
|
||||
evidence: Record<string, unknown> | null;
|
||||
sourceUrl: string | null;
|
||||
method: string | null;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
interface SourcedValueProps {
|
||||
value: ReactNode;
|
||||
fact: SourcedFact;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BAND_TONE = {
|
||||
verified: 'positive',
|
||||
probable: 'info',
|
||||
possible: 'warning',
|
||||
} as const;
|
||||
|
||||
function safeSourceUrl(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function evidenceSummary(evidence: Record<string, unknown> | null): string | null {
|
||||
if (!evidence) return null;
|
||||
for (const key of ['excerpt', 'quote', 'summary', 'snippet', 'reason']) {
|
||||
const value = evidence[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
const firstText = Object.values(evidence).find(
|
||||
(value): value is string => typeof value === 'string' && Boolean(value.trim()),
|
||||
);
|
||||
return firstText?.trim() ?? null;
|
||||
}
|
||||
|
||||
function humanise(value: string): string {
|
||||
return value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
const sourceUrl = safeSourceUrl(fact.sourceUrl);
|
||||
const summary = evidenceSummary(fact.evidence);
|
||||
const score = Number(fact.score);
|
||||
const confidence = Number.isFinite(score) ? `${Math.round(score * 100)}%` : 'Not scored';
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-1.5', className)}>
|
||||
<span className="min-w-0 break-words font-medium text-fg">{value}</span>
|
||||
<Popover>
|
||||
<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}`}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[min(22rem,calc(100vw-2rem))] border-border bg-surface text-fg"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<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)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-sm font-medium">{fact.value}</p>
|
||||
</div>
|
||||
<Badge tone={BAND_TONE[fact.band]}>{confidence}</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2 text-sm">
|
||||
<ScanSearch className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">Evidence</p>
|
||||
<p className="mt-0.5 break-words text-muted">
|
||||
{summary ?? 'No evidence excerpt was recorded.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<Badge tone="neutral">{humanise(fact.band)}</Badge>
|
||||
<Badge tone="neutral">{humanise(fact.status)}</Badge>
|
||||
{fact.method ? <span>via {humanise(fact.method)}</span> : null}
|
||||
</div>
|
||||
{sourceUrl ? (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
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
|
||||
<ExternalLink className="size-3.5 shrink-0" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("useFormField should be used within <FormItem>")
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
permissionGranted,
|
||||
type GlobalCapability,
|
||||
type PermissionGrant,
|
||||
type Team,
|
||||
type TeamCapability,
|
||||
} from '@pig/core';
|
||||
|
||||
export interface PermissionIdentity {
|
||||
permissions: PermissionGrant[];
|
||||
}
|
||||
|
||||
export function can(identity: PermissionIdentity | undefined, capability: GlobalCapability): boolean;
|
||||
export function can(
|
||||
identity: PermissionIdentity | undefined,
|
||||
capability: TeamCapability,
|
||||
team: Team,
|
||||
): boolean;
|
||||
export function can(
|
||||
identity: PermissionIdentity | undefined,
|
||||
capability: GlobalCapability | TeamCapability,
|
||||
team?: Team,
|
||||
): boolean {
|
||||
return Boolean(identity && permissionGranted(identity.permissions, capability, team));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ApiError, getSupabase } from './api';
|
||||
|
||||
export interface PiggyChatContext {
|
||||
type: 'account' | 'contact' | 'demand_deal' | 'supply_deal' | 'contract' | 'commitment';
|
||||
id: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface PiggyChatTurn {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type PiggyChatEvent =
|
||||
| { type: 'meta'; model: string }
|
||||
| { type: 'reasoning_delta'; delta: string }
|
||||
| { type: 'content_delta'; delta: string }
|
||||
| { type: 'tool_call'; id: string; name: string; arguments: unknown }
|
||||
| { type: 'tool_result'; id: string; name: string; ok: boolean; result?: unknown; error?: string }
|
||||
| { type: 'done'; inputTokens: number | null; outputTokens: number | null }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface PiggyStatus {
|
||||
enabled: boolean;
|
||||
canUse: boolean;
|
||||
}
|
||||
|
||||
export async function* streamPiggyChat(
|
||||
request: {
|
||||
message: string;
|
||||
history?: PiggyChatTurn[];
|
||||
context?: PiggyChatContext;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<PiggyChatEvent> {
|
||||
const supabase = getSupabase();
|
||||
const token = supabase ? (await supabase.auth.getSession()).data.session?.access_token : null;
|
||||
const response = await fetch('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
let code: string | undefined;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string; code?: string };
|
||||
message = body.error ?? message;
|
||||
code = body.code;
|
||||
} catch {
|
||||
// The authenticated proxy normally returns JSON, but an upstream proxy may not.
|
||||
}
|
||||
throw new ApiError(message, response.status, code);
|
||||
}
|
||||
if (!response.body) throw new Error('Piggy returned no response stream.');
|
||||
|
||||
yield* readNdjson<PiggyChatEvent>(response.body, signal);
|
||||
}
|
||||
|
||||
export async function* readNdjson<Value>(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<Value> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) yield JSON.parse(line) as Value;
|
||||
}
|
||||
if (done) {
|
||||
if (buffer.trim()) yield JSON.parse(buffer) as Value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,111 +1,73 @@
|
||||
/**
|
||||
* Accounts — suppliers and customers in one list, filtered by side.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus, 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { get, relativeTime } from '@/lib/api';
|
||||
import { Badge, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
side: string;
|
||||
supplierType: string | null;
|
||||
customerSegment: string | null;
|
||||
country: string | null;
|
||||
confidence: string;
|
||||
lastActivityAt: string | null;
|
||||
}
|
||||
interface Me { permissions: PermissionGrant[] }
|
||||
|
||||
export function Accounts() {
|
||||
usePageTitle('Accounts');
|
||||
const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['accounts', side, query],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (side !== 'all') params.set('side', side);
|
||||
if (query) params.set('q', query);
|
||||
return get<Account[]>(`/api/accounts?${params}`);
|
||||
},
|
||||
const [view, setView] = useState<'accounts' | 'contacts'>('accounts');
|
||||
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 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 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: '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: '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> },
|
||||
];
|
||||
const contactColumns: ColumnDef<ContactRow>[] = [
|
||||
{ id: 'contact', accessorFn: (row) => `${row.contact.fullName} ${row.contact.email ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Contact" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.contact.fullName}</p>{row.original.contact.email ? <p className="truncate text-xs text-muted">{row.original.contact.email}</p> : <p className="text-xs text-muted">No email recorded</p>}</div> },
|
||||
{ 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: '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>; } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header>
|
||||
<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 ones who are both.
|
||||
</p>
|
||||
<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">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search accounts"
|
||||
type="search"
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<div className="inline-flex rounded-lg bg-surface-2 p-1">
|
||||
{(['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>
|
||||
<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>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : !data || data.length === 0 ? (
|
||||
<EmptyState icon={<Building2 className="h-8 w-8" />} title="No accounts found" />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.map((account) => (
|
||||
<article key={account.id} className="card min-w-0 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{account.name}</p>
|
||||
{account.domain ? (
|
||||
<p className="truncate text-xs text-muted">{account.domain}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ConfidenceBadge confidence={account.confidence} />
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
<Badge tone={account.side === 'supply' ? 'info' : account.side === 'both' ? 'accent' : 'neutral'}>
|
||||
{account.side}
|
||||
</Badge>
|
||||
{account.supplierType ? (
|
||||
<Badge tone="neutral">{account.supplierType.replace(/_/g, ' ')}</Badge>
|
||||
) : null}
|
||||
{account.customerSegment ? (
|
||||
<Badge tone="neutral">{account.customerSegment.replace(/_/g, ' ')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{account.lastActivityAt ? (
|
||||
<p className="mt-2 text-[11px] text-muted">
|
||||
Active {relativeTime(account.lastActivityAt)}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Search, Server, Zap } from 'lucide-react';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Search, Server, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { can } from '@/lib/permissions';
|
||||
import {
|
||||
AllocationSheet,
|
||||
type AvailabilityRow,
|
||||
type MatchRow,
|
||||
} from '@/components/AllocationSheet';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -22,29 +29,20 @@ import {
|
||||
Skeleton,
|
||||
} from '@/components/ui';
|
||||
|
||||
interface AvailabilityRow {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
interconnectType: string;
|
||||
securityTier: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
totalGpuHours: number;
|
||||
soldGpuHours: number;
|
||||
heldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
costPerGpuHourCents: number;
|
||||
utilisation: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
type MatchRow = AvailabilityRow & { score: number; rationale: string[] };
|
||||
|
||||
export function Capacity() {
|
||||
usePageTitle('Capacity');
|
||||
const [tab, setTab] = useState<'available' | 'match'>('available');
|
||||
const [allocation, setAllocation] = useState<{
|
||||
open: boolean;
|
||||
preferredCommitmentId?: string;
|
||||
matches?: MatchRow[];
|
||||
defaultGpuHours?: number;
|
||||
}>({ open: false });
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
|
||||
});
|
||||
const writable = can(me, 'deal:write', 'demand');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -78,12 +76,30 @@ export function Capacity() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'available' ? <Availability /> : <Matcher />}
|
||||
{tab === 'available' ? (
|
||||
<Availability
|
||||
writable={writable}
|
||||
onAllocate={(preferredCommitmentId) =>
|
||||
setAllocation({ open: true, preferredCommitmentId })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Matcher
|
||||
writable={writable}
|
||||
onAllocate={(preferredCommitmentId, matches, defaultGpuHours) =>
|
||||
setAllocation({ open: true, preferredCommitmentId, matches, defaultGpuHours })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AllocationSheet
|
||||
{...allocation}
|
||||
onOpenChange={(open) => setAllocation((state) => ({ ...state, open }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Availability() {
|
||||
function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['availability'],
|
||||
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||||
@@ -108,13 +124,13 @@ function Availability() {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.map((row) => (
|
||||
<CapacityCard key={row.commitmentId} row={row} />
|
||||
<CapacityCard key={row.commitmentId} row={row} writable={writable} onAllocate={() => onAllocate(row.commitmentId)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapacityCard({ row }: { row: AvailabilityRow }) {
|
||||
function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; writable: boolean; onAllocate(): void }) {
|
||||
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
|
||||
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
|
||||
|
||||
@@ -170,16 +186,22 @@ function CapacityCard({ row }: { row: AvailabilityRow }) {
|
||||
: `${money(row.breakEvenPriceCents)}/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}>
|
||||
<ShieldCheck data-icon="inline-start" aria-hidden />
|
||||
Allocate or hold
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Matcher() {
|
||||
function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: string, matches: MatchRow[], defaultGpuHours?: number): void }) {
|
||||
const [form, setForm] = useState({
|
||||
gpuType: '',
|
||||
gpuCount: '64',
|
||||
totalGpuHours: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
requiresHighSpeedInterconnect: true,
|
||||
maxPrice: '',
|
||||
});
|
||||
@@ -190,6 +212,8 @@ function Matcher() {
|
||||
gpuType: form.gpuType || undefined,
|
||||
gpuCount: Number(form.gpuCount) || 1,
|
||||
totalGpuHours: form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
|
||||
startsAt: form.startsAt ? new Date(`${form.startsAt}T00:00:00`).toISOString() : undefined,
|
||||
endsAt: form.endsAt ? new Date(`${form.endsAt}T23:59:59`).toISOString() : undefined,
|
||||
requiresHighSpeedInterconnect: form.requiresHighSpeedInterconnect,
|
||||
maxPricePerGpuHourCents: form.maxPrice
|
||||
? Math.round(Number(form.maxPrice) * 100)
|
||||
@@ -249,6 +273,21 @@ function Matcher() {
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Needed from">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.startsAt}
|
||||
onChange={(e) => setForm({ ...form, startsAt: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Needed until">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.endsAt}
|
||||
min={form.startsAt || undefined}
|
||||
onChange={(e) => setForm({ ...form, endsAt: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="tap flex items-center gap-2.5 text-sm sm:col-span-2 lg:col-span-3">
|
||||
<input
|
||||
@@ -265,8 +304,8 @@ function Matcher() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||
<Search className="h-4 w-4" aria-hidden />
|
||||
<Button type="submit" variant="primary" disabled={mutation.isPending} className="lg:col-start-4">
|
||||
<Search data-icon="inline-start" aria-hidden />
|
||||
{mutation.isPending ? 'Matching…' : 'Find capacity'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -301,7 +340,7 @@ function Matcher() {
|
||||
{percent(match.score)} fit
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1 text-sm">
|
||||
<ul className="mt-3 flex flex-col gap-1 text-sm">
|
||||
{match.rationale.map((reason, i) => (
|
||||
<li
|
||||
key={i}
|
||||
@@ -313,6 +352,24 @@ function Matcher() {
|
||||
</li>
|
||||
))}
|
||||
</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
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!writable}
|
||||
onClick={() => onAllocate(
|
||||
match.commitmentId,
|
||||
mutation.data,
|
||||
form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
|
||||
)}
|
||||
title={!writable ? 'Demand-team write permission is required' : undefined}
|
||||
>
|
||||
<ShieldCheck data-icon="inline-start" aria-hidden />
|
||||
Allocate this capacity
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -321,7 +378,7 @@ function Matcher() {
|
||||
) : null}
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-danger">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Check, ExternalLink, FileCheck2, ShieldCheck, X } from 'lucide-react';
|
||||
import { SourcedValue, type SourcedFact } from '@/components/SourcedValue';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
} from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { get, patch, relativeTime } from '@/lib/api';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface ReviewItem {
|
||||
fact: SourcedFact;
|
||||
accountName: string | null;
|
||||
contactName: string | null;
|
||||
}
|
||||
|
||||
interface ReviewResponse {
|
||||
facts: ReviewItem[];
|
||||
}
|
||||
|
||||
interface ReviewIdentity {
|
||||
permissions: PermissionGrant[];
|
||||
}
|
||||
|
||||
interface DecisionResponse {
|
||||
fact: SourcedFact;
|
||||
recordUpdated: false;
|
||||
}
|
||||
|
||||
function humanise(value: string): string {
|
||||
return value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function safeSourceUrl(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function evidenceSummary(evidence: Record<string, unknown> | null): string {
|
||||
if (!evidence) return 'No evidence excerpt was recorded.';
|
||||
for (const key of ['excerpt', 'quote', 'summary', 'snippet', 'reason']) {
|
||||
const value = evidence[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return 'Structured evidence is attached. Open the provenance marker for details.';
|
||||
}
|
||||
|
||||
export function FactReview() {
|
||||
usePageTitle('Fact review');
|
||||
const queryClient = useQueryClient();
|
||||
const factsQuery = useQuery({
|
||||
queryKey: ['facts', 'proposed'],
|
||||
queryFn: () => get<ReviewResponse>('/api/facts?status=proposed'),
|
||||
});
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<ReviewIdentity>('/api/me'),
|
||||
});
|
||||
const mayReview = can(me, 'data:import', 'research');
|
||||
|
||||
const decision = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
||||
patch<DecisionResponse>(`/api/facts/${id}/decision`, { status }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] });
|
||||
},
|
||||
});
|
||||
|
||||
const items = factsQuery.data?.facts ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 sm:gap-6">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-accent-fg">
|
||||
<ShieldCheck className="size-4" aria-hidden />
|
||||
Evidence control
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={items.length > 0 ? 'warning' : 'positive'}>
|
||||
{items.length} awaiting review
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<Card className="overflow-hidden border-accent/30 bg-accent-subtle/40">
|
||||
<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="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.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{decision.error ? (
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger" role="alert">
|
||||
{decision.error instanceof Error ? decision.error.message : 'The review could not be saved.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{factsQuery.isLoading ? <ReviewSkeleton /> : null}
|
||||
|
||||
{factsQuery.error ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Could not load the review queue"
|
||||
description={factsQuery.error instanceof Error ? factsQuery.error.message : 'Try again.'}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!factsQuery.isLoading && !factsQuery.error && items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<Check className="size-8 text-positive" aria-hidden />}
|
||||
title="The queue is clear"
|
||||
description="Piggy has no proposed facts waiting for a human decision."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const busy = decision.isPending && decision.variables?.id === item.fact.id;
|
||||
return (
|
||||
<ReviewCard
|
||||
key={item.fact.id}
|
||||
item={item}
|
||||
busy={busy}
|
||||
mayReview={mayReview}
|
||||
onDecision={(status) => decision.mutate({ id: item.fact.id, status })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewCard({
|
||||
item,
|
||||
busy,
|
||||
mayReview,
|
||||
onDecision,
|
||||
}: {
|
||||
item: ReviewItem;
|
||||
busy: boolean;
|
||||
mayReview: boolean;
|
||||
onDecision(status: 'approved' | 'dismissed'): void;
|
||||
}) {
|
||||
const { fact } = item;
|
||||
const subject = item.contactName ?? item.accountName ?? 'Unlinked record';
|
||||
const sourceUrl = safeSourceUrl(fact.sourceUrl);
|
||||
const hasEvidence = Boolean(sourceUrl || (fact.evidence && Object.keys(fact.evidence).length > 0));
|
||||
|
||||
return (
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{subject}</p>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
{humanise(fact.field)} · observed {relativeTime(fact.observedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={fact.band === 'probable' ? 'info' : 'warning'}>
|
||||
{humanise(fact.band)}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="min-w-0 text-lg">
|
||||
<SourcedValue value={fact.value} fact={fact} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="rounded-lg bg-surface-2 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">Evidence</p>
|
||||
<p className="mt-1 break-words text-sm leading-relaxed">
|
||||
{evidenceSummary(fact.evidence)}
|
||||
</p>
|
||||
{sourceUrl ? (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex min-h-11 items-center gap-2 break-all text-sm font-medium text-accent-fg hover:underline"
|
||||
>
|
||||
Inspect source
|
||||
<ExternalLink className="size-3.5 shrink-0" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mayReview ? (
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-w-28"
|
||||
disabled={busy}
|
||||
onClick={() => onDecision('dismissed')}
|
||||
>
|
||||
<X aria-hidden />
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 sm:min-w-28"
|
||||
disabled={busy || !hasEvidence}
|
||||
title={!hasEvidence ? 'Approval requires evidence or a source.' : undefined}
|
||||
onClick={() => onDecision('approved')}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
{busy ? 'Saving…' : 'Approve evidence'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted">
|
||||
Research administrators can decide proposals. You have read-only access.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-3 xl:grid-cols-2" aria-label="Loading fact review queue">
|
||||
{[0, 1].map((item) => (
|
||||
<Card key={item} className="p-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-4 h-7 w-2/3" />
|
||||
<Skeleton className="mt-5 h-24 w-full" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IMPORT_ENTITIES,
|
||||
IMPORT_ENTITY_DEFINITIONS,
|
||||
TEAMS,
|
||||
type ImportEntity,
|
||||
type PermissionGrant,
|
||||
} from '@pig/core';
|
||||
import { AlertTriangle, CheckCircle2, FileSpreadsheet, LoaderCircle, Upload } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { ApiError, get, post } from '@/lib/api';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { NotionImportSource, type ImportedTable } from '@/components/NotionImportSource';
|
||||
import { GoogleSheetsSource } from '@/components/GoogleSheetsSource';
|
||||
|
||||
type ParsedTable = ImportedTable;
|
||||
|
||||
interface PreviewRow {
|
||||
rowNumber: number;
|
||||
key: string;
|
||||
action: 'create' | 'update' | 'error';
|
||||
recordId: string | null;
|
||||
values: Record<string, unknown>;
|
||||
errors: { field: string | null; message: string }[];
|
||||
}
|
||||
|
||||
interface Preview {
|
||||
digest: string;
|
||||
rows: PreviewRow[];
|
||||
counts: { create: number; update: number; error: number };
|
||||
}
|
||||
|
||||
interface CommitResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export function Imports() {
|
||||
usePageTitle('Import data');
|
||||
const [entity, setEntity] = useState<ImportEntity>('account');
|
||||
const [source, setSource] = useState<'file' | 'notion' | 'google'>('file');
|
||||
const [parsed, setParsed] = useState<ParsedTable | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [keySourceColumn, setKeySourceColumn] = useState('');
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
|
||||
});
|
||||
const allowed = TEAMS.some((team) => can(me, 'data:import', team));
|
||||
const definition = IMPORT_ENTITY_DEFINITIONS[entity];
|
||||
|
||||
const adoptTable = (table: ParsedTable) => {
|
||||
setParsed(table);
|
||||
setPreview(null);
|
||||
setFileError(null);
|
||||
const nextMapping: Record<string, string> = {};
|
||||
const normalisedHeaders = new Map(table.headers.map((header) => [normalise(header), header]));
|
||||
for (const field of definition.fields) {
|
||||
const source = normalisedHeaders.get(normalise(field.key)) ?? normalisedHeaders.get(normalise(field.label));
|
||||
if (source) nextMapping[field.key] = source;
|
||||
}
|
||||
setMapping(nextMapping);
|
||||
setKeySourceColumn(table.headers[0] ?? '');
|
||||
};
|
||||
|
||||
const parse = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (file.size > MAX_FILE_BYTES) throw new Error('Import files may not exceed 5 MB.');
|
||||
return post<ParsedTable>('/api/imports/parse', {
|
||||
fileName: file.name,
|
||||
mimeType: file.type || undefined,
|
||||
base64: arrayBufferToBase64(await file.arrayBuffer()),
|
||||
});
|
||||
},
|
||||
onSuccess: adoptTable,
|
||||
onError: (error) => setFileError(errorMessage(error)),
|
||||
});
|
||||
|
||||
const plan = useMemo(() => parsed ? {
|
||||
entity,
|
||||
sourceName: parsed.fileName,
|
||||
headers: parsed.headers,
|
||||
rows: parsed.rows,
|
||||
mapping,
|
||||
keySourceColumn,
|
||||
} : null, [entity, keySourceColumn, mapping, parsed]);
|
||||
const dryRun = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!plan) throw new Error('Choose and map a file first.');
|
||||
return post<Preview>('/api/imports/preview', plan);
|
||||
},
|
||||
onSuccess: setPreview,
|
||||
});
|
||||
const commit = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!plan || !preview) throw new Error('Run the dry run first.');
|
||||
return post<CommitResult>('/api/imports/commit', { ...plan, previewDigest: preview.digest });
|
||||
},
|
||||
});
|
||||
|
||||
const resetForEntity = (next: ImportEntity) => {
|
||||
setEntity(next);
|
||||
setParsed(null);
|
||||
setMapping({});
|
||||
setKeySourceColumn('');
|
||||
setPreview(null);
|
||||
setFileError(null);
|
||||
commit.reset();
|
||||
};
|
||||
|
||||
if (me && !allowed) {
|
||||
return <Card><EmptyState title="Import access required" description="A team administrator with data-import permission must run spreadsheet imports." /></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{IMPORT_ENTITIES.map((candidate) => {
|
||||
const candidateDefinition = IMPORT_ENTITY_DEFINITIONS[candidate];
|
||||
return (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
aria-pressed={entity === candidate}
|
||||
onClick={() => resetForEntity(candidate)}
|
||||
className={entity === candidate ? 'tap card min-w-0 border-accent p-4 text-left ring-1 ring-accent' : 'tap card min-w-0 p-4 text-left'}
|
||||
>
|
||||
<p className="font-semibold">{candidateDefinition.label}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted">{candidateDefinition.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet className="size-5 text-muted" aria-hidden />
|
||||
<div><CardTitle className="text-base">1. Choose source</CardTitle><p className="mt-1 text-xs text-muted">Import {definition.label.toLocaleLowerCase()} from a file, Notion, or a bounded Google Sheets range</p></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl bg-subtle p-1">
|
||||
{([
|
||||
['file', 'File'],
|
||||
['notion', 'Notion'],
|
||||
['google', 'Google Sheets'],
|
||||
] as const).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant={source === value ? 'secondary' : 'ghost'}
|
||||
className="min-h-11 px-2"
|
||||
onClick={() => setSource(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{source === 'notion' ? <NotionImportSource disabled={!allowed} onTable={adoptTable} /> : null}
|
||||
{source === 'google' ? <GoogleSheetsSource onLoaded={adoptTable} /> : null}
|
||||
{source === 'file' ? (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
disabled={!allowed || parse.isPending}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) parse.mutate(file);
|
||||
}}
|
||||
/>
|
||||
{parse.isPending ? <p className="flex items-center gap-2 text-sm text-muted"><LoaderCircle className="animate-spin" aria-hidden />Parsing untrusted cells safely…</p> : null}
|
||||
{fileError ? <ErrorNotice message={fileError} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{parsed ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge tone="positive"><CheckCircle2 aria-hidden />Parsed</Badge>
|
||||
<span className="font-medium">{parsed.fileName}</span>
|
||||
<span className="text-muted">{parsed.rows.length} rows · {parsed.headers.length} columns{parsed.sheetName ? ` · ${parsed.sheetName}` : ''}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{parsed?.warnings.map((warning) => <p key={warning} className="text-xs text-warning">{warning}</p>)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{parsed ? (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">2. Map source columns</CardTitle><p className="text-xs text-muted">Only mapped fields are written. Blank optional cells clear nullable fields; required and non-null defaulted fields are left unchanged on updates.</p></CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<label className="flex flex-col gap-1.5 text-sm font-medium">
|
||||
Stable source key
|
||||
<Select value={keySourceColumn} onValueChange={(value) => { setKeySourceColumn(value); setPreview(null); }}>
|
||||
<SelectTrigger className="h-11"><SelectValue placeholder="Choose a unique source column" /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>{parsed.headers.map((header) => <SelectItem key={header} value={header}>{header}</SelectItem>)}</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs font-normal text-muted">Repeated imports update the same PIG record only when this source value and column name are unchanged.</span>
|
||||
</label>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{definition.fields.map((field) => (
|
||||
<label key={field.key} className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">
|
||||
<span>{field.label}{field.required ? <span className="text-danger"> *</span> : null}</span>
|
||||
<Select value={mapping[field.key] ?? 'none'} onValueChange={(value) => {
|
||||
setMapping((current) => {
|
||||
const next = { ...current };
|
||||
if (value === 'none') delete next[field.key];
|
||||
else next[field.key] = value;
|
||||
return next;
|
||||
});
|
||||
setPreview(null);
|
||||
}}>
|
||||
<SelectTrigger className="h-11"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup><SelectItem value="none">Do not import</SelectItem>{parsed.headers.map((header) => <SelectItem key={header} value={header}>{header}</SelectItem>)}</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
{field.description ? <span className="text-xs font-normal text-muted">{field.description}</span> : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="primary" disabled={dryRun.isPending || !keySourceColumn} onClick={() => dryRun.mutate()}>
|
||||
{dryRun.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Upload data-icon="inline-start" aria-hidden />}
|
||||
{dryRun.isPending ? 'Validating rows…' : 'Run dry-run preview'}
|
||||
</Button>
|
||||
{dryRun.isError ? <ErrorNotice message={errorMessage(dryRun.error)} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">3. Review the exact plan</CardTitle>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge tone="positive">{preview.counts.create} create</Badge>
|
||||
<Badge tone="info">{preview.counts.update} update</Badge>
|
||||
<Badge tone={preview.counts.error ? 'danger' : 'neutral'}>{preview.counts.error} errors</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="scroll-x rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Row</TableHead><TableHead>Source key</TableHead><TableHead>Decision</TableHead><TableHead>Mapped values / errors</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{preview.rows.slice(0, 100).map((row) => (
|
||||
<TableRow key={row.rowNumber}>
|
||||
<TableCell className="nums">{row.rowNumber}</TableCell>
|
||||
<TableCell className="max-w-48 truncate font-medium">{row.key || 'Blank'}</TableCell>
|
||||
<TableCell><Badge tone={row.action === 'create' ? 'positive' : row.action === 'update' ? 'info' : 'danger'}>{row.action}</Badge></TableCell>
|
||||
<TableCell className="min-w-72">
|
||||
{row.errors.length > 0 ? <ul className="flex flex-col gap-1 text-xs text-danger">{row.errors.map((error) => <li key={`${error.field}:${error.message}`}>{error.field ? `${error.field}: ` : ''}{error.message}</li>)}</ul> : <p className="text-xs text-muted">{Object.entries(row.values).slice(0, 4).map(([key, value]) => `${key}: ${String(value)}`).join(' · ')}</p>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{preview.rows.length > 100 ? <p className="text-xs text-muted">Showing the first 100 of {preview.rows.length} rows. All rows were validated and will be committed.</p> : null}
|
||||
{preview.counts.error > 0 ? <ErrorNotice message="Fix the source data or mapping, then run the dry run again. No rows can commit while any row has an error." /> : null}
|
||||
{commit.data ? <div role="status" className="rounded-lg border border-positive/30 bg-positive/10 p-4 text-sm text-positive">Committed {commit.data.total} rows: {commit.data.created} created and {commit.data.updated} updated.</div> : null}
|
||||
{commit.isError ? <ErrorNotice message={errorMessage(commit.error)} /> : null}
|
||||
<Button variant="primary" disabled={preview.counts.error > 0 || commit.isPending || Boolean(commit.data)} onClick={() => commit.mutate()}>
|
||||
{commit.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <CheckCircle2 data-icon="inline-start" aria-hidden />}
|
||||
{commit.isPending ? 'Re-checking and committing…' : 'Commit reviewed import'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorNotice({ message }: { message: string }) {
|
||||
return <div role="alert" className="flex gap-3 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function normalise(value: string): string {
|
||||
return value.toLocaleLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) return error.message;
|
||||
return error instanceof Error ? error.message : 'The import request failed.';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PiggyChatWorkspace } from '@/components/PiggyChat';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
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>
|
||||
<PiggyChatWorkspace />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,30 +8,18 @@
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { get, money, relativeTime } from '@/lib/api';
|
||||
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface DemandDeal {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
productLine: string;
|
||||
acvCents: number | null;
|
||||
msaExecuted: boolean;
|
||||
dpaExecuted: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SupplyDeal {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
gpuType: string | null;
|
||||
gpuCount: number | null;
|
||||
targetCostPerGpuHourCents: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
import { can } from '@/lib/permissions';
|
||||
import {
|
||||
DemandDealSheet,
|
||||
SupplyDealSheet,
|
||||
type DemandDealRecord,
|
||||
type SupplyDealRecord,
|
||||
} from '@/components/RecordSheets';
|
||||
|
||||
interface Board<T> {
|
||||
stages: string[];
|
||||
@@ -64,10 +52,12 @@ const STAGE_LABELS: Record<string, string> = {
|
||||
|
||||
export function DemandPipeline() {
|
||||
return (
|
||||
<PipelineBoard<DemandDeal>
|
||||
<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>
|
||||
@@ -90,10 +80,12 @@ export function DemandPipeline() {
|
||||
|
||||
export function SupplyPipeline() {
|
||||
return (
|
||||
<PipelineBoard<SupplyDeal>
|
||||
<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>
|
||||
@@ -120,12 +112,16 @@ 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';
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode;
|
||||
}) {
|
||||
usePageTitle(title);
|
||||
|
||||
@@ -133,6 +129,12 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
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);
|
||||
|
||||
@@ -150,13 +152,15 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
if (!data || data.deals.length === 0) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +170,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<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">
|
||||
@@ -192,7 +196,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(byStage.get(currentStage) ?? []).map((row) => (
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||
<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">
|
||||
@@ -216,7 +220,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -224,6 +228,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -231,23 +236,28 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
function DealCard<T extends { id: string; updatedAt: string }>({
|
||||
row,
|
||||
renderCard,
|
||||
writable,
|
||||
onEdit,
|
||||
}: {
|
||||
row: { deal: T; accountName: string | null };
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
writable: boolean;
|
||||
onEdit(): void;
|
||||
}) {
|
||||
return (
|
||||
<article className="card p-3">
|
||||
<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 }: { title: string; subtitle: string }) {
|
||||
function Header({ title, subtitle, writable, onCreate }: { title: string; subtitle: string; writable: boolean; onCreate(): void }) {
|
||||
return (
|
||||
<header>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import { useState } from 'react';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { AdminSettings } from '@/components/AdminSettings';
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ export function Settings() {
|
||||
|
||||
<Appearance />
|
||||
<Profile me={me} />
|
||||
{me?.isPlatformAdmin ? <AdminSettings /> : null}
|
||||
<ConnectAgent />
|
||||
<SessionCard />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user