feat(bins): clickable bin cards with dedicated detail page
Clicking a bin on Locations now navigates to /bins/:id, showing the bin's site/room/name, created/updated metadata, and a paginated DataTable of parts currently in the bin. Admins can rename or delete the bin from the detail page; the BinGrid kebab menu still works without triggering navigation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import Dashboard from './pages/Dashboard.js';
|
|||||||
import Parts from './pages/Parts.js';
|
import Parts from './pages/Parts.js';
|
||||||
import PartDetail from './pages/PartDetail.js';
|
import PartDetail from './pages/PartDetail.js';
|
||||||
import Locations from './pages/Locations.js';
|
import Locations from './pages/Locations.js';
|
||||||
|
import BinDetail from './pages/BinDetail.js';
|
||||||
import Manufacturers from './pages/Manufacturers.js';
|
import Manufacturers from './pages/Manufacturers.js';
|
||||||
import ManufacturerDetail from './pages/ManufacturerDetail.js';
|
import ManufacturerDetail from './pages/ManufacturerDetail.js';
|
||||||
import PartModels from './pages/PartModels.js';
|
import PartModels from './pages/PartModels.js';
|
||||||
@@ -60,6 +61,7 @@ export default function App() {
|
|||||||
<Route path="/parts" element={<Parts />} />
|
<Route path="/parts" element={<Parts />} />
|
||||||
<Route path="/parts/:id" element={<PartDetail />} />
|
<Route path="/parts/:id" element={<PartDetail />} />
|
||||||
<Route path="/locations" element={<Locations />} />
|
<Route path="/locations" element={<Locations />} />
|
||||||
|
<Route path="/bins/:id" element={<BinDetail />} />
|
||||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||||
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
|
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
|
||||||
<Route path="/part-models" element={<PartModels />} />
|
<Route path="/part-models" element={<PartModels />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
@@ -117,8 +118,15 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
{bins.data!.data.map((b) => (
|
{bins.data!.data.map((b) => (
|
||||||
<Card key={b.id} className="group relative">
|
<Card
|
||||||
<CardContent className="flex items-start gap-2 p-3">
|
key={b.id}
|
||||||
|
className="group relative transition-colors hover:border-primary/40 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-start gap-2 p-0">
|
||||||
|
<Link
|
||||||
|
to={`/bins/${b.id}`}
|
||||||
|
className="flex min-w-0 flex-1 items-start gap-2 p-3"
|
||||||
|
>
|
||||||
<Archive className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
<Archive className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium text-sm">{b.name}</p>
|
<p className="truncate font-medium text-sm">{b.name}</p>
|
||||||
@@ -126,7 +134,9 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
|
|||||||
{b.fullPath}
|
{b.fullPath}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
|
<div className="p-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -150,6 +160,7 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export function listBins(
|
|||||||
return getList<BinWithPath>('/bins', filters);
|
return getList<BinWithPath>('/bins', filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBin(id: string): Promise<BinWithPath> {
|
||||||
|
const res = await api.get<BinWithPath>(`/bins/${id}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBin(input: CreateBinRequest): Promise<BinWithPath> {
|
export async function createBin(input: CreateBinRequest): Promise<BinWithPath> {
|
||||||
const res = await api.post<BinWithPath>('/bins', input);
|
const res = await api.post<BinWithPath>('/bins', input);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const queryKeys = {
|
|||||||
all: ['bins'] as const,
|
all: ['bins'] as const,
|
||||||
list: (filters?: Record<string, unknown>) =>
|
list: (filters?: Record<string, unknown>) =>
|
||||||
[...queryKeys.bins.all, 'list', filters ?? {}] as const,
|
[...queryKeys.bins.all, 'list', filters ?? {}] as const,
|
||||||
|
detail: (id: string) => [...queryKeys.bins.all, 'detail', id] as const,
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
all: ['users'] as const,
|
all: ['users'] as const,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-foreground">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ColumnDef<Part>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'serialNumber',
|
||||||
|
header: 'Serial',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/parts/${row.original.id}`} className="font-mono text-xs hover:underline">
|
||||||
|
{row.original.serialNumber}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mpn',
|
||||||
|
header: 'MPN',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/part-models/${row.original.partModelId}`}
|
||||||
|
className="font-mono text-xs hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.partModel.mpn}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manufacturer',
|
||||||
|
header: 'Manufacturer',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/manufacturers/${row.original.manufacturerId}`}
|
||||||
|
className="text-xs hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.manufacturer.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'state',
|
||||||
|
header: 'State',
|
||||||
|
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'price',
|
||||||
|
header: 'Price',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.price != null ? (
|
||||||
|
<span className="tabular-nums">{currency(row.original.price)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (binQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binQuery.isError || !bin) {
|
||||||
|
const msg =
|
||||||
|
binQuery.error instanceof ApiRequestError ? binQuery.error.body.message : 'Bin not found.';
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bin unavailable</CardTitle>
|
||||||
|
<CardDescription>{msg}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/locations')}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to locations
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(`/locations${locationQuery}`)}
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">{bin.name}</h1>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">{bin.fullPath}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setRenameOpen(true)}>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[1fr_1.6fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<DetailRow label="Name" value={bin.name} />
|
||||||
|
<DetailRow
|
||||||
|
label="Site"
|
||||||
|
value={
|
||||||
|
<Link
|
||||||
|
to={`/locations?site=${bin.room.siteId}`}
|
||||||
|
className="text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{bin.room.site.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Room"
|
||||||
|
value={
|
||||||
|
<Link
|
||||||
|
to={`/locations?site=${bin.room.siteId}&room=${bin.roomId}`}
|
||||||
|
className="text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{bin.room.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<DetailRow label="Created" value={new Date(bin.createdAt).toLocaleString()} />
|
||||||
|
<DetailRow label="Updated" value={new Date(bin.updatedAt).toLocaleString()} />
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Parts in this bin</CardTitle>
|
||||||
|
<CardDescription>Every unit currently assigned to {bin.name}.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DataTable<Part, Record<string, never>>
|
||||||
|
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={
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No parts in this bin yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NamePromptDialog
|
||||||
|
open={renameOpen}
|
||||||
|
onOpenChange={setRenameOpen}
|
||||||
|
title="Rename bin"
|
||||||
|
label="Bin name"
|
||||||
|
confirmLabel="Rename"
|
||||||
|
initialValue={bin.name}
|
||||||
|
pending={renameMutation.isPending}
|
||||||
|
onSubmit={(name) => renameMutation.mutate(name)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
onOpenChange={setConfirmDelete}
|
||||||
|
title="Delete bin?"
|
||||||
|
description={`Remove ${bin.name}. Parts in this bin become unassigned.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user