feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle, comments, assignee, notes) that duplicate what the external ticketing tool already owns. Vector only needs to track whether maintenance is open or closed. - Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes - New Repair table: persistent log of physical part swaps, with ingest on unknown broken MPN via partModels.upsertByMpn - New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY states + Part.custodianId, with a "My Custody" page for drop-off - PartModel.destroyOnFail routes broken parts to the destruction path - Host lookup on /fms and /repairs accepts hostId XOR assetId - Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged - Single fresh Prisma migration (dev DB was wiped, no backfill) Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts, repairs.test.ts, custody.test.ts covering happy paths, validation failures, webhook emissions, and ingest-on-unknown-MPN). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,10 @@ 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 Fms from './pages/Fms.js';
|
||||
import FmDetail from './pages/FmDetail.js';
|
||||
import Repairs from './pages/Repairs.js';
|
||||
import RepairDetail from './pages/RepairDetail.js';
|
||||
import MyCustody from './pages/MyCustody.js';
|
||||
import Hosts from './pages/Hosts.js';
|
||||
import Users from './pages/admin/Users.js';
|
||||
import Webhooks from './pages/admin/Webhooks.js';
|
||||
@@ -57,8 +59,10 @@ export default function App() {
|
||||
<Route path="/locations" element={<Locations />} />
|
||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||
<Route path="/part-models" element={<PartModels />} />
|
||||
<Route path="/fms" element={<Fms />} />
|
||||
<Route path="/fms/:id" element={<FmDetail />} />
|
||||
<Route path="/repairs" element={<Repairs />} />
|
||||
<Route path="/repairs/:id" element={<RepairDetail />} />
|
||||
<Route path="/custody" element={<MyCustody />} />
|
||||
<Route path="/hosts" element={<Hosts />} />
|
||||
<Route
|
||||
path="/admin/users"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@vector/ui';
|
||||
import { listBins } from '../../lib/api/bins.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { Part } from '../../lib/api/types.js';
|
||||
|
||||
const UNASSIGNED = '__none__';
|
||||
|
||||
interface DropOffDialogProps {
|
||||
part: Part | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (binId: string | null) => void;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOffDialogProps) {
|
||||
const open = Boolean(part);
|
||||
const [binId, setBinId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setBinId('');
|
||||
}, [open]);
|
||||
|
||||
const bins = useQuery({
|
||||
queryKey: queryKeys.bins.list({ pageSize: 100 }),
|
||||
queryFn: () => listBins({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drop in bin</DialogTitle>
|
||||
<DialogDescription>
|
||||
{destruction
|
||||
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
|
||||
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Select
|
||||
value={binId ? binId : UNASSIGNED}
|
||||
onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{bins.data?.data.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.fullPath ?? b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onConfirm(binId || null)} disabled={pending}>
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Drop off
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+17
-43
@@ -29,35 +29,34 @@ import {
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { createRepair } from '../../lib/api/repairs.js';
|
||||
import { createFm } from '../../lib/api/fms.js';
|
||||
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { RepairJob } from '../../lib/api/types.js';
|
||||
import type { Fm } from '../../lib/api/types.js';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
hostId: z.string().uuid('Pick a host'),
|
||||
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
|
||||
problemPartIds: z.array(z.string().uuid()).max(100),
|
||||
notes: z.string().max(4096).optional(),
|
||||
});
|
||||
type CreateValues = z.infer<typeof CreateSchema>;
|
||||
|
||||
interface RepairFormDialogProps {
|
||||
interface FmFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultHostId?: string;
|
||||
defaultProblemPartIds?: string[];
|
||||
onCreated?: (repair: RepairJob) => void;
|
||||
onCreated?: (fm: Fm) => void;
|
||||
}
|
||||
|
||||
export function RepairFormDialog({
|
||||
export function FmFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultHostId,
|
||||
defaultProblemPartIds,
|
||||
onCreated,
|
||||
}: RepairFormDialogProps) {
|
||||
}: FmFormDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const hostsQuery = useQuery({
|
||||
@@ -68,12 +67,7 @@ export function RepairFormDialog({
|
||||
|
||||
const form = useForm<CreateValues>({
|
||||
resolver: zodResolver(CreateSchema),
|
||||
defaultValues: {
|
||||
hostId: '',
|
||||
problem: '',
|
||||
problemPartIds: [],
|
||||
notes: '',
|
||||
},
|
||||
defaultValues: { hostId: '', problem: '', problemPartIds: [] },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,7 +76,6 @@ export function RepairFormDialog({
|
||||
hostId: defaultHostId ?? '',
|
||||
problem: '',
|
||||
problemPartIds: defaultProblemPartIds ?? [],
|
||||
notes: '',
|
||||
});
|
||||
}, [open, defaultHostId, defaultProblemPartIds, form]);
|
||||
|
||||
@@ -96,19 +89,18 @@ export function RepairFormDialog({
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: CreateValues) =>
|
||||
createRepair({
|
||||
createFm({
|
||||
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 });
|
||||
onSuccess: (fm) => {
|
||||
toast.success('FM opened');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
onOpenChange(false);
|
||||
onCreated?.(repair);
|
||||
onCreated?.(fm);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
||||
@@ -127,9 +119,9 @@ export function RepairFormDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Open repair</DialogTitle>
|
||||
<DialogTitle>Open FM</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a repair against a host. Select the deployed parts involved (optional).
|
||||
Open a Future Maintenance item against a host. Select deployed parts involved (optional).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -147,9 +139,7 @@ export function RepairFormDialog({
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v);
|
||||
form.setValue('problemPartIds', [], {
|
||||
shouldValidate: false,
|
||||
});
|
||||
form.setValue('problemPartIds', [], { shouldValidate: false });
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
@@ -224,9 +214,7 @@ export function RepairFormDialog({
|
||||
htmlFor={`pp-${part.id}`}
|
||||
className="flex-1 cursor-pointer select-none"
|
||||
>
|
||||
<span className="font-mono text-xs">
|
||||
{part.serialNumber}
|
||||
</span>{' '}
|
||||
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{part.partModel.mpn}
|
||||
</span>
|
||||
@@ -243,20 +231,6 @@ export function RepairFormDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
<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"
|
||||
@@ -268,7 +242,7 @@ export function RepairFormDialog({
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Open repair
|
||||
Open FM
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Boxes,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Hand,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
type LucideIcon,
|
||||
@@ -29,7 +31,9 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ 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 },
|
||||
{ to: '/fms', label: 'FMs', icon: Wrench },
|
||||
{ to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
|
||||
{ to: '/custody', label: 'My Custody', icon: Hand },
|
||||
{ to: '/hosts', label: 'Hosts', icon: Server },
|
||||
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
|
||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
@@ -42,6 +43,7 @@ const Schema = z.object({
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
|
||||
.or(z.literal(''))
|
||||
.optional(),
|
||||
destroyOnFail: z.boolean(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
@@ -67,7 +69,13 @@ export function PartModelFormDialog({
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { manufacturerId: '', mpn: '', eolDate: '', notes: '' },
|
||||
defaultValues: {
|
||||
manufacturerId: '',
|
||||
mpn: '',
|
||||
eolDate: '',
|
||||
destroyOnFail: false,
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,6 +84,7 @@ export function PartModelFormDialog({
|
||||
manufacturerId: partModel?.manufacturerId ?? '',
|
||||
mpn: partModel?.mpn ?? '',
|
||||
eolDate: isoToDateInput(partModel?.eolDate ?? null),
|
||||
destroyOnFail: partModel?.destroyOnFail ?? false,
|
||||
notes: partModel?.notes ?? '',
|
||||
});
|
||||
}, [open, partModel, form]);
|
||||
@@ -92,6 +101,7 @@ export function PartModelFormDialog({
|
||||
manufacturerId: values.manufacturerId,
|
||||
mpn: values.mpn,
|
||||
eolDate: values.eolDate ? values.eolDate : null,
|
||||
destroyOnFail: values.destroyOnFail,
|
||||
notes: values.notes ? values.notes : null,
|
||||
};
|
||||
return editing && partModel
|
||||
@@ -172,6 +182,29 @@ export function PartModelFormDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destroyOnFail"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-start gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="destroyOnFail"
|
||||
checked={field.value}
|
||||
onCheckedChange={(v) => field.onChange(v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel htmlFor="destroyOnFail">Destroy on fail</FormLabel>
|
||||
<FormDescription>
|
||||
When this model fails, its broken part goes to the destruction path instead
|
||||
of being held for return/repair.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
|
||||
@@ -2,14 +2,13 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowRightLeft,
|
||||
CheckCircle2,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Pencil,
|
||||
Tag,
|
||||
Wrench,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { PartEventType } from '@vector/shared';
|
||||
@@ -22,10 +21,9 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
|
||||
STATE_CHANGED: CheckCircle2,
|
||||
LOCATION_CHANGED: MapPin,
|
||||
FIELD_UPDATED: Pencil,
|
||||
REPAIR_STARTED: Wrench,
|
||||
REPAIR_COMPLETED: Wrench,
|
||||
REPAIR_CANCELLED: XCircle,
|
||||
REPAIR_COMMENTED: MessageSquare,
|
||||
FM_OPENED: Wrench,
|
||||
FM_CLOSED: Wrench,
|
||||
PART_SWAPPED: ArrowRightLeft,
|
||||
TAG_ADDED: Tag,
|
||||
TAG_REMOVED: Tag,
|
||||
};
|
||||
@@ -35,10 +33,9 @@ const EVENT_TITLE: Record<PartEventType, string> = {
|
||||
STATE_CHANGED: 'State changed',
|
||||
LOCATION_CHANGED: 'Location changed',
|
||||
FIELD_UPDATED: 'Field updated',
|
||||
REPAIR_STARTED: 'Repair started',
|
||||
REPAIR_COMPLETED: 'Repair completed',
|
||||
REPAIR_CANCELLED: 'Repair cancelled',
|
||||
REPAIR_COMMENTED: 'Repair comment',
|
||||
FM_OPENED: 'FM opened',
|
||||
FM_CLOSED: 'FM closed',
|
||||
PART_SWAPPED: 'Part swapped',
|
||||
TAG_ADDED: 'Tag added',
|
||||
TAG_REMOVED: 'Tag removed',
|
||||
};
|
||||
|
||||
@@ -294,7 +294,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
/>
|
||||
</div>
|
||||
|
||||
{watchedState === 'DEPLOYED' ? (
|
||||
{watchedState === 'PENDING_DROP_IN_CUSTODY' ||
|
||||
watchedState === 'PENDING_DESTRUCTION_IN_CUSTODY' ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<div className="inline-flex items-center rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs">
|
||||
In custody: {part?.custodian?.username ?? '—'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drop-off happens through the My Custody page.
|
||||
</p>
|
||||
</div>
|
||||
) : watchedState === 'DEPLOYED' ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostId"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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';
|
||||
|
||||
interface PartRepairSectionProps {
|
||||
partId: string;
|
||||
}
|
||||
|
||||
export function PartRepairSection({ partId }: PartRepairSectionProps) {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.repairs.list({ problemPartId: partId, pageSize: 50 }),
|
||||
queryFn: () => listRepairs({ problemPartId: partId, pageSize: 50 }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Repairs touching this part</p>
|
||||
{query.isPending ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : !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.data.map((repair) => (
|
||||
<li
|
||||
key={repair.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RepairStatusBadge status={repair.status} />
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(repair.openedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ const STATE_LABEL: Record<PartState, string> = {
|
||||
DEPLOYED: 'Deployed',
|
||||
BROKEN: 'Broken',
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||
};
|
||||
|
||||
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
@@ -13,12 +15,16 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
DEPLOYED: 'success',
|
||||
BROKEN: 'warning',
|
||||
PENDING_DESTRUCTION: 'destructive',
|
||||
PENDING_DROP_IN_CUSTODY: 'outline',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
|
||||
};
|
||||
|
||||
export function PartStateBadge({ state }: { state: PartState }) {
|
||||
return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
|
||||
}
|
||||
|
||||
// Options users can set via the Part form. Custody states are intentionally excluded —
|
||||
// they're only reached via the Repair flow, then unwound via the Custody drop-off page.
|
||||
export const partStateOptions: { value: PartState; label: string }[] = [
|
||||
{ value: 'SPARE', label: 'Spare' },
|
||||
{ value: 'DEPLOYED', label: 'Deployed' },
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
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,
|
||||
} from '@vector/ui';
|
||||
import { logRepair } from '../../lib/api/repairs.js';
|
||||
import { listHosts } from '../../lib/api/hosts.js';
|
||||
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||
import { listFms } from '../../lib/api/fms.js';
|
||||
import { listParts } from '../../lib/api/parts.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { Repair } from '../../lib/api/types.js';
|
||||
|
||||
const Schema = z.object({
|
||||
hostId: z.string().uuid('Pick a host'),
|
||||
brokenSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenMpn: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenManufacturerId: z.string().uuid('Select a manufacturer'),
|
||||
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
fmId: z.string().optional(),
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const NO_FM = '__none__';
|
||||
|
||||
interface LogRepairDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLogged?: (repair: Repair) => void;
|
||||
}
|
||||
|
||||
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset({
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
});
|
||||
}, [open, form]);
|
||||
|
||||
const hostId = form.watch('hostId');
|
||||
const brokenSerial = form.watch('brokenSerial').trim();
|
||||
|
||||
const hosts = useQuery({
|
||||
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
|
||||
queryFn: () => listHosts({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const manufacturers = useQuery({
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Open FMs on the chosen host, so the optional linker only shows relevant items.
|
||||
const openFms = useQuery({
|
||||
queryKey: queryKeys.fms.list({ hostId, status: 'OPEN', pageSize: 50 }),
|
||||
queryFn: () => listFms({ hostId, status: 'OPEN', pageSize: 50 }),
|
||||
enabled: open && Boolean(hostId),
|
||||
});
|
||||
|
||||
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
|
||||
// already knows that part (existing) or will auto-ingest it (new).
|
||||
const brokenLookup = useQuery({
|
||||
queryKey: queryKeys.parts.list({ serialNumber: brokenSerial, pageSize: 1 }),
|
||||
queryFn: () => listParts({ serialNumber: brokenSerial, pageSize: 1 }),
|
||||
enabled: open && brokenSerial.length >= 3,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
const existingBroken = brokenLookup.data?.data.find(
|
||||
(p) => p.serialNumber === brokenSerial,
|
||||
);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (v: Values) =>
|
||||
logRepair({
|
||||
hostId: v.hostId,
|
||||
brokenSerial: v.brokenSerial.trim(),
|
||||
brokenMpn: v.brokenMpn.trim(),
|
||||
brokenManufacturerId: v.brokenManufacturerId,
|
||||
replacementSerial: v.replacementSerial.trim(),
|
||||
fmId: v.fmId ? v.fmId : undefined,
|
||||
}),
|
||||
onSuccess: (repair) => {
|
||||
toast.success('Repair logged');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
|
||||
onOpenChange(false);
|
||||
onLogged?.(repair);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not log repair'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log a repair</DialogTitle>
|
||||
<DialogDescription>
|
||||
Record a physical part swap. The broken part goes into your custody until you drop it
|
||||
in a bin from the My Custody page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
|
||||
className="space-y-3"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select host" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{hosts.data?.data.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.assetId} — {h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenSerial"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus placeholder="SN-…" {...field} />
|
||||
</FormControl>
|
||||
{brokenSerial.length >= 3 && (
|
||||
<FormDescription>
|
||||
{brokenLookup.isFetching
|
||||
? 'Looking up…'
|
||||
: existingBroken
|
||||
? `Found: ${existingBroken.partModel.mpn}`
|
||||
: 'Will be ingested as a new part.'}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="replacementSerial"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Replacement serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="SN-…" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Must be an existing SPARE.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenMpn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken MPN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenManufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken manufacturer</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fmId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Link to open FM (optional)</FormLabel>
|
||||
<Select
|
||||
value={field.value ? field.value : NO_FM}
|
||||
onValueChange={(v) => field.onChange(v === NO_FM ? '' : v)}
|
||||
disabled={!hostId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={hostId ? 'No linked FM' : 'Pick a host first'} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_FM}>No linked FM</SelectItem>
|
||||
{openFms.data?.data.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.problem.slice(0, 80)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Linking doesn't auto-close the FM — n8n handles that.
|
||||
</FormDescription>
|
||||
<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" />}
|
||||
Log repair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { RepairStatus } from '@vector/shared';
|
||||
import { Badge } from '@vector/ui';
|
||||
|
||||
const LABELS: Record<RepairStatus, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In progress',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
const VARIANTS: Record<RepairStatus, 'outline' | 'warning' | 'success' | 'secondary'> = {
|
||||
PENDING: 'outline',
|
||||
IN_PROGRESS: 'warning',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'secondary',
|
||||
};
|
||||
|
||||
export const repairStatusOptions: { value: RepairStatus; label: string }[] = (
|
||||
Object.keys(LABELS) as RepairStatus[]
|
||||
).map((value) => ({ value, label: LABELS[value] }));
|
||||
|
||||
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
|
||||
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { DropOffRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Part } from './types.js';
|
||||
|
||||
export function listMyCustody(filters: { page?: number; pageSize?: number } = {}) {
|
||||
return getList<Part>('/custody/mine', filters);
|
||||
}
|
||||
|
||||
export async function dropOff(partId: string, input: DropOffRequest): Promise<Part> {
|
||||
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CreateFmRequest, FmStatus, UpdateFmRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Fm } from './types.js';
|
||||
|
||||
export type FmListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: FmStatus;
|
||||
hostId?: string;
|
||||
problemPartId?: string;
|
||||
openOnly?: boolean;
|
||||
};
|
||||
|
||||
export function listFms(filters: FmListFilters = {}) {
|
||||
return getList<Fm>('/fms', filters);
|
||||
}
|
||||
|
||||
export async function getFm(id: string): Promise<Fm> {
|
||||
const res = await api.get<Fm>(`/fms/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createFm(input: CreateFmRequest): Promise<Fm> {
|
||||
const res = await api.post<Fm>('/fms', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateFm(id: string, input: UpdateFmRequest): Promise<Fm> {
|
||||
const res = await api.patch<Fm>(`/fms/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteFm(id: string): Promise<void> {
|
||||
await api.delete(`/fms/${id}`);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type PartListFilters = {
|
||||
binId?: string;
|
||||
tagId?: string;
|
||||
eolOnly?: boolean;
|
||||
serialNumber?: string;
|
||||
custodianId?: string;
|
||||
};
|
||||
|
||||
export function listParts(filters: PartListFilters) {
|
||||
|
||||
@@ -1,60 +1,27 @@
|
||||
import type {
|
||||
CreateRepairCommentRequest,
|
||||
CreateRepairJobRequest,
|
||||
RepairStatus,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import type { LogRepairRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { RepairComment, RepairJob } from './types.js';
|
||||
import type { Repair } from './types.js';
|
||||
|
||||
export type RepairListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: RepairStatus;
|
||||
hostId?: string;
|
||||
problemPartId?: string;
|
||||
assigneeId?: string;
|
||||
openOnly?: boolean;
|
||||
performedById?: string;
|
||||
fmId?: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export function listRepairs(filters: RepairListFilters = {}) {
|
||||
return getList<RepairJob>('/repairs', filters);
|
||||
return getList<Repair>('/repairs', filters);
|
||||
}
|
||||
|
||||
export async function getRepair(id: string): Promise<RepairJob> {
|
||||
const res = await api.get<RepairJob>(`/repairs/${id}`);
|
||||
export async function getRepair(id: string): Promise<Repair> {
|
||||
const res = await api.get<Repair>(`/repairs/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
|
||||
const res = await api.post<RepairJob>('/repairs', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateRepair(
|
||||
id: string,
|
||||
input: UpdateRepairJobRequest,
|
||||
): Promise<RepairJob> {
|
||||
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
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);
|
||||
export async function logRepair(input: LogRepairRequest): Promise<Repair> {
|
||||
const res = await api.post<Repair>('/repairs', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
|
||||
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
|
||||
|
||||
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
|
||||
// Keep these in sync with apps/api/src/services responses.
|
||||
@@ -15,6 +15,7 @@ export interface PartModel {
|
||||
manufacturerId: string;
|
||||
mpn: string;
|
||||
eolDate: string | null;
|
||||
destroyOnFail: boolean;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -60,6 +61,7 @@ export interface Part {
|
||||
binId: string | null;
|
||||
categoryId: string | null;
|
||||
hostId: string | null;
|
||||
custodianId: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -67,6 +69,7 @@ export interface Part {
|
||||
partModel: PartModel;
|
||||
bin: BinWithPath | null;
|
||||
host: Host | null;
|
||||
custodian: Pick<User, 'id' | 'username'> | null;
|
||||
}
|
||||
|
||||
export interface PartEvent {
|
||||
@@ -115,42 +118,47 @@ export interface Category {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RepairJobProblemPart {
|
||||
repairJobId: string;
|
||||
export interface FmProblemPart {
|
||||
fmId: string;
|
||||
partId: string;
|
||||
createdAt: string;
|
||||
part: Part;
|
||||
}
|
||||
|
||||
export interface RepairJob {
|
||||
export interface Fm {
|
||||
id: string;
|
||||
hostId: string;
|
||||
assigneeId: string | null;
|
||||
status: RepairStatus;
|
||||
status: FmStatus;
|
||||
problem: string;
|
||||
notes: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
host: Host;
|
||||
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
|
||||
problemParts: RepairJobProblemPart[];
|
||||
problemParts: FmProblemPart[];
|
||||
}
|
||||
|
||||
export interface RepairComment {
|
||||
export interface Repair {
|
||||
id: string;
|
||||
repairJobId: string;
|
||||
userId: string | null;
|
||||
content: string;
|
||||
hostId: string;
|
||||
brokenPartId: string;
|
||||
replacementPartId: string;
|
||||
performedById: string;
|
||||
performedAt: string;
|
||||
fmId: string | null;
|
||||
createdAt: string;
|
||||
user: Pick<User, 'id' | 'username'> | null;
|
||||
updatedAt: string;
|
||||
host: Host;
|
||||
brokenPart: Part;
|
||||
replacement: Part;
|
||||
performedBy: Pick<User, 'id' | 'username'>;
|
||||
fm: { id: string; status: FmStatus } | null;
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
userId: string;
|
||||
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
|
||||
resource: 'parts' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
|
||||
name: string;
|
||||
filterJson: unknown;
|
||||
createdAt: string;
|
||||
|
||||
@@ -49,12 +49,22 @@ export const queryKeys = {
|
||||
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
||||
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
|
||||
},
|
||||
fms: {
|
||||
all: ['fms'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.fms.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.fms.all, 'detail', 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,
|
||||
},
|
||||
custody: {
|
||||
all: ['custody'] as const,
|
||||
mine: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.custody.all, 'mine', filters ?? {}] as const,
|
||||
},
|
||||
partModels: {
|
||||
all: ['part-models'] as const,
|
||||
|
||||
@@ -33,6 +33,8 @@ const STATE_LABELS: Record<PartState, string> = {
|
||||
DEPLOYED: 'Deployed',
|
||||
BROKEN: 'Broken',
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<PartState, string> = {
|
||||
@@ -40,6 +42,8 @@ const STATE_COLORS: Record<PartState, string> = {
|
||||
DEPLOYED: 'hsl(142 71% 45%)',
|
||||
BROKEN: 'hsl(0 84% 60%)',
|
||||
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
||||
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
|
||||
};
|
||||
|
||||
function currency(cents: number): string {
|
||||
@@ -90,9 +94,9 @@ export default function Dashboard() {
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
label="Open repairs"
|
||||
value={data.openRepairs.toLocaleString()}
|
||||
href="/repairs"
|
||||
label="Open FMs"
|
||||
value={data.openFms.toLocaleString()}
|
||||
href="/fms"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Deployed value"
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
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 { ArrowLeft, Check, Loader2, Pencil, Plus, Server, Trash2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { RepairStatus } from '@vector/shared';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -21,45 +12,38 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { getRepair, updateRepair } from '../lib/api/repairs.js';
|
||||
import { getFm, updateFm } from '../lib/api/fms.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 type { Fm } from '../lib/api/types.js';
|
||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||
import { RepairCommentThread } from '../components/repairs/RepairCommentThread.js';
|
||||
|
||||
export default function RepairDetail() {
|
||||
export default function FmDetail() {
|
||||
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!),
|
||||
const { data: fm, isPending, isError, error } = useQuery({
|
||||
queryKey: queryKeys.fms.detail(id!),
|
||||
queryFn: () => getFm(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.detail(id!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.list() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.detail(id!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.list() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
};
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: RepairStatus) => updateRepair(id!, { status }),
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: () => updateFm(id!, { status: fm?.status === 'OPEN' ? 'CLOSED' : 'OPEN' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Status updated');
|
||||
toast.success(fm?.status === 'OPEN' ? 'FM closed' : 'FM reopened');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) =>
|
||||
@@ -76,25 +60,25 @@ export default function RepairDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !repair) {
|
||||
const msg = error instanceof ApiRequestError ? error.body.message : 'Repair not found.';
|
||||
if (isError || !fm) {
|
||||
const msg = error instanceof ApiRequestError ? error.body.message : 'FM not found.';
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repair unavailable</CardTitle>
|
||||
<CardTitle>FM unavailable</CardTitle>
|
||||
<CardDescription>{msg}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => navigate('/repairs')}>
|
||||
<Button variant="outline" onClick={() => navigate('/fms')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to repairs
|
||||
Back to FMs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const terminal = repair.status === 'COMPLETED' || repair.status === 'CANCELLED';
|
||||
const closed = fm.status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -103,98 +87,77 @@ export default function RepairDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/repairs')}
|
||||
onClick={() => navigate('/fms')}
|
||||
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="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}
|
||||
{fm.host.assetId}
|
||||
</span>
|
||||
<RepairStatusBadge status={repair.status} />
|
||||
<Badge variant={closed ? 'secondary' : 'warning'}>
|
||||
{closed ? 'Closed' : 'Open'}
|
||||
</Badge>
|
||||
</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>}
|
||||
<span>{fm.host.name}</span>
|
||||
{fm.host.location && <span>· {fm.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}
|
||||
<Button
|
||||
variant={closed ? 'outline' : 'default'}
|
||||
onClick={() => toggleMutation.mutate()}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repairStatusOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{toggleMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : closed ? (
|
||||
<Pencil className="h-4 w-4" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
{closed ? 'Reopen' : 'Close FM'}
|
||||
</Button>
|
||||
</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} />
|
||||
<ProblemCard fm={fm} onSaved={invalidate} disabled={closed} />
|
||||
<ProblemPartsCard fm={fm} onSaved={invalidate} disabled={closed} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Comments</CardTitle>
|
||||
<CardTitle className="text-base">Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Discuss progress, record findings, tag handoffs.
|
||||
The actual repair work lives in the external ticketing system.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RepairCommentThread repairId={repair.id} />
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Field label="Opened" value={new Date(fm.openedAt).toLocaleString()} />
|
||||
<Field
|
||||
label="Closed"
|
||||
value={fm.closedAt ? new Date(fm.closedAt).toLocaleString() : '—'}
|
||||
/>
|
||||
<Field label="Updated" value={new Date(fm.updatedAt).toLocaleString()} />
|
||||
</dl>
|
||||
<Separator className="my-3" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Opened and closed events fire <code className="font-mono">fm.opened</code> and{' '}
|
||||
<code className="font-mono">fm.closed</code> webhooks.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -209,23 +172,23 @@ function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function ProblemCard({
|
||||
repair,
|
||||
fm,
|
||||
onSaved,
|
||||
disabled,
|
||||
}: {
|
||||
repair: { id: string; problem: string };
|
||||
fm: { id: string; problem: string };
|
||||
onSaved: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(repair.problem);
|
||||
const [value, setValue] = useState(fm.problem);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(repair.problem);
|
||||
}, [repair.problem]);
|
||||
setValue(fm.problem);
|
||||
}, [fm.problem]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (problem: string) => updateRepair(repair.id, { problem }),
|
||||
mutationFn: (problem: string) => updateFm(fm.id, { problem }),
|
||||
onSuccess: () => {
|
||||
toast.success('Problem updated');
|
||||
setEditing(false);
|
||||
@@ -260,7 +223,7 @@ function ProblemCard({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setValue(repair.problem);
|
||||
setValue(fm.problem);
|
||||
setEditing(false);
|
||||
}}
|
||||
disabled={mutation.isPending}
|
||||
@@ -274,7 +237,7 @@ function ProblemCard({
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
value.trim().length === 0 ||
|
||||
value.trim() === repair.problem
|
||||
value.trim() === fm.problem
|
||||
}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
@@ -287,7 +250,7 @@ function ProblemCard({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.problem}</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-foreground">{fm.problem}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -295,11 +258,11 @@ function ProblemCard({
|
||||
}
|
||||
|
||||
function ProblemPartsCard({
|
||||
repair,
|
||||
fm,
|
||||
onSaved,
|
||||
disabled,
|
||||
}: {
|
||||
repair: RepairJob;
|
||||
fm: Fm;
|
||||
onSaved: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
@@ -307,20 +270,19 @@ function ProblemPartsCard({
|
||||
const [draft, setDraft] = useState<string[]>([]);
|
||||
|
||||
const deployedQuery = useQuery({
|
||||
queryKey: queryKeys.hosts.deployedParts(repair.hostId),
|
||||
queryFn: () => listHostDeployedParts(repair.hostId),
|
||||
queryKey: queryKeys.hosts.deployedParts(fm.hostId),
|
||||
queryFn: () => listHostDeployedParts(fm.hostId),
|
||||
enabled: picking,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (picking) {
|
||||
setDraft(repair.problemParts.map((pp) => pp.partId));
|
||||
setDraft(fm.problemParts.map((pp) => pp.partId));
|
||||
}
|
||||
}, [picking, repair.problemParts]);
|
||||
}, [picking, fm.problemParts]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (problemPartIds: string[]) =>
|
||||
updateRepair(repair.id, { problemPartIds }),
|
||||
mutationFn: (problemPartIds: string[]) => updateFm(fm.id, { problemPartIds }),
|
||||
onSuccess: () => {
|
||||
toast.success('Problem parts updated');
|
||||
setPicking(false);
|
||||
@@ -332,8 +294,8 @@ function ProblemPartsCard({
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (partId: string) => {
|
||||
const next = repair.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
|
||||
return updateRepair(repair.id, { problemPartIds: next });
|
||||
const next = fm.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
|
||||
return updateFm(fm.id, { problemPartIds: next });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Part removed');
|
||||
@@ -354,9 +316,7 @@ function ProblemPartsCard({
|
||||
<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>
|
||||
<CardDescription>Deployed parts on this host involved in the issue.</CardDescription>
|
||||
</div>
|
||||
{!picking && !disabled && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPicking(true)}>
|
||||
@@ -380,17 +340,14 @@ function ProblemPartsCard({
|
||||
{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"
|
||||
>
|
||||
<li key={part.id} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||
<Checkbox
|
||||
id={`rd-pp-${part.id}`}
|
||||
id={`fd-pp-${part.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggle(part.id, v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`rd-pp-${part.id}`}
|
||||
htmlFor={`fd-pp-${part.id}`}
|
||||
className="flex-1 cursor-pointer select-none"
|
||||
>
|
||||
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
|
||||
@@ -428,13 +385,13 @@ function ProblemPartsCard({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : repair.problemParts.length === 0 ? (
|
||||
) : fm.problemParts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No specific parts tagged — the repair is against the host itself.
|
||||
No specific parts tagged — the FM is against the host itself.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{repair.problemParts.map((pp) => (
|
||||
{fm.problemParts.map((pp) => (
|
||||
<li
|
||||
key={pp.partId}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
|
||||
import type { FmStatus } from '@vector/shared';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { deleteFm, listFms } from '../lib/api/fms.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Fm } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
|
||||
type FmFilters = {
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
status: parseAsString,
|
||||
};
|
||||
|
||||
const ALL = '__all__';
|
||||
const STATUS_OPTIONS: { value: FmStatus; label: string }[] = [
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'CLOSED', label: 'Closed' },
|
||||
];
|
||||
|
||||
function FmStatusBadge({ status }: { status: FmStatus }) {
|
||||
return (
|
||||
<Badge variant={status === 'OPEN' ? 'warning' : 'secondary'}>
|
||||
{status === 'OPEN' ? 'Open' : 'Closed'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Fms() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Fm | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteFm(id),
|
||||
onSuccess: () => {
|
||||
toast.success('FM removed');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Fm>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <FmStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'assetId',
|
||||
header: 'Asset ID',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/fms/${row.original.id}`}
|
||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.host.assetId}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'host',
|
||||
header: 'Host',
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.host.name}</span>,
|
||||
},
|
||||
{
|
||||
id: 'problem',
|
||||
header: 'Problem',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/fms/${row.original.id}`}
|
||||
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
|
||||
>
|
||||
{row.original.problem}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'openedAt',
|
||||
header: 'Opened',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.openedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'closedAt',
|
||||
header: 'Closed',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.closedAt
|
||||
? new Date(row.original.closedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<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={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="FMs"
|
||||
description="Future Maintenance items open against hosts. n8n handles the ticketing; Vector just tracks open/closed."
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Open FM
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Fm, FmFilters>
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterParsers={filterParsers}
|
||||
queryKey={(params) =>
|
||||
queryKeys.fms.list({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: params.filters.status,
|
||||
})
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listFms({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: (params.filters.status ?? undefined) as FmStatus | undefined,
|
||||
})
|
||||
}
|
||||
enableSearch={false}
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<Select
|
||||
value={filters.status ?? ALL}
|
||||
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Any status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>Any status</SelectItem>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Wrench className="h-6 w-6" />
|
||||
<span className="text-sm">No FMs yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<FmFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={(fm) => navigate(`/fms/${fm.id}`)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete FM?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove FM "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Hand, PackageCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { DropOffDialog } from '../components/custody/DropOffDialog.js';
|
||||
import { dropOff, listMyCustody } from '../lib/api/custody.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||
import type { Part } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
|
||||
export default function MyCustody() {
|
||||
const queryClient = useQueryClient();
|
||||
const [dropping, setDropping] = useState<Part | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ partId, binId }: { partId: string; binId: string | null }) =>
|
||||
dropOff(partId, { binId }),
|
||||
onSuccess: () => {
|
||||
toast.success('Dropped off');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
setDropping(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Drop-off failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Part>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'serial',
|
||||
header: 'Serial',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/parts/${row.original.id}`}
|
||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mpn',
|
||||
header: 'MPN',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs">{row.original.partModel.mpn}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.manufacturer.name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'state',
|
||||
header: 'State',
|
||||
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
|
||||
},
|
||||
{
|
||||
id: 'since',
|
||||
header: 'Since',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="outline" onClick={() => setDropping(row.original)}>
|
||||
<PackageCheck className="h-3.5 w-3.5" />
|
||||
Drop in bin
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="My Custody"
|
||||
description="Broken parts you're holding until you drop them in a bin."
|
||||
/>
|
||||
|
||||
<DataTable<Part, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(p) => p.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.custody.mine({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listMyCustody({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
enableSearch={false}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Hand className="h-6 w-6" />
|
||||
<span className="text-sm">Nothing in your custody.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropOffDialog
|
||||
part={dropping}
|
||||
onOpenChange={(o) => !o && setDropping(null)}
|
||||
onConfirm={(binId) =>
|
||||
dropping && mutation.mutate({ partId: dropping.id, binId })
|
||||
}
|
||||
pending={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { useAuth } from '../contexts/AuthContext.js';
|
||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||
import { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
|
||||
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
||||
import { PartRepairSection } from '../components/parts/PartRepairSection.js';
|
||||
import { TagPicker } from '../components/tags/TagPicker.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
|
||||
@@ -170,6 +169,8 @@ export default function PartDetail() {
|
||||
<span className="font-mono text-xs">
|
||||
{part.host.assetId} / {part.host.name}
|
||||
</span>
|
||||
) : part.custodian ? (
|
||||
<span className="text-xs">Custody: {part.custodian.username}</span>
|
||||
) : part.bin?.fullPath ? (
|
||||
<span className="font-mono text-xs">{part.bin.fullPath}</span>
|
||||
) : (
|
||||
@@ -223,8 +224,6 @@ export default function PartDetail() {
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p>
|
||||
<TagPicker partId={part.id} />
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<PartRepairSection partId={part.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Check, Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
@@ -80,6 +80,16 @@ export default function PartModels() {
|
||||
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'destroyOnFail',
|
||||
header: 'Destroy on fail',
|
||||
cell: ({ row }) =>
|
||||
row.original.destroyOnFail ? (
|
||||
<Check className="h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function Parts() {
|
||||
id: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => {
|
||||
const host = row.original.host;
|
||||
const { host, custodian, bin } = row.original;
|
||||
if (host) {
|
||||
return (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
@@ -124,9 +124,15 @@ export default function Parts() {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const path = row.original.bin?.fullPath;
|
||||
return path ? (
|
||||
<span className="text-xs font-mono text-muted-foreground">{path}</span>
|
||||
if (custodian) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Custody: {custodian.username}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return bin?.fullPath ? (
|
||||
<span className="text-xs font-mono text-muted-foreground">{bin.fullPath}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">Unassigned</span>
|
||||
);
|
||||
|
||||
+55
-159
@@ -1,143 +1,84 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link } 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 { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
|
||||
import type { RepairStatus } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@vector/ui';
|
||||
import { ArrowRightLeft, Plus } from 'lucide-react';
|
||||
import { Button } from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { RepairFormDialog } from '../components/repairs/RepairFormDialog.js';
|
||||
import {
|
||||
RepairStatusBadge,
|
||||
repairStatusOptions,
|
||||
} from '../components/repairs/RepairStatusBadge.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { deleteRepair, listRepairs } from '../lib/api/repairs.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { RepairJob } from '../lib/api/types.js';
|
||||
import { LogRepairDialog } from '../components/repairs/LogRepairDialog.js';
|
||||
import { listRepairs } from '../lib/api/repairs.js';
|
||||
import type { Repair } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
|
||||
type RepairFilters = {
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
status: parseAsString,
|
||||
};
|
||||
|
||||
const ALL = '__all__';
|
||||
|
||||
export default function Repairs() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<RepairJob | null>(null);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRepair(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Repair removed');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<RepairJob>[]>(
|
||||
const columns = useMemo<ColumnDef<Repair>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'assetId',
|
||||
header: 'Asset ID',
|
||||
id: 'performedAt',
|
||||
header: 'When',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/repairs/${row.original.id}`}
|
||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.host.assetId}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.performedAt).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'host',
|
||||
header: 'Host',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{row.original.host.name}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs">{row.original.host.assetId}</span>
|
||||
<span className="text-xs text-muted-foreground">{row.original.host.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'problem',
|
||||
header: 'Problem',
|
||||
id: 'broken',
|
||||
header: 'Broken',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/repairs/${row.original.id}`}
|
||||
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
|
||||
to={`/parts/${row.original.brokenPart.id}`}
|
||||
className="font-mono text-xs hover:underline"
|
||||
>
|
||||
{row.original.problem}
|
||||
{row.original.brokenPart.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'openedAt',
|
||||
header: 'Opened',
|
||||
id: 'replacement',
|
||||
header: 'Replacement',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.openedAt).toLocaleDateString()}
|
||||
</span>
|
||||
<Link
|
||||
to={`/parts/${row.original.replacement.id}`}
|
||||
className="font-mono text-xs hover:underline"
|
||||
>
|
||||
{row.original.replacement.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'closedAt',
|
||||
header: 'Closed',
|
||||
id: 'performedBy',
|
||||
header: 'By',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.closedAt
|
||||
? new Date(row.original.closedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</span>
|
||||
<span className="text-xs">{row.original.performedBy.username}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<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={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
id: 'fm',
|
||||
header: 'FM',
|
||||
cell: ({ row }) =>
|
||||
row.original.fmId ? (
|
||||
<Link
|
||||
to={`/fms/${row.original.fmId}`}
|
||||
className="text-xs text-foreground hover:underline"
|
||||
>
|
||||
View FM
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
@@ -147,79 +88,34 @@ export default function Repairs() {
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Repairs"
|
||||
description="Open work against hosts. Click a row to view and comment."
|
||||
description="Physical part swaps. Logging a repair moves the broken part into your custody."
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Button onClick={() => setLogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Open repair
|
||||
Log repair
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<RepairJob, RepairFilters>
|
||||
<DataTable<Repair, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterParsers={filterParsers}
|
||||
queryKey={(params) =>
|
||||
queryKeys.repairs.list({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: params.filters.status,
|
||||
})
|
||||
queryKeys.repairs.list({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listRepairs({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: (params.filters.status ?? undefined) as RepairStatus | undefined,
|
||||
})
|
||||
listRepairs({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
enableSearch={false}
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<Select
|
||||
value={filters.status ?? ALL}
|
||||
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Any status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>Any status</SelectItem>
|
||||
{repairStatusOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Wrench className="h-6 w-6" />
|
||||
<span className="text-sm">No repair jobs yet.</span>
|
||||
<ArrowRightLeft className="h-6 w-6" />
|
||||
<span className="text-sm">No repairs logged yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<RepairFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={(repair) => navigate(`/repairs/${repair.id}`)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete repair?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove repair "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
<LogRepairDialog open={logOpen} onOpenChange={setLogOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user