feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle, comments, assignee, notes) that duplicate what the external ticketing tool already owns. Vector only needs to track whether maintenance is open or closed. - Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes - New Repair table: persistent log of physical part swaps, with ingest on unknown broken MPN via partModels.upsertByMpn - New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY states + Part.custodianId, with a "My Custody" page for drop-off - PartModel.destroyOnFail routes broken parts to the destruction path - Host lookup on /fms and /repairs accepts hostId XOR assetId - Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged - Single fresh Prisma migration (dev DB was wiped, no backfill) Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts, repairs.test.ts, custody.test.ts covering happy paths, validation failures, webhook emissions, and ingest-on-unknown-MPN). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import { useEffect } 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 { Repair } from '../../lib/api/types.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(),
|
||||
});
|
||||
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 form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset({
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
});
|
||||
}, [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,
|
||||
);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (v: Values) =>
|
||||
logRepair({
|
||||
hostId: v.hostId,
|
||||
brokenSerial: v.brokenSerial.trim(),
|
||||
brokenMpn: v.brokenMpn.trim(),
|
||||
brokenManufacturerId: v.brokenManufacturerId,
|
||||
replacementSerial: v.replacementSerial.trim(),
|
||||
fmId: v.fmId ? v.fmId : undefined,
|
||||
}),
|
||||
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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<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}
|
||||
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 — n8n handles that.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user