feat: laundry-list polish pass
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s

Seven bundled improvements:
- PartModel combobox on Add Part + Log Repair (known MPN auto-fills;
  unknown reveals manufacturer picker for catalog upsert).
- Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack
  (PRODUCTION/VETTING) fields, driven by external clients via the API.
- Locations page redesigned as a 2-pane tree + bin grid with breadcrumb.
- PENDING_REPAIR custody state: tech takes a SPARE into custody for a
  future swap; resolves to DEPLOYED via Repair or back to SPARE via a
  bin-required drop-off.
- Move Category from Part to PartModel; seed common categories
  (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category
  column and filter sourced from the model.
- Fix Deployed Value 100x bug on the Dashboard (price is stored as
  dollars, not cents).
- PartModels table shows "No" instead of "--" when destroyOnFail=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 13:36:11 -04:00
parent 3d77f2846d
commit 60255f20bb
39 changed files with 1731 additions and 630 deletions
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -34,16 +34,42 @@ import { listFms } from '../../lib/api/fms.js';
import { listParts } from '../../lib/api/parts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Repair } from '../../lib/api/types.js';
import type { PartModel, Repair } from '../../lib/api/types.js';
import { PartModelCombobox } from '../common/PartModelCombobox.js';
const Schema = z.object({
hostId: z.string().uuid('Pick a host'),
brokenSerial: z.string().trim().min(1, 'Required').max(128),
brokenMpn: z.string().trim().min(1, 'Required').max(128),
brokenManufacturerId: z.string().uuid('Select a manufacturer'),
replacementSerial: z.string().trim().min(1, 'Required').max(128),
fmId: z.string().optional(),
});
// When the broken serial matches an existing Part the model fields are skipped entirely;
// otherwise the tech either picks an existing PartModel (partModelId) or types a new MPN
// and a manufacturer. The refine mirrors LogRepairRequest.superRefine on the server.
const Schema = z
.object({
hostId: z.string().uuid('Pick a host'),
brokenSerial: z.string().trim().min(1, 'Required').max(128),
brokenPartModelId: z.string().uuid().optional(),
brokenMpn: z.string().trim().max(128).optional(),
brokenManufacturerId: z.string().uuid().optional(),
replacementSerial: z.string().trim().min(1, 'Required').max(128),
fmId: z.string().optional(),
brokenExists: z.boolean().optional(),
})
.superRefine((v, ctx) => {
if (v.brokenExists) return;
const hasModel = Boolean(v.brokenPartModelId);
const hasNew = Boolean(v.brokenMpn && v.brokenMpn.length > 0);
if (!hasModel && !hasNew) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Pick a part model or enter a new MPN',
path: ['brokenPartModelId'],
});
}
if (hasNew && !v.brokenManufacturerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Select a manufacturer for the new model',
path: ['brokenManufacturerId'],
});
}
});
type Values = z.infer<typeof Schema>;
const NO_FM = '__none__';
@@ -57,27 +83,34 @@ interface LogRepairDialogProps {
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
const queryClient = useQueryClient();
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: {
hostId: '',
brokenSerial: '',
brokenPartModelId: '',
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
fmId: '',
brokenExists: false,
},
});
useEffect(() => {
if (!open) return;
setPickedModel(null);
form.reset({
hostId: '',
brokenSerial: '',
brokenPartModelId: '',
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
fmId: '',
brokenExists: false,
});
}, [open, form]);
@@ -115,16 +148,30 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
(p) => p.serialNumber === brokenSerial,
);
// Keep a form-level flag so the zod refine can skip model validation when the broken part
// is already in the catalog (server just reuses the existing PartModel).
useEffect(() => {
form.setValue('brokenExists', Boolean(existingBroken), { shouldValidate: true });
}, [existingBroken, form]);
const mutation = useMutation({
mutationFn: (v: Values) =>
logRepair({
mutationFn: (v: Values) => {
const base = {
hostId: v.hostId,
brokenSerial: v.brokenSerial.trim(),
brokenMpn: v.brokenMpn.trim(),
brokenManufacturerId: v.brokenManufacturerId,
replacementSerial: v.replacementSerial.trim(),
fmId: v.fmId ? v.fmId : undefined,
}),
};
// If the broken part is already catalogued, the server ignores model fields entirely.
if (existingBroken) return logRepair(base);
const modelPayload = v.brokenPartModelId
? { brokenPartModelId: v.brokenPartModelId }
: {
brokenMpn: v.brokenMpn?.trim(),
brokenManufacturerId: v.brokenManufacturerId,
};
return logRepair({ ...base, ...modelPayload });
},
onSuccess: (repair) => {
toast.success('Repair logged');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
@@ -217,45 +264,68 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="brokenMpn"
render={({ field }) => (
<FormItem>
<FormLabel>Broken MPN</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
{!existingBroken && (
<>
<FormField
control={form.control}
name="brokenPartModelId"
render={() => (
<FormItem>
<FormLabel>Broken part model</FormLabel>
<PartModelCombobox
value={pickedModel}
newMpn={form.watch('brokenMpn') || null}
onPick={(m) => {
setPickedModel(m);
form.setValue('brokenPartModelId', m.id, { shouldValidate: true });
form.setValue('brokenMpn', '');
form.setValue('brokenManufacturerId', '');
}}
onCreateNew={(mpn) => {
setPickedModel(null);
form.setValue('brokenPartModelId', '');
form.setValue('brokenMpn', mpn, { shouldValidate: true });
}}
onClear={() => {
setPickedModel(null);
form.setValue('brokenPartModelId', '');
form.setValue('brokenMpn', '');
form.setValue('brokenManufacturerId', '');
}}
/>
<FormMessage />
</FormItem>
)}
/>
{!pickedModel && form.watch('brokenMpn') && (
<FormField
control={form.control}
name="brokenManufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Manufacturer (for new model)</FormLabel>
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
/>
<FormField
control={form.control}
name="brokenManufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Broken manufacturer</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
<FormField
control={form.control}