60255f20bb
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>
426 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|