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()} + /> +
+ ); +}