574 lines
24 KiB
JavaScript
574 lines
24 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 { 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;
|