diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index d3ba64b..bd8e342 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -11,6 +11,7 @@ import Dashboard from './pages/Dashboard.js';
import Parts from './pages/Parts.js';
import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js';
+import BinDetail from './pages/BinDetail.js';
import Manufacturers from './pages/Manufacturers.js';
import ManufacturerDetail from './pages/ManufacturerDetail.js';
import PartModels from './pages/PartModels.js';
@@ -60,6 +61,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/web/src/components/locations/BinGrid.tsx b/apps/web/src/components/locations/BinGrid.tsx
index 189c130..093ff74 100644
--- a/apps/web/src/components/locations/BinGrid.tsx
+++ b/apps/web/src/components/locations/BinGrid.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
@@ -117,39 +118,49 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
) : (
{bins.data!.data.map((b) => (
-
-
-
-
-
{b.name}
-
- {b.fullPath}
-
-
+
+
+
+
+
+
{b.name}
+
+ {b.fullPath}
+
+
+
{canEdit && (
-
-
-
-
-
- setRenaming(b)}>
- Rename
-
- setDeleting(b)}
- className="text-destructive focus:text-destructive"
- >
-
- Delete
-
-
-
+
+
+
+
+
+
+ setRenaming(b)}>
+ Rename
+
+ setDeleting(b)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
)}
diff --git a/apps/web/src/lib/api/bins.ts b/apps/web/src/lib/api/bins.ts
index efdfddf..edf64f2 100644
--- a/apps/web/src/lib/api/bins.ts
+++ b/apps/web/src/lib/api/bins.ts
@@ -9,6 +9,11 @@ export function listBins(
return getList('/bins', filters);
}
+export async function getBin(id: string): Promise {
+ const res = await api.get(`/bins/${id}`);
+ return res.data;
+}
+
export async function createBin(input: CreateBinRequest): Promise {
const res = await api.post('/bins', input);
return res.data;
diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts
index 5ad18b2..2d4e5b6 100644
--- a/apps/web/src/lib/queryKeys.ts
+++ b/apps/web/src/lib/queryKeys.ts
@@ -37,6 +37,7 @@ export const queryKeys = {
all: ['bins'] as const,
list: (filters?: Record) =>
[...queryKeys.bins.all, 'list', filters ?? {}] as const,
+ detail: (id: string) => [...queryKeys.bins.all, 'detail', id] as const,
},
users: {
all: ['users'] as const,
diff --git a/apps/web/src/pages/BinDetail.tsx b/apps/web/src/pages/BinDetail.tsx
new file mode 100644
index 0000000..61d9e52
--- /dev/null
+++ b/apps/web/src/pages/BinDetail.tsx
@@ -0,0 +1,305 @@
+import { useMemo, useState } from 'react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+import type { ColumnDef } from '@tanstack/react-table';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { ArrowLeft, Edit, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Separator,
+ Skeleton,
+} from '@vector/ui';
+import { deleteBin, getBin, updateBin } from '../lib/api/bins.js';
+import { listParts } from '../lib/api/parts.js';
+import { ApiRequestError } from '../lib/api/client.js';
+import { queryKeys } from '../lib/queryKeys.js';
+import { useAuth } from '../contexts/AuthContext.js';
+import { DataTable } from '../components/data-table/DataTable.js';
+import { PartStateBadge } from '../components/parts/PartStateBadge.js';
+import { NamePromptDialog } from '../components/NamePromptDialog.js';
+import { ConfirmDialog } from '../components/ConfirmDialog.js';
+import type { Part } from '../lib/api/types.js';
+
+function currency(dollars: number): string {
+ return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
+}
+
+function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
+
+export default function BinDetail() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
+ const isAdmin = user?.role === 'ADMIN';
+
+ const [renameOpen, setRenameOpen] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ const binQuery = useQuery({
+ queryKey: queryKeys.bins.detail(id!),
+ queryFn: () => getBin(id!),
+ enabled: Boolean(id),
+ });
+
+ const bin = binQuery.data;
+ const locationQuery = bin
+ ? `?site=${bin.room.siteId}&room=${bin.roomId}`
+ : '';
+
+ const renameMutation = useMutation({
+ mutationFn: (name: string) => updateBin(id!, { name }),
+ onSuccess: () => {
+ toast.success('Bin renamed');
+ queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
+ setRenameOpen(false);
+ },
+ onError: (err) => {
+ toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed');
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: () => deleteBin(id!),
+ onSuccess: () => {
+ toast.success('Bin deleted');
+ queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
+ queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
+ navigate(`/locations${locationQuery}`, { replace: true });
+ },
+ onError: (err) => {
+ toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
+ },
+ });
+
+ const partColumns = useMemo[]>(
+ () => [
+ {
+ accessorKey: 'serialNumber',
+ header: 'Serial',
+ cell: ({ row }) => (
+
+ {row.original.serialNumber}
+
+ ),
+ },
+ {
+ id: 'mpn',
+ header: 'MPN',
+ cell: ({ row }) => (
+
+ {row.original.partModel.mpn}
+
+ ),
+ },
+ {
+ id: 'manufacturer',
+ header: 'Manufacturer',
+ cell: ({ row }) => (
+
+ {row.original.manufacturer.name}
+
+ ),
+ },
+ {
+ accessorKey: 'state',
+ header: 'State',
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: 'price',
+ header: 'Price',
+ cell: ({ row }) =>
+ row.original.price != null ? (
+ {currency(row.original.price)}
+ ) : (
+ —
+ ),
+ },
+ ],
+ [],
+ );
+
+ if (binQuery.isPending) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (binQuery.isError || !bin) {
+ const msg =
+ binQuery.error instanceof ApiRequestError ? binQuery.error.body.message : 'Bin not found.';
+ return (
+
+
+ Bin unavailable
+ {msg}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
{bin.name}
+
{bin.fullPath}
+
+
+
+ {isAdmin && (
+
+ )}
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+
+ Summary
+
+
+
+
+
+ {bin.room.site.name}
+
+ }
+ />
+
+ {bin.room.name}
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ Parts in this bin
+ Every unit currently assigned to {bin.name}.
+
+
+ >
+ columns={partColumns}
+ getRowId={(p) => p.id}
+ queryKey={(params) =>
+ queryKeys.parts.list({
+ binId: id,
+ page: params.page,
+ pageSize: params.pageSize,
+ q: params.q,
+ sort: params.sort,
+ })
+ }
+ queryFn={(params) =>
+ listParts({
+ binId: id,
+ page: params.page,
+ pageSize: params.pageSize,
+ q: params.q,
+ sort: params.sort,
+ })
+ }
+ searchPlaceholder="Search serial..."
+ emptyState={
+
+ No parts in this bin yet.
+
+ }
+ />
+
+
+
+
+
renameMutation.mutate(name)}
+ />
+ deleteMutation.mutate()}
+ />
+
+ );
+}