feat(parts): couple state and location (host vs bin)
CI / Lint · Typecheck · Test · Build (push) Successful in 45s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m23s

DEPLOYED parts live on a host; every other state lives in a bin (or
unassigned). Previously binId and hostId were independent nullable
fields with no validation, so the Edit Part dialog could leave a
DEPLOYED part with only a bin and no host — which silently dropped
it from the repair problem-part picker.

- Service: resolveLocation() helper enforces the invariant on create
  and update. On a state transition, update auto-clears the stale
  relation and emits LOCATION_CHANGED for the cleared side.
- Zod: CreatePartRequest.superRefine rejects mismatched state/location
  up front; UpdatePartRequest rejects both-fields-set.
- Web: PartFormDialog swaps a single Location field between Host
  combobox (DEPLOYED) and Bin combobox (others); switching State
  clears the opposite field. Parts list + detail render host first,
  then bin path, then Unassigned.
- Tests: 9 new cases covering the invariant including the no-op guard
  so an unrelated PATCH on a DEPLOYED part doesn't touch hostId/binId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 10:43:02 -04:00
parent 0f952d6c1b
commit 6690d8a5dd
6 changed files with 432 additions and 52 deletions
+101 -40
View File
@@ -30,6 +30,7 @@ import {
} from '@vector/ui';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { listBins } from '../../lib/api/bins.js';
import { listHosts } from '../../lib/api/hosts.js';
import { createPart, updatePart } from '../../lib/api/parts.js';
import type { Part } from '../../lib/api/types.js';
import { ApiRequestError } from '../../lib/api/client.js';
@@ -38,15 +39,26 @@ 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(),
});
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
hostId: z.string().optional(), // '' = none
price: z.string().optional(), // empty string = null
notes: z.string().max(4096).optional(),
})
.superRefine((v, ctx) => {
if (v.state === 'DEPLOYED' && !v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A deployed part must be assigned to a host',
path: ['hostId'],
});
}
});
type PartFormValues = z.infer<typeof PartFormSchema>;
const UNASSIGNED = '__none__';
@@ -69,6 +81,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
manufacturerId: '',
state: 'SPARE',
binId: '',
hostId: '',
price: '',
notes: '',
},
@@ -84,6 +97,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
manufacturerId: part.manufacturerId,
state: part.state,
binId: part.binId ?? '',
hostId: part.hostId ?? '',
price: part.price != null ? String(part.price) : '',
notes: part.notes ?? '',
}
@@ -93,12 +107,15 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
manufacturerId: '',
state: 'SPARE',
binId: '',
hostId: '',
price: '',
notes: '',
},
);
}, [open, part, form]);
const watchedState = form.watch('state');
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
@@ -108,17 +125,25 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
const bins = useQuery({
queryKey: queryKeys.bins.list({ pageSize: 100 }),
queryFn: () => listBins({ pageSize: 100 }),
enabled: open,
enabled: open && watchedState !== 'DEPLOYED',
});
const hosts = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open && watchedState === 'DEPLOYED',
});
const mutation = useMutation({
mutationFn: async (values: PartFormValues) => {
const deployed = values.state === 'DEPLOYED';
const payload = {
serialNumber: values.serialNumber,
mpn: values.mpn,
manufacturerId: values.manufacturerId,
state: values.state,
binId: values.binId ? values.binId : null,
binId: deployed ? null : values.binId ? values.binId : null,
hostId: deployed ? (values.hostId ? values.hostId : null) : null,
price: values.price === '' ? null : Number(values.price),
notes: values.notes ? values.notes : null,
};
@@ -227,7 +252,16 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
render={({ field }) => (
<FormItem>
<FormLabel>State</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<Select
value={field.value}
onValueChange={(v) => {
field.onChange(v);
// State and location are coupled: DEPLOYED lives on a host, all other
// states live in a bin. Clear the now-invalid field on transition.
if (v === 'DEPLOYED') form.setValue('binId', '');
else form.setValue('hostId', '');
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
@@ -260,34 +294,61 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
/>
</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>
)}
/>
{watchedState === 'DEPLOYED' ? (
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Location (host)</FormLabel>
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
{hosts.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="binId"
render={({ field }) => (
<FormItem>
<FormLabel>Location (bin)</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}
+5 -1
View File
@@ -166,7 +166,11 @@ export default function PartDetail() {
<DetailRow
label="Location"
value={
part.bin?.fullPath ? (
part.host ? (
<span className="font-mono text-xs">
{part.host.assetId} / {part.host.name}
</span>
) : part.bin?.fullPath ? (
<span className="font-mono text-xs">{part.bin.fullPath}</span>
) : (
<span className="text-muted-foreground italic">Unassigned</span>
+8
View File
@@ -116,6 +116,14 @@ export default function Parts() {
id: 'location',
header: 'Location',
cell: ({ row }) => {
const host = row.original.host;
if (host) {
return (
<span className="text-xs font-mono text-muted-foreground">
{host.assetId} / {host.name}
</span>
);
}
const path = row.original.bin?.fullPath;
return path ? (
<span className="text-xs font-mono text-muted-foreground">{path}</span>