Build the agent-native compute CRM platform
CI / verify (push) Successful in 3m6s

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
+228
View File
@@ -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>;
}
+567
View File
@@ -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>
);
}
+243
View File
@@ -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.';
}
+332
View File
@@ -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());
}
+693
View File
@@ -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 persons 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.';
}
+46 -5
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+50
View File
@@ -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 }
+57
View File
@@ -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 }
+28
View File
@@ -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 }
+151
View File
@@ -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,
}
+120
View File
@@ -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,
}
+116
View File
@@ -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,
}
+178
View File
@@ -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,
}
+24
View File
@@ -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 }
+31
View File
@@ -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 }
+159
View File
@@ -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,
}
+31
View File
@@ -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 }
+140
View File
@@ -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,
}
+29
View File
@@ -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 }
+29
View File
@@ -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 }
+120
View File
@@ -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,
}
+53
View File
@@ -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 }
+22
View File
@@ -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 }
+32
View File
@@ -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 }