updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View 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>
);
}