3d77f2846d
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>
254 lines
8.3 KiB
TypeScript
254 lines
8.3 KiB
TypeScript
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 { createFm } from '../../lib/api/fms.js';
|
|
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
|
|
import { ApiRequestError } from '../../lib/api/client.js';
|
|
import { queryKeys } from '../../lib/queryKeys.js';
|
|
import type { Fm } 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),
|
|
});
|
|
type CreateValues = z.infer<typeof CreateSchema>;
|
|
|
|
interface FmFormDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
defaultHostId?: string;
|
|
defaultProblemPartIds?: string[];
|
|
onCreated?: (fm: Fm) => void;
|
|
}
|
|
|
|
export function FmFormDialog({
|
|
open,
|
|
onOpenChange,
|
|
defaultHostId,
|
|
defaultProblemPartIds,
|
|
onCreated,
|
|
}: FmFormDialogProps) {
|
|
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: [] },
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
form.reset({
|
|
hostId: defaultHostId ?? '',
|
|
problem: '',
|
|
problemPartIds: defaultProblemPartIds ?? [],
|
|
});
|
|
}, [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) =>
|
|
createFm({
|
|
hostId: values.hostId,
|
|
problem: values.problem,
|
|
problemPartIds:
|
|
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
|
|
}),
|
|
onSuccess: (fm) => {
|
|
toast.success('FM opened');
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
|
onOpenChange(false);
|
|
onCreated?.(fm);
|
|
},
|
|
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 FM</DialogTitle>
|
|
<DialogDescription>
|
|
Open a Future Maintenance item against a host. Select 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>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<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 FM
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|