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; 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(null); const form = useForm({ 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 ( Log a repair Record a physical part swap. The broken part goes into your custody until you drop it in a bin from the My Custody page.
mutation.mutate(v))} className="space-y-3" > ( Host )} />
( Broken serial {brokenSerial.length >= 3 && ( {brokenLookup.isFetching ? 'Looking up…' : existingBroken ? `Found: ${existingBroken.partModel.mpn}` : 'Will be ingested as a new part.'} )} )} /> ( Replacement serial Must be an existing SPARE. )} />
{!existingBroken && ( <> ( Broken part model { 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', ''); }} /> )} /> {!pickedModel && form.watch('brokenMpn') && ( ( Manufacturer (for new model) )} /> )} )} ( Link to open FM (optional) Linking doesn't auto-close the FM. )} />
); }