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>
603 lines
25 KiB
JavaScript
603 lines
25 KiB
JavaScript
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;
|