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>
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user