feat: laundry-list polish pass
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s

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>
This commit is contained in:
2026-04-17 13:36:11 -04:00
parent 3d77f2846d
commit 60255f20bb
39 changed files with 1731 additions and 630 deletions
+67 -3
View File
@@ -4,7 +4,7 @@ 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, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
import {
Button,
DropdownMenu,
@@ -26,7 +26,9 @@ 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';
@@ -35,12 +37,14 @@ 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,
};
@@ -62,6 +66,10 @@ export default function Parts() {
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 }),
@@ -79,6 +87,17 @@ export default function Parts() {
},
});
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>[]>(
() => [
{
@@ -107,6 +126,18 @@ export default function Parts() {
<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',
@@ -159,7 +190,7 @@ export default function Parts() {
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
View
</DropdownMenuItem>
@@ -167,6 +198,15 @@ export default function Parts() {
<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 />
@@ -184,7 +224,7 @@ export default function Parts() {
),
},
],
[navigate, isAdmin],
[navigate, isAdmin, takeForRepairMutation],
);
return (
@@ -213,6 +253,7 @@ export default function Parts() {
sort: params.sort,
state: params.filters.state,
manufacturerId: params.filters.manufacturerId,
categoryId: params.filters.categoryId,
tagId: params.filters.tagId,
})
}
@@ -224,6 +265,7 @@ export default function Parts() {
sort: params.sort,
state: params.filters.state ?? undefined,
manufacturerId: params.filters.manufacturerId ?? undefined,
categoryId: params.filters.categoryId ?? undefined,
tagId: params.filters.tagId ?? undefined,
})
}
@@ -239,12 +281,15 @@ export default function Parts() {
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)}
/>
)}
@@ -296,23 +341,29 @@ export default function Parts() {
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 (
@@ -343,6 +394,19 @@ function PartsFilters({
))}
</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" />