feat: laundry-list polish pass
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user