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
+86 -10
View File
@@ -1,8 +1,13 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { parseAsString, useQueryState } from 'nuqs';
import { ChevronRight, MapPin } from 'lucide-react';
import { PageHeader } from '../components/layout/PageHeader.js';
import { SiteList } from '../components/locations/SiteList.js';
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
import { SiteRoomTree } from '../components/locations/SiteRoomTree.js';
import { BinGrid } from '../components/locations/BinGrid.js';
import { listSites } from '../lib/api/sites.js';
import { listRooms } from '../lib/api/rooms.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
export default function Locations() {
@@ -20,23 +25,94 @@ export default function Locations() {
void setRoomId(id || null);
};
const sites = useQuery({
queryKey: queryKeys.sites.list({ pageSize: 100 }),
queryFn: () => listSites({ pageSize: 100 }),
});
const rooms = useQuery({
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
enabled: Boolean(siteId),
});
const siteName = useMemo(
() => sites.data?.data.find((s) => s.id === siteId)?.name,
[sites.data, siteId],
);
const roomName = useMemo(
() => rooms.data?.data.find((r) => r.id === roomId)?.name,
[rooms.data, roomId],
);
return (
<div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4">
<PageHeader
title="Locations"
description="Sites → Rooms → Bins. Select a site to drill in."
description="Sites → Rooms → Bins. Pick a room to see its bins."
/>
<div className="grid min-h-0 flex-1 grid-cols-[16rem_16rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
<div className="border-r border-border">
<SiteList selectedId={siteId} onSelect={handleSite} canEdit={canEdit} />
<SiteRoomTree
siteId={siteId}
roomId={roomId}
onSelectSite={handleSite}
onSelectRoom={handleRoom}
canEdit={canEdit}
/>
</div>
<div className="border-r border-border">
<RoomDrawer siteId={siteId} selectedId={roomId} onSelect={handleRoom} canEdit={canEdit} />
</div>
<div>
<BinGrid roomId={roomId} canEdit={canEdit} />
<div className="flex min-h-0 flex-col">
<Breadcrumb siteName={siteName} roomName={roomName} />
<div className="min-h-0 flex-1 overflow-y-auto">
{roomId ? (
<BinGrid roomId={roomId} canEdit={canEdit} />
) : (
<EmptyPane siteSelected={Boolean(siteId)} />
)}
</div>
</div>
</div>
</div>
);
}
function Breadcrumb({
siteName,
roomName,
}: {
siteName: string | undefined;
roomName: string | undefined;
}) {
return (
<div className="flex items-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground">
{siteName ? (
<>
<span className="text-foreground">{siteName}</span>
{roomName && (
<>
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
<span className="text-foreground">{roomName}</span>
</>
)}
</>
) : (
<span>Select a site to begin.</span>
)}
</div>
);
}
function EmptyPane({ siteSelected }: { siteSelected: boolean }) {
return (
<div className="flex h-full items-center justify-center p-8">
<div className="flex max-w-sm flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-8 py-10 text-center">
<MapPin className="h-6 w-6 text-muted-foreground" />
<p className="text-sm font-medium">
{siteSelected ? 'Pick a room' : 'Pick a site and room'}
</p>
<p className="text-xs text-muted-foreground">
Bins live inside rooms. Expand a site in the tree and choose a room to manage its bins.
</p>
</div>
</div>
);
}