c35bc39adf
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>
383 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|