Files
Vector/apps/web/src/components/parts/PartFormDialog.tsx
T
josh 0f952d6c1b
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s
feat: rework EOL, repairs, and hosts for real workflow
Four domain-model changes driven by exercising the deployed 2.0 build:

- EOL moves from manufacturer to MPN via new PartModel catalog table,
  so alerts fire on the thing that actually ages.
- Repairs re-home to Host (required hostId + problem text) with an
  optional RepairJobPart join for affected parts; drop Part.replacementPartId.
- New /repairs/:id detail page with editable problem, part list, and
  a RepairComment thread (REPAIR_COMMENTED events fan out to each
  problem part's timeline).
- Host.assetId (required, unique) surfaces prominently on the repair
  page so techs can confirm they're touching the right box.

Single destructive migration reshapes existing dev data. All 7 packages
typecheck clean; 30 API tests pass (9 new covering host membership,
upsertByMpn idempotency + race, assetId 409, comment userId stamping).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:17:29 -04:00

326 lines
10 KiB
TypeScript

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { PartState } from '@vector/shared';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@vector/ui';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { listBins } from '../../lib/api/bins.js';
import { createPart, updatePart } from '../../lib/api/parts.js';
import type { Part } from '../../lib/api/types.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { partStateOptions } from './PartStateBadge.js';
// Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the
// submit handler coerce to the network shape.
const PartFormSchema = z.object({
serialNumber: z.string().min(1, 'Required').max(128),
mpn: z.string().min(1, 'Required').max(128),
manufacturerId: z.string().uuid('Select a manufacturer'),
state: PartState,
binId: z.string().optional(), // '' = none
price: z.string().optional(), // empty string = null
notes: z.string().max(4096).optional(),
});
type PartFormValues = z.infer<typeof PartFormSchema>;
const UNASSIGNED = '__none__';
interface PartFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
part?: Part | null;
}
export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps) {
const editing = Boolean(part);
const queryClient = useQueryClient();
const form = useForm<PartFormValues>({
resolver: zodResolver(PartFormSchema),
defaultValues: {
serialNumber: '',
mpn: '',
manufacturerId: '',
state: 'SPARE',
binId: '',
price: '',
notes: '',
},
});
useEffect(() => {
if (!open) return;
form.reset(
part
? {
serialNumber: part.serialNumber,
mpn: part.partModel.mpn,
manufacturerId: part.manufacturerId,
state: part.state,
binId: part.binId ?? '',
price: part.price != null ? String(part.price) : '',
notes: part.notes ?? '',
}
: {
serialNumber: '',
mpn: '',
manufacturerId: '',
state: 'SPARE',
binId: '',
price: '',
notes: '',
},
);
}, [open, part, form]);
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
const bins = useQuery({
queryKey: queryKeys.bins.list({ pageSize: 100 }),
queryFn: () => listBins({ pageSize: 100 }),
enabled: open,
});
const mutation = useMutation({
mutationFn: async (values: PartFormValues) => {
const payload = {
serialNumber: values.serialNumber,
mpn: values.mpn,
manufacturerId: values.manufacturerId,
state: values.state,
binId: values.binId ? values.binId : null,
price: values.price === '' ? null : Number(values.price),
notes: values.notes ? values.notes : null,
};
return editing && part
? updatePart(part.id, payload)
: createPart(payload);
},
onSuccess: (saved) => {
toast.success(editing ? 'Part updated' : 'Part created');
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
if (editing) {
queryClient.setQueryData(queryKeys.parts.detail(saved.id), saved);
}
onOpenChange(false);
},
onError: (err) => {
const msg =
err instanceof ApiRequestError ? err.body.message : 'Could not save part';
toast.error(msg);
},
});
const onSubmit = (values: PartFormValues) => {
if (values.price !== '' && values.price !== undefined) {
const n = Number(values.price);
if (!Number.isFinite(n) || n < 0) {
form.setError('price', { message: 'Must be a non-negative number' });
return;
}
}
mutation.mutate(values);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{editing ? 'Edit part' : 'New part'}</DialogTitle>
<DialogDescription>
{editing
? 'Update this part. Changes are logged to its history.'
: 'Add a part to inventory. Serial numbers must be unique.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="serialNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Serial</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mpn"
render={({ field }) => (
<FormItem>
<FormLabel>MPN</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="manufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Manufacturer</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select manufacturer" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="state"
render={({ field }) => (
<FormItem>
<FormLabel>State</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{partStateOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price (USD)</FormLabel>
<FormControl>
<Input type="number" step="0.01" min="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="binId"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<Select
value={field.value ? field.value : UNASSIGNED}
onValueChange={(v) => field.onChange(v === UNASSIGNED ? '' : v)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{bins.data?.data.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.fullPath ?? b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{editing ? 'Save changes' : 'Create part'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}