0f952d6c1b
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>
280 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|