568 lines
26 KiB
TypeScript
568 lines
26 KiB
TypeScript
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.';
|
||
}
|