Files
Doormilexpress_console/src/pages/nearle/exceptions/exceptions.js
dharaneesh-r 414ba6dbf2 Fix assistant assign flow, cache staleness, and sweep KPI gloss lines
Assistant — assign:
- Order step is a dropdown, not a typed booking number. Exact bookingid /
  bookingno matches now win across the whole list before any substring
  fallback; row order previously decided the winner, so a short numeric id
  could substring-match a different booking than the one on screen.
- Rider step resolves by id or name, so answering by typing no longer
  leaves __rider undefined and throws inside executeAssign.
- notified now reflects whether the push actually went out. It was
  Boolean(rider.milerprofileid) — "has an id, assume they were told" — so a
  notify returning 400 still reported "The rider has been notified."
- Batch assign removed. /hub/bookings/batch-assign is a hub-console
  endpoint; an admin JWT is refused (403 on an empty body, so the gate is
  on identity). It read as the fast path and could never run.

Assistant — repeat runs:
- Bulk create response is read at data.results, the shape the live endpoint
  returns. Only the two flatter shapes were checked, so every bookingid was
  discarded while the count came out right by accident.
- Repeat carries each order's previous rider and re-assigns on create, one
  admin call per order, one push per rider. Confirm cards say so.
- Message ids are clock-seeded and healed on load; the counter restarted at
  1 each session and overwrote itself, giving duplicate React keys.
- Successful writes invalidate the query cache, so the page behind the panel
  stops serving rows cached before the write.

Date range picker:
- Fields seed from the range already in force instead of opening blank on a
  filtered table.
- Start field takes max={end}; only the end had a bound, so an inverted
  range could be emitted and filtered to nothing.

UI:
- Deliveries filters moved outside the table card.
- Side nav keeps one hairline; the border and box-shadow stacked into a
  heavy edge.
- KPI gloss lines removed across orders, riders, tenants, customers, hubs,
  vehicles, exceptions, app users, orders summary/details, riders summary
  and profitability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:51:32 +05:30

603 lines
25 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 { Tooltip } from '@astryxdesign/core/Tooltip';
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 StatusBadge from 'components/nearle_components/StatusBadge';
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: 200,
minSize: 150,
meta: { label: 'Consignment & Hub' },
header: () => <SectionLabel>Consignment & Hub</SectionLabel>,
cell: ({ row }) => {
const hub = hubMap.get(row.original.hubid)?.hubname || (row.original.hubid ? `Hub #${row.original.hubid}` : '—');
return (
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="bold" style={{ fontSize: 13, color: DT.textPrimary }}>
#{row.original.consignmentid}
</Text>
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
{hub}
</Text>
</VStack>
);
}
},
{
id: 'type',
accessorKey: 'exceptiontype',
size: 280,
minSize: 200,
meta: { label: 'Exception Details' },
header: () => <SectionLabel>Exception Details</SectionLabel>,
cell: ({ row }) => (
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
{row.original.exceptiontype || 'Exception'}
</Text>
{row.original.description && (
<Tooltip content={row.original.description}>
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
{row.original.description}
</Text>
</Tooltip>
)}
</VStack>
)
},
{
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: 'status',
accessorFn: (row) => row.status || 'Open',
size: 130,
minSize: 110,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => <StatusBadge status={getValue()} />
},
{
id: 'actions',
size: 90,
minSize: 80,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
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}
meta={`${stats.total} logged`}
icon={<MdWarningAmber size={20} />}
color={BRAND}
/>
<StatCard
title="Open"
value={stats.open}
meta={`${stats.total > 0 ? Math.round((stats.open / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdWarningAmber size={20} />}
color="#f59e0b"
/>
<StatCard
title="Critical"
value={stats.critical}
meta={`${stats.total > 0 ? Math.round((stats.critical / stats.total) * 100) : 0}% of ${stats.total}`}
icon={<MdWarningAmber size={20} />}
color="#ef4444"
/>
<StatCard
title="Resolved"
value={stats.resolved}
meta={`${stats.total > 0 ? Math.round((stats.resolved / stats.total) * 100) : 0}% of ${stats.total}`}
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;