feat: rework EOL, repairs, and hosts for real workflow
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s

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:
2026-04-17 10:17:29 -04:00
parent 23bd0f0c6a
commit 0f952d6c1b
50 changed files with 2665 additions and 602 deletions
+4
View File
@@ -12,7 +12,9 @@ import Parts from './pages/Parts.js';
import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js';
import Manufacturers from './pages/Manufacturers.js';
import PartModels from './pages/PartModels.js';
import Repairs from './pages/Repairs.js';
import RepairDetail from './pages/RepairDetail.js';
import Hosts from './pages/Hosts.js';
import Users from './pages/admin/Users.js';
import Webhooks from './pages/admin/Webhooks.js';
@@ -54,7 +56,9 @@ export default function App() {
<Route path="/parts/:id" element={<PartDetail />} />
<Route path="/locations" element={<Locations />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/part-models" element={<PartModels />} />
<Route path="/repairs" element={<Repairs />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
<Route path="/hosts" element={<Hosts />} />
<Route
path="/admin/users"
@@ -28,6 +28,7 @@ import { queryKeys } from '../../lib/queryKeys.js';
import type { Host } from '../../lib/api/types.js';
const Schema = z.object({
assetId: z.string().trim().min(1, 'Required').max(64),
name: z.string().min(1, 'Required').max(128),
location: z.string().max(256).optional(),
notes: z.string().max(4096).optional(),
@@ -46,12 +47,13 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', location: '', notes: '' },
defaultValues: { assetId: '', name: '', location: '', notes: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
assetId: host?.assetId ?? '',
name: host?.name ?? '',
location: host?.location ?? '',
notes: host?.notes ?? '',
@@ -60,12 +62,20 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
if (editing && host) {
return updateHost(host.id, {
assetId: values.assetId,
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
});
}
return createHost({
assetId: values.assetId,
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
};
return editing && host ? updateHost(host.id, payload) : createHost(payload);
});
},
onSuccess: () => {
toast.success(editing ? 'Host updated' : 'Host created');
@@ -88,6 +98,19 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="assetId"
render={({ field }) => (
<FormItem>
<FormLabel>Asset ID</FormLabel>
<FormControl>
<Input autoFocus placeholder="e.g. ASSET-001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
@@ -95,7 +118,7 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -4,6 +4,7 @@ import {
ChevronsLeft,
ChevronsRight,
LayoutDashboard,
Layers,
type LucideIcon,
MapPinned,
Package,
@@ -25,6 +26,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/parts', label: 'Parts', icon: Package },
{ to: '/part-models', label: 'Part models', icon: Layers },
{ to: '/locations', label: 'Locations', icon: MapPinned },
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
{ to: '/repairs', label: 'Repairs', icon: Wrench },
@@ -15,7 +15,6 @@ import {
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -32,11 +31,6 @@ import type { Manufacturer } from '../../lib/api/types.js';
const Schema = z.object({
name: z.string().min(1, 'Required').max(128),
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
});
type Values = z.infer<typeof Schema>;
@@ -46,11 +40,6 @@ interface ManufacturerFormDialogProps {
manufacturer?: Manufacturer | null;
}
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
export function ManufacturerFormDialog({
open,
onOpenChange,
@@ -61,23 +50,17 @@ export function ManufacturerFormDialog({
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', eolDate: '' },
defaultValues: { name: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
name: manufacturer?.name ?? '',
eolDate: isoToDateInput(manufacturer?.eolDate ?? null),
});
form.reset({ name: manufacturer?.name ?? '' });
}, [open, manufacturer, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
name: values.name,
eolDate: values.eolDate ? values.eolDate : null,
};
const payload = { name: values.name };
return editing && manufacturer
? updateManufacturer(manufacturer.id, payload)
: createManufacturer(payload);
@@ -98,8 +81,8 @@ export function ManufacturerFormDialog({
<DialogTitle>{editing ? 'Edit manufacturer' : 'New manufacturer'}</DialogTitle>
<DialogDescription>
{editing
? 'Update this manufacturer. EOL drives replacement alerts on parts.'
: 'Add a manufacturer. Names must be unique.'}
? 'Update the manufacturer record.'
: 'Add a manufacturer. Names must be unique. EOL is tracked per part model.'}
</DialogDescription>
</DialogHeader>
@@ -118,23 +101,6 @@ export function ManufacturerFormDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>End-of-life date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Optional. Parts from this manufacturer will show a replacement alert past this
date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
@@ -0,0 +1,207 @@
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,
Textarea,
} from '@vector/ui';
import { createPartModel, updatePartModel } from '../../lib/api/part-models.js';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { PartModel } from '../../lib/api/types.js';
const Schema = z.object({
manufacturerId: z.string().uuid('Pick a manufacturer'),
mpn: z.string().min(1, 'Required').max(128),
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
notes: z.string().max(4096).optional(),
});
type Values = z.infer<typeof Schema>;
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
interface PartModelFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
partModel?: PartModel | null;
}
export function PartModelFormDialog({
open,
onOpenChange,
partModel,
}: PartModelFormDialogProps) {
const editing = Boolean(partModel);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { manufacturerId: '', mpn: '', eolDate: '', notes: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
manufacturerId: partModel?.manufacturerId ?? '',
mpn: partModel?.mpn ?? '',
eolDate: isoToDateInput(partModel?.eolDate ?? null),
notes: partModel?.notes ?? '',
});
}, [open, partModel, form]);
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
manufacturerId: values.manufacturerId,
mpn: values.mpn,
eolDate: values.eolDate ? values.eolDate : null,
notes: values.notes ? values.notes : null,
};
return editing && partModel
? updatePartModel(partModel.id, payload)
: createPartModel(payload);
},
onSuccess: () => {
toast.success(editing ? 'Part model updated' : 'Part model created');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit part model' : 'New part model'}</DialogTitle>
<DialogDescription>
A part model (MPN) is the catalog entry that carries an end-of-life date.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="manufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Manufacturer</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select manufacturer" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mpn"
render={({ field }) => (
<FormItem>
<FormLabel>MPN</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>EOL date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Deployed parts past this date surface on the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.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={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{editing ? 'Save changes' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -4,10 +4,12 @@ import {
ArrowRight,
CheckCircle2,
MapPin,
MessageSquare,
Package,
Pencil,
Tag,
Wrench,
XCircle,
type LucideIcon,
} from 'lucide-react';
import type { PartEventType } from '@vector/shared';
@@ -22,6 +24,8 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
FIELD_UPDATED: Pencil,
REPAIR_STARTED: Wrench,
REPAIR_COMPLETED: Wrench,
REPAIR_CANCELLED: XCircle,
REPAIR_COMMENTED: MessageSquare,
TAG_ADDED: Tag,
TAG_REMOVED: Tag,
};
@@ -33,6 +37,8 @@ const EVENT_TITLE: Record<PartEventType, string> = {
FIELD_UPDATED: 'Field updated',
REPAIR_STARTED: 'Repair started',
REPAIR_COMPLETED: 'Repair completed',
REPAIR_CANCELLED: 'Repair cancelled',
REPAIR_COMMENTED: 'Repair comment',
TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed',
};
@@ -80,7 +80,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
part
? {
serialNumber: part.serialNumber,
mpn: part.mpn,
mpn: part.partModel.mpn,
manufacturerId: part.manufacturerId,
state: part.state,
binId: part.binId ?? '',
@@ -1,74 +1,53 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
import { listRepairsForPart } from '../../lib/api/repairs.js';
import { Link } from 'react-router-dom';
import { Skeleton } from '@vector/ui';
import { listRepairs } from '../../lib/api/repairs.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { RepairStatusBadge } from '../repairs/RepairStatusBadge.js';
import { RepairFormDialog } from '../repairs/RepairFormDialog.js';
import type { RepairJob } from '../../lib/api/types.js';
interface PartRepairSectionProps {
partId: string;
}
export function PartRepairSection({ partId }: PartRepairSectionProps) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const query = useQuery({
queryKey: [...queryKeys.parts.detail(partId), 'repairs'],
queryFn: () => listRepairsForPart(partId),
queryKey: queryKeys.repairs.list({ problemPartId: partId, pageSize: 50 }),
queryFn: () => listRepairs({ problemPartId: partId, pageSize: 50 }),
});
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Repair history</p>
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5" />
Open repair
</Button>
</div>
<p className="text-sm font-medium">Repairs touching this part</p>
{query.isPending ? (
<Skeleton className="h-16 w-full" />
) : !query.data || query.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repair jobs yet.</p>
) : !query.data || query.data.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repairs reference this part yet.</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{query.data.map((repair) => (
{query.data.data.map((repair) => (
<li
key={repair.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<RepairStatusBadge status={repair.status} />
<span className="text-xs text-muted-foreground">
Opened {new Date(repair.openedAt).toLocaleDateString()}
{repair.host ? ` · ${repair.host.name}` : ''}
<Link
to={`/repairs/${repair.id}`}
className="truncate text-xs text-foreground hover:underline"
>
{repair.problem}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">
· {repair.host.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setEditing(repair)}
>
Edit
</Button>
<span className="text-xs text-muted-foreground">
{new Date(repair.openedAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
<RepairFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultPartId={partId}
/>
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
/>
</div>
);
}
@@ -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>
);
+6 -1
View File
@@ -1,7 +1,7 @@
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Host } from './types.js';
import type { Host, Part } from './types.js';
export type HostListFilters = {
page?: number;
@@ -18,6 +18,11 @@ export async function getHost(id: string): Promise<Host> {
return res.data;
}
export async function listHostDeployedParts(id: string): Promise<Part[]> {
const res = await api.get<Part[]>(`/hosts/${id}/deployed-parts`);
return res.data;
}
export async function createHost(input: CreateHostRequest): Promise<Host> {
const res = await api.post<Host>('/hosts', input);
return res.data;
+41
View File
@@ -0,0 +1,41 @@
import type {
CreatePartModelRequest,
UpdatePartModelRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { PartModel } from './types.js';
export type PartModelListFilters = {
page?: number;
pageSize?: number;
manufacturerId?: string;
q?: string;
eolBefore?: string;
};
export function listPartModels(filters: PartModelListFilters = {}) {
return getList<PartModel>('/part-models', filters);
}
export async function getPartModel(id: string): Promise<PartModel> {
const res = await api.get<PartModel>(`/part-models/${id}`);
return res.data;
}
export async function createPartModel(input: CreatePartModelRequest): Promise<PartModel> {
const res = await api.post<PartModel>('/part-models', input);
return res.data;
}
export async function updatePartModel(
id: string,
input: UpdatePartModelRequest,
): Promise<PartModel> {
const res = await api.patch<PartModel>(`/part-models/${id}`, input);
return res.data;
}
export async function deletePartModel(id: string): Promise<void> {
await api.delete(`/part-models/${id}`);
}
+18 -7
View File
@@ -1,18 +1,19 @@
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { RepairJob } from './types.js';
import type { RepairComment, RepairJob } from './types.js';
export type RepairListFilters = {
page?: number;
pageSize?: number;
status?: RepairStatus;
partId?: string;
hostId?: string;
problemPartId?: string;
assigneeId?: string;
openOnly?: boolean;
};
@@ -26,11 +27,6 @@ export async function getRepair(id: string): Promise<RepairJob> {
return res.data;
}
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> {
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`);
return res.data;
}
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
@@ -47,3 +43,18 @@ export async function updateRepair(
export async function deleteRepair(id: string): Promise<void> {
await api.delete(`/repairs/${id}`);
}
export function listRepairComments(
id: string,
filters: { page?: number; pageSize?: number } = {},
) {
return getList<RepairComment>(`/repairs/${id}/comments`, filters);
}
export async function addRepairComment(
id: string,
input: CreateRepairCommentRequest,
): Promise<RepairComment> {
const res = await api.post<RepairComment>(`/repairs/${id}/comments`, input);
return res.data;
}
+37 -7
View File
@@ -6,11 +6,22 @@ import type { PartEventType, PartState, RepairStatus, Role } from '@vector/share
export interface Manufacturer {
id: string;
name: string;
eolDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface PartModel {
id: string;
manufacturerId: string;
mpn: string;
eolDate: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer?: Manufacturer;
_count?: { parts: number };
}
export interface Site {
id: string;
name: string;
@@ -42,18 +53,20 @@ export interface BinWithPath extends Bin {
export interface Part {
id: string;
serialNumber: string;
mpn: string;
partModelId: string;
manufacturerId: string;
price: number | null;
state: PartState;
binId: string | null;
categoryId: string | null;
replacementPartId: string | null;
hostId: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer: Manufacturer;
partModel: PartModel;
bin: BinWithPath | null;
host: Host | null;
}
export interface PartEvent {
@@ -79,6 +92,7 @@ export interface User {
export interface Host {
id: string;
assetId: string;
name: string;
location: string | null;
notes: string | null;
@@ -101,20 +115,36 @@ export interface Category {
updatedAt: string;
}
export interface RepairJobProblemPart {
repairJobId: string;
partId: string;
createdAt: string;
part: Part;
}
export interface RepairJob {
id: string;
partId: string;
hostId: string | null;
hostId: string;
assigneeId: string | null;
status: RepairStatus;
problem: string;
notes: string | null;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
part: Part;
host: Host | null;
host: Host;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
problemParts: RepairJobProblemPart[];
}
export interface RepairComment {
id: string;
repairJobId: string;
userId: string | null;
content: string;
createdAt: string;
user: Pick<User, 'id' | 'username'> | null;
}
export interface SavedView {
+8
View File
@@ -47,12 +47,20 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
},
repairs: {
all: ['repairs'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
comments: (id: string) => [...queryKeys.repairs.all, 'comments', id] as const,
},
partModels: {
all: ['part-models'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.partModels.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
},
tags: {
all: ['tags'] as const,
+16 -7
View File
@@ -276,28 +276,37 @@ function KpiCard({
function PastEolBanner({
rows,
}: {
rows: { manufacturerId: string; name: string; eolDate: string | null; deployedCount: number }[];
rows: {
partModelId: string;
mpn: string;
manufacturerId: string;
manufacturerName: string;
eolDate: string;
deployedCount: number;
}[];
}) {
return (
<Card className="border-warning/50 bg-warning/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" />
Deployed past manufacturer EOL
Deployed past part-model EOL
</CardTitle>
<CardDescription>
These manufacturers have passed their end-of-life date plan replacements for any parts
still in production.
These MPNs have passed their end-of-life date plan replacements for any parts still in
production.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pb-5">
{rows.map((row) => (
<div
key={row.manufacturerId}
key={row.partModelId}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
<div className="truncate font-medium">
{row.manufacturerName} · <span className="font-mono">{row.mpn}</span>
</div>
{row.eolDate && (
<div className="text-xs text-muted-foreground">
EOL {new Date(row.eolDate).toLocaleDateString()}
@@ -309,7 +318,7 @@ function PastEolBanner({
{row.deployedCount} deployed
</span>
<Button asChild variant="outline" size="sm">
<Link to={`/parts?manufacturerId=${row.manufacturerId}&state=DEPLOYED`}>View</Link>
<Link to={`/parts?partModelId=${row.partModelId}&state=DEPLOYED`}>View</Link>
</Button>
</div>
</div>
+7
View File
@@ -43,6 +43,13 @@ export default function Hosts() {
const columns = useMemo<ColumnDef<Host>[]>(
() => [
{
accessorKey: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.assetId}</span>
),
},
{
accessorKey: 'name',
header: 'Name',
+1 -16
View File
@@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Building, Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
@@ -49,20 +48,6 @@ export default function Manufacturers() {
header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
if (!row.original.eolDate) {
return <span className="text-xs text-muted-foreground"></span>;
}
const d = new Date(row.original.eolDate);
const past = d.getTime() < Date.now();
return (
<Badge variant={past ? 'warning' : 'outline'}>{d.toLocaleDateString()}</Badge>
);
},
},
{
accessorKey: 'createdAt',
header: 'Added',
@@ -109,7 +94,7 @@ export default function Manufacturers() {
<div className="space-y-5">
<PageHeader
title="Manufacturers"
description="Vendors and their end-of-life dates."
description="Vendors. EOL is tracked per part model (see Catalog → Part models)."
actions={
isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
+4 -4
View File
@@ -89,7 +89,7 @@ export default function PartDetail() {
);
}
const eolDate = part.manufacturer.eolDate ? new Date(part.manufacturer.eolDate) : null;
const eolDate = part.partModel.eolDate ? new Date(part.partModel.eolDate) : null;
const pastEol = eolDate ? eolDate.getTime() < Date.now() : false;
return (
@@ -102,7 +102,7 @@ export default function PartDetail() {
<div>
<h1 className="font-mono text-lg font-semibold tracking-tight">{part.serialNumber}</h1>
<p className="text-xs text-muted-foreground">
{part.manufacturer.name} · {part.mpn}
{part.manufacturer.name} · {part.partModel.mpn}
</p>
</div>
</div>
@@ -132,7 +132,7 @@ export default function PartDetail() {
<AlertTriangle className="mt-0.5 h-4 w-4 text-warning" />
<div className="text-sm">
<span className="font-medium text-foreground">
{part.manufacturer.name} reached EOL {eolDate.toLocaleDateString()}.
{part.partModel.mpn} reached EOL {eolDate.toLocaleDateString()}.
</span>{' '}
<span className="text-muted-foreground">
Plan a replacement for this part.
@@ -150,7 +150,7 @@ export default function PartDetail() {
<CardContent>
<dl className="space-y-2">
<DetailRow label="Serial" value={<span className="font-mono text-xs">{part.serialNumber}</span>} />
<DetailRow label="MPN" value={part.mpn} />
<DetailRow label="MPN" value={part.partModel.mpn} />
<DetailRow
label="Manufacturer"
value={
+171
View File
@@ -0,0 +1,171 @@
import { useMemo, useState } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deletePartModel, listPartModels } from '../lib/api/part-models.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { PartModel } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
export default function PartModels() {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<PartModel | null>(null);
const [deleting, setDeleting] = useState<PartModel | null>(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePartModel(id),
onSuccess: () => {
toast.success('Part model deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const columns = useMemo<ColumnDef<PartModel>[]>(
() => [
{
accessorKey: 'manufacturer',
header: 'Manufacturer',
cell: ({ row }) => (
<span className="text-sm">{row.original.manufacturer?.name ?? '—'}</span>
),
},
{
accessorKey: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.mpn}</span>
),
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
const iso = row.original.eolDate;
if (!iso) return <span className="text-sm text-muted-foreground"></span>;
const pastEol = new Date(iso).getTime() <= Date.now();
return (
<div className="flex items-center gap-2">
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
</div>
);
},
},
{
id: 'deployedCount',
header: 'Deployed',
cell: ({ row }) => (
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) =>
isAdmin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null,
},
],
[isAdmin],
);
return (
<div className="space-y-5">
<PageHeader
title="Part models"
description="Catalog of MPNs. End-of-life is tracked per model and drives the past-EOL dashboard alert."
actions={
isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
New part model
</Button>
)
}
/>
<DataTable<PartModel, Record<string, never>>
columns={columns}
getRowId={(m) => m.id}
queryKey={(params) =>
queryKeys.partModels.list({ page: params.page, pageSize: params.pageSize, q: params.q })
}
queryFn={(params) =>
listPartModels({ page: params.page, pageSize: params.pageSize, q: params.q })
}
searchPlaceholder="Search MPN..."
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Layers className="h-6 w-6" />
<span className="text-sm">No part models yet.</span>
</div>
}
/>
<PartModelFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<PartModelFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
partModel={editing}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete part model?"
description={
deleting
? `Remove ${deleting.mpn}. Fails if any parts reference this model.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+4 -2
View File
@@ -94,9 +94,11 @@ export default function Parts() {
),
},
{
accessorKey: 'mpn',
id: 'mpn',
header: 'MPN',
cell: ({ row }) => <span className="text-sm">{row.original.mpn}</span>,
cell: ({ row }) => (
<span className="text-sm font-mono">{row.original.partModel.mpn}</span>
),
},
{
id: 'manufacturer',
+473
View File
@@ -0,0 +1,473 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Check,
Loader2,
Pencil,
Plus,
Server,
Trash2,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import type { RepairStatus } from '@vector/shared';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Skeleton,
Textarea,
} from '@vector/ui';
import { getRepair, updateRepair } from '../lib/api/repairs.js';
import { 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 { RepairStatusBadge, repairStatusOptions } from '../components/repairs/RepairStatusBadge.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { RepairCommentThread } from '../components/repairs/RepairCommentThread.js';
export default function RepairDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: repair, isPending, isError, error } = useQuery({
queryKey: queryKeys.repairs.detail(id!),
queryFn: () => getRepair(id!),
enabled: Boolean(id),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.detail(id!) });
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.list() });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
};
const statusMutation = useMutation({
mutationFn: (status: RepairStatus) => updateRepair(id!, { status }),
onSuccess: () => {
toast.success('Status updated');
invalidate();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Update failed'),
});
if (isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (isError || !repair) {
const msg = error instanceof ApiRequestError ? error.body.message : 'Repair not found.';
return (
<Card>
<CardHeader>
<CardTitle>Repair unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/repairs')}>
<ArrowLeft className="h-4 w-4" />
Back to repairs
</Button>
</CardContent>
</Card>
);
}
const terminal = repair.status === 'COMPLETED' || repair.status === 'CANCELLED';
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/repairs')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Asset
</span>
<span className="font-mono text-2xl font-semibold tracking-tight text-foreground">
{repair.host.assetId}
</span>
<RepairStatusBadge status={repair.status} />
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Server className="h-3 w-3" />
<span>{repair.host.name}</span>
{repair.host.location && <span>· {repair.host.location}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={repair.status}
onValueChange={(v) => statusMutation.mutate(v as RepairStatus)}
disabled={statusMutation.isPending}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr]">
<div className="space-y-4">
<ProblemCard repair={repair} onSaved={invalidate} disabled={terminal} />
<ProblemPartsCard repair={repair} onSaved={invalidate} disabled={terminal} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Comments</CardTitle>
<CardDescription>
Discuss progress, record findings, tag handoffs.
</CardDescription>
</CardHeader>
<CardContent>
<RepairCommentThread repairId={repair.id} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Timeline</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
<Field label="Opened" value={new Date(repair.openedAt).toLocaleString()} />
<Field
label="Closed"
value={
repair.closedAt ? new Date(repair.closedAt).toLocaleString() : '—'
}
/>
<Field
label="Assignee"
value={repair.assignee?.username ?? '—'}
/>
<Field label="Updated" value={new Date(repair.updatedAt).toLocaleString()} />
</dl>
{repair.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="space-y-0.5">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-sm text-foreground">{value}</dd>
</div>
);
}
function ProblemCard({
repair,
onSaved,
disabled,
}: {
repair: { id: string; problem: string };
onSaved: () => void;
disabled: boolean;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(repair.problem);
useEffect(() => {
setValue(repair.problem);
}, [repair.problem]);
const mutation = useMutation({
mutationFn: (problem: string) => updateRepair(repair.id, { problem }),
onSuccess: () => {
toast.success('Problem updated');
setEditing(false);
onSaved();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Update failed'),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Problem</CardTitle>
{!editing && !disabled && (
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)}
</CardHeader>
<CardContent>
{editing ? (
<div className="space-y-2">
<Textarea
rows={4}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={mutation.isPending}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setValue(repair.problem);
setEditing(false);
}}
disabled={mutation.isPending}
>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
onClick={() => mutation.mutate(value.trim())}
disabled={
mutation.isPending ||
value.trim().length === 0 ||
value.trim() === repair.problem
}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.problem}</p>
)}
</CardContent>
</Card>
);
}
function ProblemPartsCard({
repair,
onSaved,
disabled,
}: {
repair: RepairJob;
onSaved: () => void;
disabled: boolean;
}) {
const [picking, setPicking] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const deployedQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(repair.hostId),
queryFn: () => listHostDeployedParts(repair.hostId),
enabled: picking,
});
useEffect(() => {
if (picking) {
setDraft(repair.problemParts.map((pp) => pp.partId));
}
}, [picking, repair.problemParts]);
const mutation = useMutation({
mutationFn: (problemPartIds: string[]) =>
updateRepair(repair.id, { problemPartIds }),
onSuccess: () => {
toast.success('Problem parts updated');
setPicking(false);
onSaved();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Update failed'),
});
const removeMutation = useMutation({
mutationFn: (partId: string) => {
const next = repair.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
return updateRepair(repair.id, { problemPartIds: next });
},
onSuccess: () => {
toast.success('Part removed');
onSaved();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Remove failed'),
});
function toggle(partId: string, checked: boolean) {
setDraft((prev) =>
checked ? [...new Set([...prev, partId])] : prev.filter((id) => id !== partId),
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base">Problem parts</CardTitle>
<CardDescription>
Deployed parts on this host involved in the issue.
</CardDescription>
</div>
{!picking && !disabled && (
<Button variant="outline" size="sm" onClick={() => setPicking(true)}>
<Plus className="h-3.5 w-3.5" />
Manage
</Button>
)}
</CardHeader>
<CardContent>
{picking ? (
<div className="space-y-3">
<div className="max-h-60 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 = draft.includes(part.id);
return (
<li
key={part.id}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Checkbox
id={`rd-pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => toggle(part.id, v === true)}
/>
<label
htmlFor={`rd-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>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPicking(false)}
disabled={mutation.isPending}
>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
onClick={() => mutation.mutate(draft)}
disabled={mutation.isPending}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
) : repair.problemParts.length === 0 ? (
<p className="text-sm text-muted-foreground">
No specific parts tagged the repair is against the host itself.
</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border">
{repair.problemParts.map((pp) => (
<li
key={pp.partId}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="min-w-0 flex-1">
<Link
to={`/parts/${pp.part.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{pp.part.serialNumber}
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pp.part.partModel.mpn}</span>
<PartStateBadge state={pp.part.state} />
</div>
</div>
{!disabled && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => removeMutation.mutate(pp.partId)}
disabled={removeMutation.isPending}
aria-label="Remove part"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
+26 -30
View File
@@ -1,17 +1,16 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs';
import { toast } from 'sonner';
import { Edit, MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import type { RepairStatus } from '@vector/shared';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Select,
SelectContent,
@@ -44,8 +43,8 @@ const ALL = '__all__';
export default function Repairs() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const [deleting, setDeleting] = useState<RepairJob | null>(null);
const deleteMutation = useMutation({
@@ -67,31 +66,34 @@ export default function Repairs() {
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
},
{
id: 'part',
header: 'Part',
id: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<Link
to={`/parts/${row.original.partId}`}
className="font-medium text-foreground hover:underline"
to={`/repairs/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.part.serialNumber}
{row.original.host.assetId}
</Link>
),
},
{
id: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.part.mpn}</span>
),
},
{
id: 'host',
header: 'Host',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.host?.name ?? '—'}
</span>
<span className="text-sm">{row.original.host.name}</span>
),
},
{
id: 'problem',
header: 'Problem',
cell: ({ row }) => (
<Link
to={`/repairs/${row.original.id}`}
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
>
{row.original.problem}
</Link>
),
},
{
@@ -126,11 +128,6 @@ export default function Repairs() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
@@ -150,7 +147,7 @@ export default function Repairs() {
<div className="space-y-5">
<PageHeader
title="Repairs"
description="Open RMAs and host-attached repair jobs."
description="Open work against hosts. Click a row to view and comment."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
@@ -204,11 +201,10 @@ export default function Repairs() {
}
/>
<RepairFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(repair) => navigate(`/repairs/${repair.id}`)}
/>
<ConfirmDialog
open={Boolean(deleting)}
@@ -216,7 +212,7 @@ export default function Repairs() {
title="Delete repair?"
description={
deleting
? `Remove repair for ${deleting.part.serialNumber}. This cannot be undone.`
? `Remove repair "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
: undefined
}
confirmLabel="Delete"