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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user