updates on the ui changes and changed into doormile
This commit is contained in:
434
src/pages/doormile/exceptions/Exceptions.jsx
Normal file
434
src/pages/doormile/exceptions/Exceptions.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, ClipboardList, Flame, Plus } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Field, Grid, Input, KpiCard, Modal, PageHeader,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, StatusBadge,
|
||||
Textarea,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Exceptions — the register of things that went wrong with a consignment.
|
||||
*
|
||||
* An exception is raised against a consignment, not a booking: it describes
|
||||
* something that happened to a parcel already in the network, which is why the
|
||||
* form asks for a consignment id rather than an order number.
|
||||
*/
|
||||
|
||||
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
|
||||
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
|
||||
|
||||
/** The only two states this endpoint will write. */
|
||||
const RESOLVE_STATUSES = ['Resolved', 'Closed'];
|
||||
|
||||
const SEVERITY_TONE = { low: 'neutral', medium: 'warning', high: 'destructive', critical: 'destructive' };
|
||||
|
||||
const isClosed = (row) => ['resolved', 'closed'].includes(String(row?.status || '').toLowerCase());
|
||||
|
||||
const EMPTY_FORM = {
|
||||
consignmentid: '',
|
||||
tripsheetid: '',
|
||||
hubid: '',
|
||||
exceptiontype: 'Damaged',
|
||||
severity: 'Medium',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const NO_HUB = 'none';
|
||||
|
||||
export default function Exceptions() {
|
||||
const { data: exceptions = [], isLoading, isFetching } = useExceptions();
|
||||
const { data: hubs = [] } = useHubs();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [resolveRow, setResolveRow] = useState(null);
|
||||
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
const createException = useCreateException();
|
||||
const updateStatus = useUpdateExceptionStatus();
|
||||
|
||||
const hubMap = useMemo(() => new Map((hubs || []).map((hub) => [hub.hubid, hub])), [hubs]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = exceptions.length;
|
||||
return {
|
||||
total,
|
||||
open: exceptions.filter((row) => !isClosed(row)).length,
|
||||
critical: exceptions.filter((row) => String(row.severity || '').toLowerCase() === 'critical').length,
|
||||
resolved: exceptions.filter(isClosed).length,
|
||||
};
|
||||
}, [exceptions]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = exceptions;
|
||||
if (activeFilter === 'open') list = list.filter((row) => !isClosed(row));
|
||||
else if (activeFilter === 'critical') {
|
||||
list = list.filter((row) => String(row.severity || '').toLowerCase() === 'critical');
|
||||
} else if (activeFilter === 'resolved') list = list.filter(isClosed);
|
||||
|
||||
return list.filter((row) =>
|
||||
matchesQuery(row, ['consignmentid', 'exceptiontype', 'description', 'severity', 'status'], debouncedSearch)
|
||||
);
|
||||
}, [exceptions, activeFilter, debouncedSearch]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const logException = () => {
|
||||
const nextErrors = {};
|
||||
if (!String(form.consignmentid).trim()) nextErrors.consignmentid = 'Enter the consignment id';
|
||||
if (!form.description.trim()) nextErrors.description = 'Describe what happened';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
createException.mutate(
|
||||
{
|
||||
consignmentid: form.consignmentid,
|
||||
/* Omitted rather than sent empty: the server treats a missing optional
|
||||
key as "not applicable" and an empty string as a bad value. */
|
||||
tripsheetid: form.tripsheetid || undefined,
|
||||
hubid: form.hubid ? Number(form.hubid) : undefined,
|
||||
exceptiontype: form.exceptiontype,
|
||||
severity: form.severity,
|
||||
description: form.description,
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && setDialogOpen(false) }
|
||||
);
|
||||
};
|
||||
|
||||
const openResolve = (row) => {
|
||||
setResolveRow(row);
|
||||
setResolveStatus('Resolved');
|
||||
setResolution('');
|
||||
};
|
||||
|
||||
const submitResolve = () => {
|
||||
updateStatus.mutate(
|
||||
{ id: resolveRow.exceptionid ?? resolveRow.id, status: resolveStatus, resolution },
|
||||
{ onSuccess: (res) => res?.success !== false && setResolveRow(null) }
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'consignmentid',
|
||||
header: 'Consignment & hub',
|
||||
sortable: true,
|
||||
cell: (row) => {
|
||||
const hub = hubMap.get(row.hubid);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">#{orDash(row.consignmentid)}</p>
|
||||
<p className="truncate text-caption text-ink-3">
|
||||
{hub?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'exceptiontype',
|
||||
header: 'Exception',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">
|
||||
{String(row.exceptiontype || 'Exception').replace(/_/g, ' ')}
|
||||
</p>
|
||||
{row.description ? (
|
||||
<p className="truncate text-caption text-ink-3" title={row.description}>
|
||||
{row.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<StatusBadge
|
||||
status={row.severity}
|
||||
tone={SEVERITY_TONE[String(row.severity || '').toLowerCase()] || 'neutral'}
|
||||
dot
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status || 'Open'} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'createdat',
|
||||
header: 'Raised',
|
||||
sortable: true,
|
||||
accessor: (row) => row.createdat,
|
||||
cell: (row) => (
|
||||
<span className="text-body-sm text-ink-2">{formatDoormileTimestamp(row.createdat, 'DD MMM, hh:mm A')}</span>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) =>
|
||||
isClosed(row) ? (
|
||||
<span className="text-caption text-ink-4">Closed</span>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
|
||||
Resolve
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[hubMap]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={AlertTriangle}
|
||||
title="Exceptions"
|
||||
subtitle="Parcels that need a decision before they can move again"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Log exception
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={ClipboardList}
|
||||
value={stats.total}
|
||||
label="Total raised"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={AlertTriangle}
|
||||
value={stats.open}
|
||||
label={`Open · ${percentOf(stats.open, stats.total)}%`}
|
||||
tone="warning"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('open')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Flame}
|
||||
value={stats.critical}
|
||||
label="Critical severity"
|
||||
tone="navy"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('critical')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={stats.resolved}
|
||||
label={`Resolved · ${percentOf(stats.resolved, stats.total)}%`}
|
||||
tone="success"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('resolved')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by consignment, type or description"
|
||||
count={{ shown: rows.length, total: exceptions.length, noun: 'exceptions' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.exceptionid ?? row.id}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'createdat', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title="Nothing has gone wrong"
|
||||
description="No exceptions have been raised against a consignment."
|
||||
action={{ label: 'Log exception', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="Log an exception"
|
||||
description="Raised against a consignment — a parcel already moving through the network."
|
||||
icon={AlertTriangle}
|
||||
size="lg"
|
||||
busy={createException.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={logException} disabled={createException.isPending}>
|
||||
Log exception
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Consignment id" required error={errors.consignmentid}>
|
||||
<Input
|
||||
value={form.consignmentid}
|
||||
onChange={(e) => set('consignmentid')(e.target.value)}
|
||||
placeholder="40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tripsheet id" hint="Only if the parcel was on a hub-to-hub trip">
|
||||
<Input value={form.tripsheetid} onChange={(e) => set('tripsheetid')(e.target.value)} placeholder="Optional" />
|
||||
</Field>
|
||||
|
||||
<Field label="Exception type">
|
||||
<Select value={form.exceptiontype} onValueChange={set('exceptiontype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXCEPTION_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Severity">
|
||||
<Select value={form.severity} onValueChange={set('severity')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITIES.map((severity) => (
|
||||
<SelectItem key={severity} value={severity}>
|
||||
{severity}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Hub" className="sm:col-span-2">
|
||||
<Select
|
||||
value={form.hubid === '' ? NO_HUB : String(form.hubid)}
|
||||
onValueChange={(value) => set('hubid')(value === NO_HUB ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not at a hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_HUB}>Not at a hub</SelectItem>
|
||||
{(hubs || []).map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="What happened" required error={errors.description} className="sm:col-span-2">
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={form.description}
|
||||
onChange={(e) => set('description')(e.target.value)}
|
||||
placeholder="Outer box crushed on arrival at the sorting centre; contents appear intact."
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(resolveRow)}
|
||||
onOpenChange={(open) => !open && setResolveRow(null)}
|
||||
title="Close this exception"
|
||||
description={`Consignment #${resolveRow?.consignmentid ?? ''} — ${String(resolveRow?.exceptiontype || '').replace(/_/g, ' ')}`}
|
||||
icon={CheckCircle2}
|
||||
busy={updateStatus.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setResolveRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitResolve} disabled={updateStatus.isPending}>
|
||||
Update exception
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
<Field label="Outcome">
|
||||
<Select value={resolveStatus} onValueChange={setResolveStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RESOLVE_STATUSES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Resolution" hint="What was done, and who it was agreed with">
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
placeholder="Repacked and re-dispatched on tripsheet 118; customer notified."
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user