feat: split Repairs into FM, Repair, and Custody workflows
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s

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:
2026-04-17 12:22:56 -04:00
parent 6690d8a5dd
commit 3d77f2846d
54 changed files with 3304 additions and 1287 deletions
@@ -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>
);
}
@@ -1,118 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Button, Skeleton, Textarea } from '@vector/ui';
import { addRepairComment, listRepairComments } from '../../lib/api/repairs.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
function formatWhen(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function initialsOf(username: string | undefined | null): string {
if (!username) return '?';
return username.slice(0, 2).toUpperCase();
}
export function RepairCommentThread({ repairId }: { repairId: string }) {
const queryClient = useQueryClient();
const [content, setContent] = useState('');
const query = useQuery({
queryKey: queryKeys.repairs.comments(repairId),
queryFn: () => listRepairComments(repairId, { pageSize: 100 }),
});
const mutation = useMutation({
mutationFn: (body: string) => addRepairComment(repairId, { content: body }),
onSuccess: () => {
setContent('');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.comments(repairId) });
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Post failed'),
});
const submit = () => {
const trimmed = content.trim();
if (!trimmed) return;
mutation.mutate(trimmed);
};
return (
<div className="space-y-4">
{query.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : query.isError ? (
<p className="text-sm text-destructive">Could not load comments.</p>
) : query.data && query.data.data.length > 0 ? (
<ol className="space-y-3">
{query.data.data.map((c) => (
<li key={c.id} className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-foreground">
{initialsOf(c.user?.username)}
</div>
<div className="flex-1 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{c.user?.username ?? 'Unknown user'}
</span>
<span>·</span>
<span>{formatWhen(c.createdAt)}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-foreground">{c.content}</p>
</div>
</li>
))}
</ol>
) : (
<p className="text-sm text-muted-foreground">No comments yet. Start the thread below.</p>
)}
<div className="space-y-2">
<Textarea
rows={3}
placeholder="Leave a comment..."
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={mutation.isPending}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{content.length}/4000 · Ctrl/+Enter to post
</span>
<Button
size="sm"
onClick={submit}
disabled={mutation.isPending || content.trim().length === 0}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
Post
</Button>
</div>
</div>
</div>
);
}
@@ -1,279 +0,0 @@
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,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Textarea,
} from '@vector/ui';
import { createRepair } from '../../lib/api/repairs.js';
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { RepairJob } from '../../lib/api/types.js';
const CreateSchema = z.object({
hostId: z.string().uuid('Pick a host'),
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100),
notes: z.string().max(4096).optional(),
});
type CreateValues = z.infer<typeof CreateSchema>;
interface RepairFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultHostId?: string;
defaultProblemPartIds?: string[];
onCreated?: (repair: RepairJob) => void;
}
export function RepairFormDialog({
open,
onOpenChange,
defaultHostId,
defaultProblemPartIds,
onCreated,
}: RepairFormDialogProps) {
const queryClient = useQueryClient();
const hostsQuery = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open,
});
const form = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
defaultValues: {
hostId: '',
problem: '',
problemPartIds: [],
notes: '',
},
});
useEffect(() => {
if (!open) return;
form.reset({
hostId: defaultHostId ?? '',
problem: '',
problemPartIds: defaultProblemPartIds ?? [],
notes: '',
});
}, [open, defaultHostId, defaultProblemPartIds, form]);
const hostId = form.watch('hostId');
const selectedPartIds = form.watch('problemPartIds');
const deployedQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(hostId),
queryFn: () => listHostDeployedParts(hostId),
enabled: open && Boolean(hostId),
});
const createMutation = useMutation({
mutationFn: async (values: CreateValues) =>
createRepair({
hostId: values.hostId,
problem: values.problem,
problemPartIds:
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
notes: values.notes ? values.notes : null,
}),
onSuccess: (repair) => {
toast.success('Repair opened');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
onCreated?.(repair);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
const pending = createMutation.isPending;
function togglePart(partId: string, checked: boolean) {
const next = checked
? [...new Set([...selectedPartIds, partId])]
: selectedPartIds.filter((id) => id !== partId);
form.setValue('problemPartIds', next, { shouldValidate: true, shouldDirty: true });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Open repair</DialogTitle>
<DialogDescription>
Create a repair against a host. Select the deployed parts involved (optional).
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((v) => createMutation.mutate(v))}
className="space-y-3"
>
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v);
form.setValue('problemPartIds', [], {
shouldValidate: false,
});
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
{hostsQuery.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="problem"
render={({ field }) => (
<FormItem>
<FormLabel>Problem</FormLabel>
<FormControl>
<Textarea
rows={3}
placeholder="Short description of what's wrong."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{hostId && (
<FormField
control={form.control}
name="problemPartIds"
render={() => (
<FormItem>
<FormLabel>Affected parts (optional)</FormLabel>
<FormDescription>
Select deployed parts involved in this problem.
</FormDescription>
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
{deployedQuery.isPending ? (
<Skeleton className="m-2 h-12" />
) : !deployedQuery.data || deployedQuery.data.length === 0 ? (
<p className="p-3 text-xs text-muted-foreground">
No deployed parts on this host.
</p>
) : (
<ul className="divide-y divide-border">
{deployedQuery.data.map((part) => {
const checked = selectedPartIds.includes(part.id);
return (
<li
key={part.id}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Checkbox
id={`pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => togglePart(part.id, v === true)}
/>
<label
htmlFor={`pp-${part.id}`}
className="flex-1 cursor-pointer select-none"
>
<span className="font-mono text-xs">
{part.serialNumber}
</span>{' '}
<span className="text-xs text-muted-foreground">
{part.partModel.mpn}
</span>
</label>
</li>
);
})}
</ul>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes (optional)</FormLabel>
<FormControl>
<Textarea rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
Open repair
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -1,24 +0,0 @@
import type { RepairStatus } from '@vector/shared';
import { Badge } from '@vector/ui';
const LABELS: Record<RepairStatus, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In progress',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
};
const VARIANTS: Record<RepairStatus, 'outline' | 'warning' | 'success' | 'secondary'> = {
PENDING: 'outline',
IN_PROGRESS: 'warning',
COMPLETED: 'success',
CANCELLED: 'secondary',
};
export const repairStatusOptions: { value: RepairStatus; label: string }[] = (
Object.keys(LABELS) as RepairStatus[]
).map((value) => ({ value, label: LABELS[value] }));
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
}