Files
Doormilexpress_console/src/pages/nearle/exceptions/exceptions.js

574 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
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: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'consignment',
accessorKey: 'consignmentid',
size: 170,
minSize: 130,
meta: { label: 'Consignment' },
header: () => <SectionLabel>Consignment</SectionLabel>,
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
},
{
id: 'hub',
accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'),
size: 150,
minSize: 120,
meta: { label: 'Hub' },
header: () => <SectionLabel>Hub</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text>
},
{
id: 'type',
accessorKey: 'exceptiontype',
size: 150,
minSize: 120,
meta: { label: 'Type' },
header: () => <SectionLabel>Type</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text>
},
{
id: 'severity',
accessorKey: 'severity',
size: 130,
minSize: 110,
meta: { label: 'Severity' },
header: () => <SectionLabel>Severity</SectionLabel>,
cell: ({ getValue }) => <Badge variant={SEVERITY_BADGE[getValue()] || 'neutral'} label={getValue()} />
},
{
id: 'description',
accessorKey: 'description',
size: 260,
minSize: 180,
meta: { label: 'Description' },
header: () => <SectionLabel>Description</SectionLabel>,
cell: ({ getValue }) => (
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
{getValue()}
</Text>
)
},
{
id: 'status',
accessorFn: (row) => row.status || 'Open',
size: 130,
minSize: 110,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text>
},
{
id: 'actions',
size: 140,
minSize: 110,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) =>
!['Resolved', 'Closed'].includes(row.original.status) ? (
<HStack justify="end">
<IconButton
label="Resolve / Close"
tooltip="Resolve / Close"
size="sm"
variant="ghost"
icon={<MdCheckCircleOutline size={16} />}
onClick={() => setResolveRow(row.original)}
/>
</HStack>
) : 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) && <Loader />}
<PageShell>
<PageHeader
title="Exceptions"
subtitle="Live · Lost / Damaged / Misrouted Parcels"
live
action={<Button label="Log Exception" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
/>
<KpiGrid>
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
<StatCard title="Resolved" value={stats.resolved} icon={<MdCheckCircleOutline size={20} />} color="#10b981" />
</KpiGrid>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack vAlign="center">
<Text weight="bold">
{exceptions.length} total · {rows.length} shown
</Text>
</HStack>
<HStack justify="end">
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search exceptions (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
<TableScroll minWidth={table.getTotalSize()}>
{/* 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
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Exceptions"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} isHeaderRow>
{headerGroup.headers.map((header) => {
const align = header.column.columnDef.meta?.align;
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
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' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={colCount}>
<EmptyState
title="No exceptions logged"
description="Log lost, damaged or misrouted parcels here to track resolution."
icon={<MdWarningAmber size={28} />}
/>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((tableRow) => (
<TableRow key={tableRow.id}>
{tableRow.getAllCells().map((cell) => (
<TableCell
key={cell.id}
style={{
textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} exception${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Exceptions pagination"
/>
</HStack>
</>
)}
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form">
<Layout
header={
<DialogHeader
title="Log Exception"
subtitle="Record a lost, damaged or misrouted parcel"
onOpenChange={(next) => !next && setDialogOpen(false)}
startContent={
<AccentAvatar color={BRAND} selected size={36}>
<MdWarningAmber size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<TextInput label="Consignment ID" value={form.consignmentid} onChange={set('consignmentid')} isRequired />
<TextInput label="Tripsheet ID" value={form.tripsheetid} onChange={set('tripsheetid')} isOptional />
<Selector
label="Hub"
options={hubOptions}
value={form.hub}
onChange={set('hub')}
placeholder="Choose hub"
hasSearch
isOptional
/>
<Selector label="Type" options={EXCEPTION_TYPE_OPTIONS} value={form.exceptiontype} onChange={set('exceptiontype')} />
<Selector label="Severity" options={SEVERITY_OPTIONS} value={form.severity} onChange={set('severity')} />
</Grid>
<TextArea label="Description" value={form.description} onChange={set('description')} isRequired />
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={createMutation.isPending} />
<Button label="Log Exception" variant="primary" onClick={handleCreate} isLoading={createMutation.isPending} />
</HStack>
</LayoutFooter>
}
/>
</Dialog>
<Dialog isOpen={!!resolveRow} onOpenChange={(next) => !next && setResolveRow(null)} width={480} purpose="form">
<Layout
header={
<DialogHeader
title="Resolve Exception"
subtitle={resolveRow ? `Consignment ${resolveRow.consignmentid}` : ''}
onOpenChange={(next) => !next && setResolveRow(null)}
startContent={
<AccentAvatar color="#10b981" selected size={36}>
<MdCheckCircleOutline size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Selector label="Status" options={RESOLVE_STATUS_OPTIONS} value={resolveStatus} onChange={setResolveStatus} />
<TextArea label="Resolution Notes" value={resolution} onChange={setResolution} isOptional />
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setResolveRow(null)} isDisabled={resolveMutation.isPending} />
<Button label="Save" variant="primary" onClick={() => resolveMutation.mutate()} isLoading={resolveMutation.isPending} />
</HStack>
</LayoutFooter>
}
/>
</Dialog>
</>
);
};
export default Exceptions;