Files
Vector/apps/web/src/pages/Parts.tsx
T
josh 60255f20bb
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s
feat: laundry-list polish pass
Seven bundled improvements:
- PartModel combobox on Add Part + Log Repair (known MPN auto-fills;
  unknown reveals manufacturer picker for catalog upsert).
- Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack
  (PRODUCTION/VETTING) fields, driven by external clients via the API.
- Locations page redesigned as a 2-pane tree + bin grid with breadcrumb.
- PENDING_REPAIR custody state: tech takes a SPARE into custody for a
  future swap; resolves to DEPLOYED via Repair or back to SPARE via a
  bin-required drop-off.
- Move Category from Part to PartModel; seed common categories
  (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category
  column and filter sourced from the model.
- Fix Deployed Value 100x bug on the Dashboard (price is stored as
  dollars, not cents).
- PartModels table shows "No" instead of "--" when destroyOnFail=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 13:36:11 -04:00

426 lines
14 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs';
import { toast } from 'sonner';
import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
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 { PartStateBadge, partStateOptions } from '../components/parts/PartStateBadge.js';
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { listParts, deletePart } from '../lib/api/parts.js';
import { listManufacturers } from '../lib/api/manufacturers.js';
import { listCategories } from '../lib/api/categories.js';
import { listTags } from '../lib/api/tags.js';
import { takeForRepair } from '../lib/api/custody.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { Part } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
type PartsFilters = {
state: string | null;
manufacturerId: string | null;
categoryId: string | null;
tagId: string | null;
};
const filterParsers = {
state: parseAsString,
manufacturerId: parseAsString,
categoryId: parseAsString,
tagId: parseAsString,
};
const ALL = '__all__';
export default function Parts() {
const { user } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isAdmin = user?.role === 'ADMIN';
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Part | null>(null);
const [deleting, setDeleting] = useState<Part | null>(null);
const [bulkIds, setBulkIds] = useState<string[] | null>(null);
const [clearSelection, setClearSelection] = useState<(() => void) | null>(null);
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
});
const categoriesQuery = useQuery({
queryKey: queryKeys.categories.list({ pageSize: 100 }),
queryFn: () => listCategories({ pageSize: 100 }),
});
const tagsQuery = useQuery({
queryKey: queryKeys.tags.list({ pageSize: 100 }),
queryFn: () => listTags({ pageSize: 100 }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePart(id),
onSuccess: () => {
toast.success('Part deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
setDeleting(null);
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
const takeForRepairMutation = useMutation({
mutationFn: (id: string) => takeForRepair(id),
onSuccess: () => {
toast.success('Part moved into your custody');
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not take part'),
});
const columns = useMemo<ColumnDef<Part>[]>(
() => [
{
accessorKey: 'serialNumber',
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 }) => (
<span className="text-sm font-mono">{row.original.partModel.mpn}</span>
),
},
{
id: 'manufacturer',
header: 'Manufacturer',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.manufacturer.name}</span>
),
},
{
id: 'category',
header: 'Category',
cell: ({ row }) =>
row.original.partModel.category ? (
<span className="text-xs text-muted-foreground">
{row.original.partModel.category.name}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => {
const { host, custodian, bin } = row.original;
if (host) {
return (
<span className="text-xs font-mono text-muted-foreground">
{host.assetId} / {host.name}
</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>
);
},
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) =>
row.original.price != null ? (
<span className="text-sm tabular-nums">${row.original.price.toFixed(2)}</span>
) : (
<span className="text-xs text-muted-foreground"></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-44">
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
View
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
{row.original.state === 'SPARE' && (
<DropdownMenuItem
onSelect={() => takeForRepairMutation.mutate(row.original.id)}
disabled={takeForRepairMutation.isPending}
>
<HandHelping className="h-3.5 w-3.5" />
Take into custody
</DropdownMenuItem>
)}
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[navigate, isAdmin, takeForRepairMutation],
);
return (
<div className="space-y-5">
<PageHeader
title="Parts"
description="Search, filter, and manage every tracked part."
actions={
isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
New part
</Button>
)
}
/>
<DataTable<Part, PartsFilters>
columns={columns}
getRowId={(p) => p.id}
queryKey={(params) =>
queryKeys.parts.list({
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
state: params.filters.state,
manufacturerId: params.filters.manufacturerId,
categoryId: params.filters.categoryId,
tagId: params.filters.tagId,
})
}
queryFn={(params) =>
listParts({
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
state: params.filters.state ?? undefined,
manufacturerId: params.filters.manufacturerId ?? undefined,
categoryId: params.filters.categoryId ?? undefined,
tagId: params.filters.tagId ?? undefined,
})
}
filterParsers={filterParsers}
searchPlaceholder="Search serial, MPN, notes…"
enableSelection={isAdmin}
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Package className="h-6 w-6" />
<span className="text-sm">No parts match these filters.</span>
</div>
}
toolbar={({ filters, setFilter }) => (
<PartsFilters
manufacturers={manufacturers.data?.data ?? []}
categories={categoriesQuery.data?.data ?? []}
tags={tagsQuery.data?.data ?? []}
state={filters.state ?? ALL}
manufacturerId={filters.manufacturerId ?? ALL}
categoryId={filters.categoryId ?? ALL}
tagId={filters.tagId ?? ALL}
onState={(v) => setFilter('state', v === ALL ? null : v)}
onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)}
onCategory={(v) => setFilter('categoryId', v === ALL ? null : v)}
onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
/>
)}
bulkActions={(ids, clear) => (
<Button
variant="outline"
size="sm"
onClick={() => {
setBulkIds(ids);
setClearSelection(() => clear);
}}
>
Bulk edit
</Button>
)}
/>
<PartFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<PartFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
part={editing}
/>
<PartBulkStateDialog
open={Boolean(bulkIds)}
onOpenChange={(o) => {
if (!o) setBulkIds(null);
}}
partIds={bulkIds ?? []}
onDone={() => clearSelection?.()}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete part?"
description={
deleting
? `Permanently remove part ${deleting.serialNumber}. Its history will be removed too.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
interface PartsFiltersProps {
manufacturers: { id: string; name: string }[];
categories: { id: string; name: string }[];
tags: { id: string; name: string }[];
state: string;
manufacturerId: string;
categoryId: string;
tagId: string;
onState: (v: string) => void;
onManufacturer: (v: string) => void;
onCategory: (v: string) => void;
onTag: (v: string) => void;
}
function PartsFilters({
manufacturers,
categories,
tags,
state,
manufacturerId,
categoryId,
tagId,
onState,
onManufacturer,
onCategory,
onTag,
}: PartsFiltersProps) {
return (
<div className="flex items-center gap-2">
<Select value={state} onValueChange={onState}>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All states</SelectItem>
{partStateOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={manufacturerId} onValueChange={onManufacturer}>
<SelectTrigger className="h-8 w-48 text-xs">
<SelectValue placeholder="Manufacturer" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All manufacturers</SelectItem>
{manufacturers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={categoryId} onValueChange={onCategory}>
<SelectTrigger className="h-8 w-40 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tagId} onValueChange={onTag}>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue placeholder="Tag" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All tags</SelectItem>
{tags.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}