import { useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import type { ColumnDef } from '@tanstack/react-table'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { parseAsString } from 'nuqs'; import { toast } from 'sonner'; import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react'; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@vector/ui'; import { PageHeader } from '../components/layout/PageHeader.js'; import { DataTable } from '../components/data-table/DataTable.js'; import { PartStateBadge, partStateOptions } from '../components/parts/PartStateBadge.js'; import { PartFormDialog } from '../components/parts/PartFormDialog.js'; import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js'; import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { listParts, deletePart } from '../lib/api/parts.js'; import { listManufacturers } from '../lib/api/manufacturers.js'; import { listCategories } from '../lib/api/categories.js'; import { listTags } from '../lib/api/tags.js'; import { takeForRepair } from '../lib/api/custody.js'; import { ApiRequestError } from '../lib/api/client.js'; import type { Part } from '../lib/api/types.js'; import { queryKeys } from '../lib/queryKeys.js'; import { useAuth } from '../contexts/AuthContext.js'; type PartsFilters = { state: string | null; manufacturerId: string | null; categoryId: string | null; tagId: string | null; }; const filterParsers = { state: parseAsString, manufacturerId: parseAsString, categoryId: parseAsString, tagId: parseAsString, }; const ALL = '__all__'; export default function Parts() { const { user } = useAuth(); const navigate = useNavigate(); const queryClient = useQueryClient(); const isAdmin = user?.role === 'ADMIN'; const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [bulkIds, setBulkIds] = useState(null); const [clearSelection, setClearSelection] = useState<(() => void) | null>(null); const manufacturers = useQuery({ queryKey: queryKeys.manufacturers.list({ pageSize: 100 }), queryFn: () => listManufacturers({ pageSize: 100 }), }); const categoriesQuery = useQuery({ queryKey: queryKeys.categories.list({ pageSize: 100 }), queryFn: () => listCategories({ pageSize: 100 }), }); const tagsQuery = useQuery({ queryKey: queryKeys.tags.list({ pageSize: 100 }), queryFn: () => listTags({ pageSize: 100 }), }); const deleteMutation = useMutation({ mutationFn: (id: string) => deletePart(id), onSuccess: () => { toast.success('Part deleted'); queryClient.invalidateQueries({ queryKey: queryKeys.parts.all }); setDeleting(null); }, onError: (err) => { toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'); }, }); const takeForRepairMutation = useMutation({ mutationFn: (id: string) => takeForRepair(id), onSuccess: () => { toast.success('Part moved into your custody'); queryClient.invalidateQueries({ queryKey: queryKeys.parts.all }); queryClient.invalidateQueries({ queryKey: queryKeys.custody.all }); }, onError: (err) => toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not take part'), }); const columns = 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} ), }, { id: 'category', header: 'Category', cell: ({ row }) => row.original.partModel.category ? ( {row.original.partModel.category.name} ) : ( ), }, { accessorKey: 'state', header: 'State', cell: ({ row }) => , }, { id: 'location', header: 'Location', cell: ({ row }) => { const { host, custodian, bin } = row.original; if (host) { return ( {host.assetId} / {host.name} ); } if (custodian) { return ( Custody: {custodian.username} ); } return bin?.fullPath ? ( {bin.fullPath} ) : ( Unassigned ); }, }, { accessorKey: 'price', header: 'Price', cell: ({ row }) => row.original.price != null ? ( ${row.original.price.toFixed(2)} ) : ( ), }, { id: 'actions', header: () => Actions, size: 40, cell: ({ row }) => ( navigate(`/parts/${row.original.id}`)}> View setEditing(row.original)}> Edit {row.original.state === 'SPARE' && ( takeForRepairMutation.mutate(row.original.id)} disabled={takeForRepairMutation.isPending} > Take into custody )} {isAdmin && ( <> setDeleting(row.original)} className="text-destructive focus:text-destructive" > Delete )} ), }, ], [navigate, isAdmin, takeForRepairMutation], ); return (
setCreateOpen(true)}> New part ) } /> columns={columns} getRowId={(p) => p.id} queryKey={(params) => queryKeys.parts.list({ page: params.page, pageSize: params.pageSize, q: params.q, sort: params.sort, state: params.filters.state, manufacturerId: params.filters.manufacturerId, categoryId: params.filters.categoryId, tagId: params.filters.tagId, }) } queryFn={(params) => listParts({ page: params.page, pageSize: params.pageSize, q: params.q, sort: params.sort, state: params.filters.state ?? undefined, manufacturerId: params.filters.manufacturerId ?? undefined, categoryId: params.filters.categoryId ?? undefined, tagId: params.filters.tagId ?? undefined, }) } filterParsers={filterParsers} searchPlaceholder="Search serial, MPN, notes…" enableSelection={isAdmin} emptyState={
No parts match these filters.
} toolbar={({ filters, setFilter }) => ( setFilter('state', v === ALL ? null : v)} onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)} onCategory={(v) => setFilter('categoryId', v === ALL ? null : v)} onTag={(v) => setFilter('tagId', v === ALL ? null : v)} /> )} bulkActions={(ids, clear) => ( )} /> !o && setEditing(null)} part={editing} /> { if (!o) setBulkIds(null); }} partIds={bulkIds ?? []} onDone={() => clearSelection?.()} /> !o && setDeleting(null)} title="Delete part?" description={ deleting ? `Permanently remove part ${deleting.serialNumber}. Its history will be removed too.` : undefined } confirmLabel="Delete" destructive pending={deleteMutation.isPending} onConfirm={() => deleting && deleteMutation.mutate(deleting.id)} />
); } interface PartsFiltersProps { manufacturers: { id: string; name: string }[]; categories: { id: string; name: string }[]; tags: { id: string; name: string }[]; state: string; manufacturerId: string; categoryId: string; tagId: string; onState: (v: string) => void; onManufacturer: (v: string) => void; onCategory: (v: string) => void; onTag: (v: string) => void; } function PartsFilters({ manufacturers, categories, tags, state, manufacturerId, categoryId, tagId, onState, onManufacturer, onCategory, onTag, }: PartsFiltersProps) { return (
); }