0f952d6c1b
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>
326 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|