Files
Vector/apps/web/src/components/repairs/LogRepairDialog.tsx
T
josh c35bc39adf
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been cancelled
chore(web): tighten page and dialog copy for production
Remove internal tool references (n8n), product self-references, and
implementation-detail meta from page headers and dialog descriptions.
Copy now describes what the user is looking at rather than how the
system handles it behind the scenes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:23:48 -04:00

383 lines
14 KiB
TypeScript

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';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { logRepair } from '../../lib/api/repairs.js';
import { listHosts } from '../../lib/api/hosts.js';
import { listManufacturers } from '../../lib/api/manufacturers.js';
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 { PartModel, Repair } from '../../lib/api/types.js';
import { PartModelCombobox } from '../common/PartModelCombobox.js';
// 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__';
interface LogRepairDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLogged?: (repair: Repair) => void;
}
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]);
const hostId = form.watch('hostId');
const brokenSerial = form.watch('brokenSerial').trim();
const hosts = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open,
});
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
// Open FMs on the chosen host, so the optional linker only shows relevant items.
const openFms = useQuery({
queryKey: queryKeys.fms.list({ hostId, status: 'OPEN', pageSize: 50 }),
queryFn: () => listFms({ hostId, status: 'OPEN', pageSize: 50 }),
enabled: open && Boolean(hostId),
});
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
// already knows that part (existing) or will auto-ingest it (new).
const brokenLookup = useQuery({
queryKey: queryKeys.parts.list({ serialNumber: brokenSerial, pageSize: 1 }),
queryFn: () => listParts({ serialNumber: brokenSerial, pageSize: 1 }),
enabled: open && brokenSerial.length >= 3,
staleTime: 5_000,
});
const existingBroken = brokenLookup.data?.data.find(
(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) => {
const base = {
hostId: v.hostId,
brokenSerial: v.brokenSerial.trim(),
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 });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
onOpenChange(false);
onLogged?.(repair);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not log repair'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Log a repair</DialogTitle>
<DialogDescription>
Record a physical part swap. The broken part goes into your custody until you drop it
in a bin from the My Custody page.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
className="space-y-3"
>
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>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>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="brokenSerial"
render={({ field }) => (
<FormItem>
<FormLabel>Broken serial</FormLabel>
<FormControl>
<Input autoFocus placeholder="SN-…" {...field} />
</FormControl>
{brokenSerial.length >= 3 && (
<FormDescription>
{brokenLookup.isFetching
? 'Looking up…'
: existingBroken
? `Found: ${existingBroken.partModel.mpn}`
: 'Will be ingested as a new part.'}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="replacementSerial"
render={({ field }) => (
<FormItem>
<FormLabel>Replacement serial</FormLabel>
<FormControl>
<Input placeholder="SN-…" {...field} />
</FormControl>
<FormDescription>Must be an existing SPARE.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{!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="fmId"
render={({ field }) => (
<FormItem>
<FormLabel>Link to open FM (optional)</FormLabel>
<Select
value={field.value ? field.value : NO_FM}
onValueChange={(v) => field.onChange(v === NO_FM ? '' : v)}
disabled={!hostId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={hostId ? 'No linked FM' : 'Pick a host first'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NO_FM}>No linked FM</SelectItem>
{openFms.data?.data.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.problem.slice(0, 80)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Linking doesn't auto-close the FM.
</FormDescription>
<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" />}
Log repair
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}