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() && (
)}
);
})}
))}
{isLoading && }
{rows.length === 0 && !isLoading ? (
}
/>
) : (
table.getRowModel().rows.map((tableRow) => (
{tableRow.getAllCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
))
)}
{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"
/>
>
)}
>
);
};
export default Exceptions;