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>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
@@ -25,44 +26,38 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { createRepair, updateRepair } from '../../lib/api/repairs.js';
|
||||
import { listHosts } from '../../lib/api/hosts.js';
|
||||
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';
|
||||
import { repairStatusOptions } from './RepairStatusBadge.js';
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
partId: z.string().uuid('Pick a valid part id'),
|
||||
hostId: z.string().optional(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
});
|
||||
const EditSchema = z.object({
|
||||
status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
|
||||
hostId: z.string().optional(),
|
||||
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>;
|
||||
type EditValues = z.infer<typeof EditSchema>;
|
||||
|
||||
interface RepairFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
repair?: RepairJob | null;
|
||||
defaultPartId?: string;
|
||||
defaultHostId?: string;
|
||||
defaultProblemPartIds?: string[];
|
||||
onCreated?: (repair: RepairJob) => void;
|
||||
}
|
||||
|
||||
export function RepairFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
repair,
|
||||
defaultPartId,
|
||||
defaultHostId,
|
||||
defaultProblemPartIds,
|
||||
onCreated,
|
||||
}: RepairFormDialogProps) {
|
||||
const editing = Boolean(repair);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const hostsQuery = useQuery({
|
||||
@@ -71,242 +66,213 @@ export function RepairFormDialog({
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const createForm = useForm<CreateValues>({
|
||||
const form = useForm<CreateValues>({
|
||||
resolver: zodResolver(CreateSchema),
|
||||
defaultValues: { partId: '', hostId: NONE, notes: '' },
|
||||
});
|
||||
const editForm = useForm<EditValues>({
|
||||
resolver: zodResolver(EditSchema),
|
||||
defaultValues: { status: 'PENDING', hostId: NONE, notes: '' },
|
||||
defaultValues: {
|
||||
hostId: '',
|
||||
problem: '',
|
||||
problemPartIds: [],
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing && repair) {
|
||||
editForm.reset({
|
||||
status: repair.status,
|
||||
hostId: repair.hostId ?? NONE,
|
||||
notes: repair.notes ?? '',
|
||||
});
|
||||
} else {
|
||||
createForm.reset({ partId: defaultPartId ?? '', hostId: NONE, notes: '' });
|
||||
}
|
||||
}, [open, editing, repair, defaultPartId, createForm, editForm]);
|
||||
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({
|
||||
partId: values.partId,
|
||||
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
|
||||
hostId: values.hostId,
|
||||
problem: values.problem,
|
||||
problemPartIds:
|
||||
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
|
||||
notes: values.notes ? values.notes : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
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 editMutation = useMutation({
|
||||
mutationFn: async (values: EditValues) =>
|
||||
updateRepair(repair!.id, {
|
||||
status: values.status,
|
||||
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Repair updated');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
||||
});
|
||||
const pending = createMutation.isPending;
|
||||
|
||||
const pending = createMutation.isPending || editMutation.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-md">
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit repair' : 'Open repair'}</DialogTitle>
|
||||
<DialogTitle>Open repair</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing
|
||||
? 'Advance status, re-assign the host, or update notes.'
|
||||
: 'Open a repair job for a part. Status starts as PENDING.'}
|
||||
Create a repair against a host. Select the deployed parts involved (optional).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editing ? (
|
||||
<Form {...editForm}>
|
||||
<form
|
||||
onSubmit={editForm.handleSubmit((v) => editMutation.mutate(v))}
|
||||
className="space-y-3"
|
||||
>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{repairStatusOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{hostsQuery.data?.data.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<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>
|
||||
<Textarea rows={3} {...field} />
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select host" />
|
||||
</SelectTrigger>
|
||||
</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" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...createForm}>
|
||||
<form
|
||||
onSubmit={createForm.handleSubmit((v) => createMutation.mutate(v))}
|
||||
className="space-y-3"
|
||||
>
|
||||
<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={createForm.control}
|
||||
name="partId"
|
||||
render={({ field }) => (
|
||||
control={form.control}
|
||||
name="problemPartIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Part ID</FormLabel>
|
||||
<FormControl>
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
placeholder="Part UUID"
|
||||
autoFocus
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Affected parts (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
Paste the part UUID to open a repair against it.
|
||||
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={createForm.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host (optional)</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{hostsQuery.data?.data.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...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>
|
||||
)}
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user