import { useMemo, useState } from 'react'; import { MdWarningAmber, MdAdd, MdCheckCircleOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTable, flexRender, tableFeatures, rowSortingFeature, createSortedRowModel, sortFns, columnSizingFeature, columnResizingFeature, rowPaginationFeature, createPaginatedRowModel } from '@tanstack/react-table'; import { enqueueSnackbar } from 'notistack'; import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Divider } from '@astryxdesign/core/Divider'; import { TextInput } from '@astryxdesign/core/TextInput'; import { TextArea } from '@astryxdesign/core/TextArea'; import { Selector } from '@astryxdesign/core/Selector'; import { Badge } from '@astryxdesign/core/Badge'; import { Pagination } from '@astryxdesign/core/Pagination'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import Loader from 'components/Loader'; import TableLoader from 'components/nearle_components/TableLoader'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import PageHeader from 'components/nearle_components/PageHeader'; import StatCard from 'components/nearle_components/StatCard'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi'; import { DT } from 'themes/dt/tokens'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives'; const BRAND = DT.brand; // TanStack Table v9 registers features explicitly — only sorting/resizing/ // pagination ship here (no row-selection or column-visibility, matching // orders.js/riders.js/Tenants.js's tables). const EXCEPTIONS_TABLE_FEATURES = tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel(), sortFns, columnSizingFeature, columnResizingFeature, rowPaginationFeature, paginatedRowModel: createPaginatedRowModel() }); const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; // Uppercase micro-label for the table's column headings — same convention // orders.js's/riders.js's/Tenants.js's tables use. const SectionLabel = ({ children }) => ( {children} ); const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable']; const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') })); const SEVERITIES = ['Low', 'Medium', 'High', 'Critical']; const SEVERITY_OPTIONS = SEVERITIES.map((s) => ({ value: s, label: s })); const SEVERITY_BADGE = { Low: 'neutral', Medium: 'yellow', High: 'orange', Critical: 'error' }; const RESOLVE_STATUS_OPTIONS = [ { value: 'Resolved', label: 'Resolved' }, { value: 'Closed', label: 'Closed' } ]; const emptyForm = { consignmentid: '', tripsheetid: '', hub: '', exceptiontype: 'Damaged', severity: 'Medium', description: '' }; const Exceptions = () => { const queryClient = useQueryClient(); const [searchword, setSearchword] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [form, setForm] = useState(emptyForm); const [resolveRow, setResolveRow] = useState(null); const [resolution, setResolution] = useState(''); const [resolveStatus, setResolveStatus] = useState('Resolved'); // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's // slices. const [sorting, setSorting] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [columnSizing, setColumnSizing] = useState({}); const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions }); const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]); const hubOptions = useMemo(() => (hubs || []).map((h) => ({ value: String(h.hubid), label: h.hubname || `Hub #${h.hubid}` })), [hubs]); const rows = useMemo(() => { if (!debouncedSearch) return exceptions; const q = debouncedSearch.toLowerCase().trim(); return exceptions.filter((row) => [row.exceptiontype, row.severity, row.description, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)) ); }, [exceptions, debouncedSearch]); const stats = useMemo(() => { const total = exceptions.length; const open = exceptions.filter((e) => !['Resolved', 'Closed'].includes(e.status)).length; const critical = exceptions.filter((e) => e.severity === 'Critical').length; const resolved = exceptions.filter((e) => ['Resolved', 'Closed'].includes(e.status)).length; return { total, open, critical, resolved }; }, [exceptions]); const opentoast = (message, variant = 'error') => enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 }); const createMutation = useMutation({ mutationFn: (data) => createException(data), onSuccess: (res) => { if (res.success) { opentoast('Exception logged', 'success'); queryClient.invalidateQueries({ queryKey: ['admin-exceptions'] }); setDialogOpen(false); setForm(emptyForm); } else { opentoast(res.message || 'Failed to log exception'); } }, onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to log exception') }); const resolveMutation = useMutation({ mutationFn: () => updateExceptionStatus(resolveRow.exceptionid, resolveStatus, resolution), onSuccess: (res) => { if (res.success) { opentoast('Exception updated', 'success'); queryClient.invalidateQueries({ queryKey: ['admin-exceptions'] }); setResolveRow(null); setResolution(''); } else { opentoast(res.message || 'Failed to update exception'); } }, onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update exception') }); const handleCreate = () => { if (!form.consignmentid || !form.description) { opentoast('Fill Consignment ID and Description'); return; } createMutation.mutate({ consignmentid: form.consignmentid, tripsheetid: form.tripsheetid || undefined, hubid: form.hub ? Number(form.hub) : undefined, exceptiontype: form.exceptiontype, severity: form.severity, description: form.description }); }; const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); // The column model — everything a column IS declared once here instead of // a hardcoded header row + hand-written body row, same shape as orders.js's // / riders.js's / Tenants.js's `columns`. const columns = useMemo( () => [ { id: 'serial', size: 44, minSize: 36, maxSize: 52, enableSorting: false, enableResizing: false, meta: { label: '#' }, header: () => #, cell: ({ table: t, row }) => ( {pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1} ) }, { id: 'consignment', accessorKey: 'consignmentid', size: 170, minSize: 130, meta: { label: 'Consignment' }, header: () => Consignment, cell: ({ getValue }) => {getValue()} }, { id: 'hub', accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'), size: 150, minSize: 120, meta: { label: 'Hub' }, header: () => Hub, cell: ({ getValue }) => {getValue()} }, { id: 'type', accessorKey: 'exceptiontype', size: 150, minSize: 120, meta: { label: 'Type' }, header: () => Type, cell: ({ getValue }) => {getValue()} }, { id: 'severity', accessorKey: 'severity', size: 130, minSize: 110, meta: { label: 'Severity' }, header: () => Severity, cell: ({ getValue }) => }, { id: 'description', accessorKey: 'description', size: 260, minSize: 180, meta: { label: 'Description' }, header: () => Description, cell: ({ getValue }) => ( {getValue()} ) }, { id: 'status', accessorFn: (row) => row.status || 'Open', size: 130, minSize: 110, meta: { label: 'Status' }, header: () => Status, cell: ({ getValue }) => {getValue()} }, { id: 'actions', size: 140, minSize: 110, enableSorting: false, enableResizing: false, meta: { label: 'Actions' }, header: () => Actions, cell: ({ row }) => !['Resolved', 'Closed'].includes(row.original.status) ? ( } onClick={() => setResolveRow(row.original)} /> ) : null } ], [hubMap, pagination.pageIndex, pagination.pageSize] ); const table = useTable({ features: EXCEPTIONS_TABLE_FEATURES, // `rows` is already useMemo'd above (stable reference across renders) — // required for TanStack's row-model memoization to hold and for // pagination clicks to stick (an unmemoized array reads as "data // changed" on every render and auto-resets to page 1). data: rows, columns, getRowId: (row, index) => String(row.exceptionid ?? `row-${index}`), columnResizeMode: 'onChange', state: { sorting, pagination, columnSizing }, onSortingChange: setSorting, onPaginationChange: setPagination, onColumnSizingChange: setColumnSizing }); // Skeleton rows and the empty state span exactly the columns that are // actually rendered. Read off `columns.length` directly (not // `table.getVisibleLeafColumns()`): that method only exists when // columnVisibilityFeature is registered, which this table doesn't need. const colCount = columns.length; return ( <> {(isLoading || createMutation.isPending || resolveMutation.isPending) && } } onClick={() => setDialogOpen(true)} />} /> } color={BRAND} /> } color="#f59e0b" /> } color="#ef4444" /> } color="#10b981" /> {exceptions.length} total · {rows.length} shown } > {/* table-layout: fixed makes the declared column widths authoritative — same reasoning as orders.js's/riders.js's/ Tenants.js's tables. Header and body are both rendered FROM the column model, so they cannot drift apart. */} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const align = header.column.columnDef.meta?.align; const canSort = header.column.getCanSort(); const sorted = header.column.getIsSorted(); return ( { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); header.column.toggleSorting(); } } : undefined } style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined} > {flexRender(header.column.columnDef.header, header.getContext())} {canSort && (sorted === 'asc' ? ( ) : sorted === 'desc' ? ( ) : ( ))} {header.column.getCanResize() && (
{rows.length > 0 && ( <> {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( (pagination.pageIndex + 1) * pagination.pageSize, rows.length )} of ${rows.length} exception${rows.length === 1 ? '' : 's'}`} table.setPageIndex(nextPage - 1)} totalItems={rows.length} pageSize={pagination.pageSize} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={(size) => table.setPageSize(size)} size="sm" label="Exceptions pagination" /> )}
!next && setDialogOpen(false)} width={640} purpose="form"> !next && setDialogOpen(false)} startContent={ } /> } content={