Files
pig/apps/web/src/components/AllocationSheet.tsx
T
karti e12d27edd1
CI / verify (push) Successful in 3m32s
Polish every product workflow across desktop and mobile
Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit.

The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
2026-08-13 05:34:23 -07:00

594 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { toast } from 'sonner';
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, error: availabilityError } = 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, isLoading: demandLoading, error: demandError } = 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 (allocation, values) => {
await refresh();
onOpenChange(false);
// The sheet closes on success, so without this the only evidence the
// write landed is a number moving somewhere off-screen. Report what
// actually happened, in the units the seller was thinking in.
const hours = Number(allocation.gpuHours ?? values.gpuHours).toLocaleString();
toast.success(
values.kind === 'hold' ? 'Capacity held' : 'Capacity allocated',
{
description:
values.kind === 'hold'
? `${hours} GPU-hours reserved. The hold releases automatically when it expires.`
: `${hours} GPU-hours committed. Margin and sold-capacity reporting have been updated.`,
},
);
},
onError: (error) => toast.error('Could not save', { description: errorMessage(error) }),
});
const release = useMutation({
mutationFn: (id: string) =>
post<AllocationRecord>(`/api/allocations/${id}/release`, {
reason: releaseReason.trim() || undefined,
}),
onMutate: () => setReleaseError(null),
onSuccess: async () => {
await refresh();
toast.success('Hold released', {
description: 'The capacity is available to sell again.',
});
},
onError: (error) => {
setReleaseError(errorMessage(error));
toast.error('Could not release the hold', { description: 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 max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
<SheetTitle>Reserve capacity</SheetTitle>
<SheetDescription>
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-5 overflow-y-auto overscroll-contain px-5 py-5 sm:gap-6 sm:px-6">
<div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type">
{(['allocation', 'hold'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => form.setValue('kind', value)}
aria-pressed={kind === value}
className={
kind === value
? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm'
: 'tap rounded-md px-3 text-sm font-medium text-muted'
}
>
{value === 'allocation' ? 'Sell allocation' : 'Timed hold'}
</button>
))}
</div>
{availabilityError || demandError ? <ServerError message={errorMessage(availabilityError ?? demandError)} /> : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
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={demandLoading ? 'Loading customer deals…' : '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="w-full shrink-0 sm:w-auto" disabled={release.isPending} onClick={() => release.mutate(allocation.id)}>
{release.isPending && release.variables === allocation.id ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <RotateCcw data-icon="inline-start" aria-hidden />}
Release
</Button>
</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="break-words font-semibold leading-snug">{row.name}</p>
<p className="mt-1 text-xs text-muted">{row.gpuCount}× {row.gpuType} · {row.interconnectType} · {row.securityTier.replace(/_/g, ' ')}</p>
</div>
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
</div>
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours available`}>
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
</div>
<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.';
}