Files
Vector/apps/web/src/components/repairs/RepairFormDialog.tsx
T
josh 0f952d6c1b
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s
feat: rework EOL, repairs, and hosts for real workflow
Four domain-model changes driven by exercising the deployed 2.0 build:

- EOL moves from manufacturer to MPN via new PartModel catalog table,
  so alerts fire on the thing that actually ages.
- Repairs re-home to Host (required hostId + problem text) with an
  optional RepairJobPart join for affected parts; drop Part.replacementPartId.
- New /repairs/:id detail page with editable problem, part list, and
  a RepairComment thread (REPAIR_COMMENTED events fan out to each
  problem part's timeline).
- Host.assetId (required, unique) surfaces prominently on the repair
  page so techs can confirm they're touching the right box.

Single destructive migration reshapes existing dev data. All 7 packages
typecheck clean; 30 API tests pass (9 new covering host membership,
upsertByMpn idempotency + race, assetId 409, comment userId stamping).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:17:29 -04:00

280 lines
9.1 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 { 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>
);
}