feat: remove FM feature from Vector
CI / Lint · Typecheck · Test · Build (push) Failing after 36s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped

FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId
column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums +
webhook events + shared contracts, removes FM routes/services/pages/UI,
and collapses dashboard admin ops to Repairs 7d/30d + trend + custody
backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 18:46:40 -04:00
parent d739411510
commit db8e86b749
32 changed files with 137 additions and 2192 deletions
-4
View File
@@ -17,8 +17,6 @@ import ManufacturerDetail from './pages/ManufacturerDetail.js';
import PartModels from './pages/PartModels.js';
import PartModelDetail from './pages/PartModelDetail.js';
import CategoryDetail from './pages/CategoryDetail.js';
import Fms from './pages/Fms.js';
import FmDetail from './pages/FmDetail.js';
import Repairs from './pages/Repairs.js';
import MyCustody from './pages/MyCustody.js';
import Hosts from './pages/Hosts.js';
@@ -68,8 +66,6 @@ export default function App() {
<Route path="/part-models" element={<PartModels />} />
<Route path="/part-models/:id" element={<PartModelDetail />} />
<Route path="/categories/:id" element={<CategoryDetail />} />
<Route path="/fms" element={<Fms />} />
<Route path="/fms/:id" element={<FmDetail />} />
<Route path="/repairs" element={<Repairs />} />
<Route path="/custody" element={<MyCustody />} />
<Route path="/hosts" element={<Hosts />} />
@@ -1,253 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Textarea,
} from '@vector/ui';
import { 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 { 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),
});
type CreateValues = z.infer<typeof CreateSchema>;
interface FmFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultHostId?: string;
defaultProblemPartIds?: string[];
onCreated?: (fm: Fm) => void;
}
export function FmFormDialog({
open,
onOpenChange,
defaultHostId,
defaultProblemPartIds,
onCreated,
}: FmFormDialogProps) {
const queryClient = useQueryClient();
const hostsQuery = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open,
});
const form = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
defaultValues: { hostId: '', problem: '', problemPartIds: [] },
});
useEffect(() => {
if (!open) return;
form.reset({
hostId: defaultHostId ?? '',
problem: '',
problemPartIds: defaultProblemPartIds ?? [],
});
}, [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) =>
createFm({
hostId: values.hostId,
problem: values.problem,
problemPartIds:
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
}),
onSuccess: (fm) => {
toast.success('FM opened');
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
onCreated?.(fm);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
const pending = createMutation.isPending;
function togglePart(partId: string, checked: boolean) {
const next = checked
? [...new Set([...selectedPartIds, partId])]
: selectedPartIds.filter((id) => id !== partId);
form.setValue('problemPartIds', next, { shouldValidate: true, shouldDirty: true });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Open FM</DialogTitle>
<DialogDescription>
Open a Future Maintenance item against a host. Select deployed parts involved (optional).
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((v) => createMutation.mutate(v))}
className="space-y-3"
>
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v);
form.setValue('problemPartIds', [], { shouldValidate: false });
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
{hostsQuery.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="problem"
render={({ field }) => (
<FormItem>
<FormLabel>Problem</FormLabel>
<FormControl>
<Textarea
rows={3}
placeholder="Short description of what's wrong."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{hostId && (
<FormField
control={form.control}
name="problemPartIds"
render={() => (
<FormItem>
<FormLabel>Affected parts (optional)</FormLabel>
<FormDescription>
Select deployed parts involved in this problem.
</FormDescription>
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
{deployedQuery.isPending ? (
<Skeleton className="m-2 h-12" />
) : !deployedQuery.data || deployedQuery.data.length === 0 ? (
<p className="p-3 text-xs text-muted-foreground">
No deployed parts on this host.
</p>
) : (
<ul className="divide-y divide-border">
{deployedQuery.data.map((part) => {
const checked = selectedPartIds.includes(part.id);
return (
<li
key={part.id}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Checkbox
id={`pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => togglePart(part.id, v === true)}
/>
<label
htmlFor={`pp-${part.id}`}
className="flex-1 cursor-pointer select-none"
>
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
<span className="text-xs text-muted-foreground">
{part.partModel.mpn}
</span>
</label>
</li>
);
})}
</ul>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
)}
<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 FM
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -4,11 +4,9 @@ import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
ArrowRightLeft,
CheckCircle2,
LogIn,
LogOut,
Pencil,
Wrench,
type LucideIcon,
} from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
@@ -18,8 +16,6 @@ import type { HostTimelineEntry } from '../../lib/api/types.js';
const ENTRY_ICON: Record<HostTimelineEntry['type'], LucideIcon> = {
HOST_EVENT: Pencil,
FM_OPENED: Wrench,
FM_CLOSED: Wrench,
REPAIR: ArrowRightLeft,
PART_ARRIVED: LogIn,
PART_DEPARTED: LogOut,
@@ -70,22 +66,6 @@ function EntryRow({ entry }: { entry: HostTimelineEntry }) {
</>
);
}
case 'FM_OPENED':
case 'FM_CLOSED': {
const { fm } = entry;
const label = entry.type === 'FM_OPENED' ? 'FM opened' : 'FM closed';
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
<Link to={`/fms/${fm.id}`} className="font-mono text-xs text-muted-foreground hover:underline">
{fm.id}
</Link>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</div>
</>
);
}
case 'REPAIR': {
const { repair } = entry;
return (
@@ -144,10 +124,6 @@ function entryKey(entry: HostTimelineEntry): string {
switch (entry.type) {
case 'HOST_EVENT':
return `he-${entry.hostEvent.id}`;
case 'FM_OPENED':
return `fo-${entry.fm.id}`;
case 'FM_CLOSED':
return `fc-${entry.fm.id}`;
case 'REPAIR':
return `r-${entry.repair.id}`;
case 'PART_ARRIVED':
@@ -13,7 +13,6 @@ import {
Server,
Users as UsersIcon,
Webhook,
Wrench,
} from 'lucide-react';
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js';
@@ -31,7 +30,6 @@ 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: '/fms', label: 'FMs', icon: Wrench },
{ to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
{ to: '/custody', label: 'My Custody', icon: Hand },
{ to: '/hosts', label: 'Hosts', icon: Server },
@@ -8,7 +8,6 @@ import {
Package,
Pencil,
Tag,
Wrench,
type LucideIcon,
} from 'lucide-react';
import type { PartEventType } from '@vector/shared';
@@ -21,8 +20,6 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
STATE_CHANGED: CheckCircle2,
LOCATION_CHANGED: MapPin,
FIELD_UPDATED: Pencil,
FM_OPENED: Wrench,
FM_CLOSED: Wrench,
PART_SWAPPED: ArrowRightLeft,
TAG_ADDED: Tag,
TAG_REMOVED: Tag,
@@ -33,8 +30,6 @@ const EVENT_TITLE: Record<PartEventType, string> = {
STATE_CHANGED: 'State changed',
LOCATION_CHANGED: 'Location changed',
FIELD_UPDATED: 'Field updated',
FM_OPENED: 'FM opened',
FM_CLOSED: 'FM closed',
PART_SWAPPED: 'Part swapped',
TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed',
@@ -30,7 +30,6 @@ import {
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';
@@ -48,7 +47,6 @@ const Schema = z
brokenMpn: z.string().trim().max(128).optional(),
brokenManufacturerId: z.union([z.literal(''), z.string().uuid()]).optional(),
replacementSerial: z.string().trim().min(1, 'Required').max(128),
fmId: z.string().optional(),
brokenExists: z.boolean().optional(),
})
.superRefine((v, ctx) => {
@@ -72,8 +70,6 @@ const Schema = z
});
type Values = z.infer<typeof Schema>;
const NO_FM = '__none__';
interface LogRepairDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -94,7 +90,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
fmId: '',
brokenExists: false,
},
});
@@ -109,12 +104,10 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
fmId: '',
brokenExists: false,
});
}, [open, form]);
const hostId = form.watch('hostId');
const brokenSerial = form.watch('brokenSerial').trim();
const hosts = useQuery({
@@ -129,13 +122,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
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({
@@ -160,7 +146,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
hostId: v.hostId,
brokenSerial: v.brokenSerial.trim(),
replacementSerial: v.replacementSerial.trim(),
fmId: v.fmId ? v.fmId : undefined,
};
// If the broken part is already catalogued, the server ignores model fields entirely.
if (existingBroken) return logRepair(base);
@@ -327,39 +312,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
</>
)}
<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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
-36
View File
@@ -1,36 +0,0 @@
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}`);
}
-1
View File
@@ -8,7 +8,6 @@ export type RepairListFilters = {
pageSize?: number;
hostId?: string;
performedById?: string;
fmId?: string;
since?: string;
};
+1 -34
View File
@@ -1,5 +1,4 @@
import type {
FmStatus,
HostState,
HostStack,
PartEventType,
@@ -124,14 +123,6 @@ export interface HostEvent {
user: { username: string } | null;
}
interface FmTimelineSummary {
id: string;
status: FmStatus;
problem: string;
openedAt: string;
closedAt: string | null;
}
interface RepairTimelineSummary {
id: string;
performedAt: string;
@@ -148,8 +139,6 @@ interface PartTimelineRef {
export type HostTimelineEntry =
| { type: 'HOST_EVENT'; at: string; hostEvent: HostEvent }
| { type: 'FM_OPENED'; at: string; fm: FmTimelineSummary }
| { type: 'FM_CLOSED'; at: string; fm: FmTimelineSummary }
| { type: 'REPAIR'; at: string; repair: RepairTimelineSummary }
| { type: 'PART_ARRIVED'; at: string; part: PartTimelineRef; partEventId: string }
| { type: 'PART_DEPARTED'; at: string; part: PartTimelineRef; partEventId: string };
@@ -171,26 +160,6 @@ export interface Category {
_count?: { partModels: number };
}
export interface FmProblemPart {
fmId: string;
partId: string;
createdAt: string;
part: Part;
}
export interface Fm {
id: string;
hostId: string;
status: FmStatus;
problem: string;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
host: Host;
problemParts: FmProblemPart[];
}
export interface Repair {
id: string;
hostId: string;
@@ -198,20 +167,18 @@ export interface Repair {
replacementPartId: string;
performedById: string;
performedAt: string;
fmId: string | null;
createdAt: string;
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' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
name: string;
filterJson: unknown;
createdAt: string;
-6
View File
@@ -53,12 +53,6 @@ export const queryKeys = {
timeline: (id: string, filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'timeline', id, filters ?? {}] 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>) =>
+37 -97
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { AlertTriangle, CalendarClock, Download, Package, Wrench } from 'lucide-react';
import { AlertTriangle, CalendarClock, Download, Package } from 'lucide-react';
import {
Bar,
BarChart,
@@ -51,7 +51,6 @@ const STATE_COLORS: Record<PartState, string> = {
};
const LINE_BLUE = 'hsl(217 91% 60%)';
const FAILURE_COLOR = 'hsl(0 84% 60%)';
const TOOLTIP_CURSOR_FILL = 'color-mix(in oklch, var(--color-foreground) 8%, transparent)';
const TOOLTIP_CURSOR_STROKE = 'color-mix(in oklch, var(--color-foreground) 24%, transparent)';
@@ -75,12 +74,6 @@ function currency(dollars: number): string {
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
}
function formatHours(h: number): string {
if (h < 24) return `${h.toFixed(1)} h`;
const days = h / 24;
return `${days.toFixed(1)} d`;
}
function shortDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
@@ -122,18 +115,12 @@ export default function Dashboard() {
{data && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiCard
icon={<Package className="h-4 w-4" />}
label="Total parts"
value={data.totalParts.toLocaleString()}
/>
<KpiCard
icon={<Wrench className="h-4 w-4" />}
label="Open FMs"
value={data.openFms.toLocaleString()}
href="/fms"
/>
<KpiCard
label="Deployed value"
value={currency(
@@ -164,7 +151,7 @@ export default function Dashboard() {
</div>
{data.operations && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
<KpiCard
label="Repairs (7d)"
value={data.operations.repairs7d.toLocaleString()}
@@ -175,19 +162,6 @@ export default function Dashboard() {
value={data.operations.repairs30d.toLocaleString()}
href="/repairs"
/>
<KpiCard
label="FMs opened (7d)"
value={data.operations.newFms7d.toLocaleString()}
href="/fms"
/>
<KpiCard
label="Avg FM close (30d)"
value={
data.operations.avgFmCloseHours30d == null
? '—'
: formatHours(data.operations.avgFmCloseHours30d)
}
/>
</div>
)}
@@ -343,72 +317,38 @@ export default function Dashboard() {
</div>
{data.operations && (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Repairs (last 30 days)</CardTitle>
<CardDescription>Daily count of logged part swaps.</CardDescription>
</CardHeader>
<CardContent className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data.operations.repairsTrend30d.map((d) => ({
label: shortDate(d.date),
count: d.count,
}))}
>
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={3} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip
cursor={{ stroke: TOOLTIP_CURSOR_STROKE }}
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/>
<Line
type="monotone"
dataKey="count"
stroke={LINE_BLUE}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Open FMs by host</CardTitle>
<CardDescription>
Where the active field-maintenance load is concentrated.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pb-5">
{data.operations.openFmsByHost.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No open FMs.
</div>
) : (
data.operations.openFmsByHost.map((h) => (
<Link
key={h.hostId}
to={`/hosts/${h.hostId}`}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm transition-colors hover:bg-accent/40"
>
<span className="truncate font-medium">{h.hostName}</span>
<span
className="tabular-nums font-semibold"
style={{ color: FAILURE_COLOR }}
>
{h.count}
</span>
</Link>
))
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Repairs (last 30 days)</CardTitle>
<CardDescription>Daily count of logged part swaps.</CardDescription>
</CardHeader>
<CardContent className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data.operations.repairsTrend30d.map((d) => ({
label: shortDate(d.date),
count: d.count,
}))}
>
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={3} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip
cursor={{ stroke: TOOLTIP_CURSOR_STROKE }}
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/>
<Line
type="monotone"
dataKey="count"
stroke={LINE_BLUE}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{data.operations && data.operations.custodyBacklog.length > 0 && (
@@ -554,8 +494,8 @@ function EolBanner({
function DashboardSkeleton() {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
-438
View File
@@ -1,438 +0,0 @@
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 {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Separator,
Skeleton,
Textarea,
} from '@vector/ui';
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 { Fm } from '../lib/api/types.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
export default function FmDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: fm, isPending, isError, error } = useQuery({
queryKey: queryKeys.fms.detail(id!),
queryFn: () => getFm(id!),
enabled: Boolean(id),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fms.detail(id!) });
queryClient.invalidateQueries({ queryKey: queryKeys.fms.list() });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
};
const toggleMutation = useMutation({
mutationFn: () => updateFm(id!, { status: fm?.status === 'OPEN' ? 'CLOSED' : 'OPEN' }),
onSuccess: () => {
toast.success(fm?.status === 'OPEN' ? 'FM closed' : 'FM reopened');
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 || !fm) {
const msg = error instanceof ApiRequestError ? error.body.message : 'FM not found.';
return (
<Card>
<CardHeader>
<CardTitle>FM unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/fms')}>
<ArrowLeft className="h-4 w-4" />
Back to FMs
</Button>
</CardContent>
</Card>
);
}
const closed = fm.status === 'CLOSED';
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('/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>
<Link
to={`/hosts/${fm.host.id}`}
className="font-mono text-2xl font-semibold tracking-tight text-foreground hover:underline"
>
{fm.host.assetId}
</Link>
<HostStateBadge state={fm.host.state} />
<HostStackBadge stack={fm.host.stack} />
<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" />
<Link to={`/hosts/${fm.host.id}`} className="hover:underline">
{fm.host.name}
</Link>
{fm.host.location && <span>· {fm.host.location}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={closed ? 'outline' : 'default'}
onClick={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
>
{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 fm={fm} onSaved={invalidate} disabled={closed} />
<ProblemPartsCard fm={fm} onSaved={invalidate} disabled={closed} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Timeline</CardTitle>
<CardDescription>
The actual repair work lives in the external ticketing system.
</CardDescription>
</CardHeader>
<CardContent>
<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>
</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({
fm,
onSaved,
disabled,
}: {
fm: { id: string; problem: string };
onSaved: () => void;
disabled: boolean;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(fm.problem);
useEffect(() => {
setValue(fm.problem);
}, [fm.problem]);
const mutation = useMutation({
mutationFn: (problem: string) => updateFm(fm.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(fm.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() === fm.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">{fm.problem}</p>
)}
</CardContent>
</Card>
);
}
function ProblemPartsCard({
fm,
onSaved,
disabled,
}: {
fm: Fm;
onSaved: () => void;
disabled: boolean;
}) {
const [picking, setPicking] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const deployedQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(fm.hostId),
queryFn: () => listHostDeployedParts(fm.hostId),
enabled: picking,
});
useEffect(() => {
if (picking) {
setDraft(fm.problemParts.map((pp) => pp.partId));
}
}, [picking, fm.problemParts]);
const mutation = useMutation({
mutationFn: (problemPartIds: string[]) => updateFm(fm.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 = fm.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
return updateFm(fm.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={`fd-pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => toggle(part.id, v === true)}
/>
<label
htmlFor={`fd-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>
) : fm.problemParts.length === 0 ? (
<p className="text-sm text-muted-foreground">
No specific parts tagged the FM is against the host itself.
</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border">
{fm.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>
);
}
-244
View File
@@ -1,244 +0,0 @@
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 { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.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 }) => (
<div className="flex items-center gap-1.5">
<Link
to={`/fms/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.host.assetId}
</Link>
<HostStateBadge state={row.original.host.state} />
<HostStackBadge stack={row.original.host.stack} />
</div>
),
},
{
id: 'host',
header: 'Host',
cell: ({ row }) => (
<Link
to={`/hosts/${row.original.host.id}`}
className="text-sm hover:underline"
>
{row.original.host.name}
</Link>
),
},
{
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="Open future-maintenance items logged against hosts."
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>
);
}
-15
View File
@@ -65,21 +65,6 @@ export default function Repairs() {
<span className="text-xs">{row.original.performedBy.username}</span>
),
},
{
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>
),
},
],
[],
);