updates on the reverse logistics

This commit is contained in:
2026-10-07 13:00:43 +05:30
parent 0d5edf94bf
commit ff1b795292
7 changed files with 354 additions and 32 deletions

View File

@@ -902,3 +902,15 @@ export const getReturns = async (params = {}) => {
const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`);
return response.data;
};
/** Return rates per client and per reason for a date range (GET /admin/returns/summary). */
export const getReturnsSummary = async (params = {}) => {
const response = await doormileAxios.get(`/admin/returns/summary${buildQuery(params)}`);
return response.data;
};
/** Every recorded event of one parcel, oldest first (GET /admin/consignments/:id/history). */
export const getConsignmentHistory = async (consignmentId) => {
const response = await doormileAxios.get(`/admin/consignments/${consignmentId}/history`);
return response.data;
};

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { useConsignmentHistory } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* ConsignmentTimeline — everything recorded about one parcel, oldest first:
* pickup, base hand-overs, failed attempts, a return starting, being
* re-attempted, or completed (reverse logistics plan, C6).
*
* Reads GET /admin/consignments/:id/history, which a client login may call for
* its own parcels only. The return actions invalidate this query, so the
* timeline updates as soon as ops act.
*/
/** Plain words for each status a history row can carry. */
export const EVENT_LABELS = {
Created: 'Parcel created',
Collected_By_Miler: 'Collected by the rider',
Inwarded_at_Hub: 'Received at the base',
Tripsheet_Loaded: 'Loaded for transfer',
In_Transit: 'In transit between bases',
Out_for_Delivery: 'Out for delivery',
Delivered: 'Delivered',
Delivery_Skipped: 'Delivery attempt failed',
RTO_Initiated: 'Return to sender started',
Returned_to_Sender: 'Returned to sender',
Missing: 'Marked missing',
Damaged: 'Marked damaged',
Cancelled: 'Cancelled',
};
const TONE = {
Delivered: 'bg-success',
Delivery_Skipped: 'bg-warning',
RTO_Initiated: 'bg-warning',
Returned_to_Sender: 'bg-brand',
Missing: 'bg-destructive',
Damaged: 'bg-destructive',
Cancelled: 'bg-ink-4',
};
export const eventLabel = (status) =>
EVENT_LABELS[status] || String(status || 'Update').replace(/_/g, ' ');
/** The secondary line: who or where, then any note. */
export function eventDetail(event) {
const parts = [];
if (event?.actorname) parts.push(`by ${event.actorname}`);
if (event?.hubname) parts.push(`at ${event.hubname}`);
// A re-attempt is recorded as the status the parcel went back to; say so.
if (event?.fromstatus && event.eventstatus === 'RTO_Initiated') {
parts.push(`was ${eventLabel(event.fromstatus).toLowerCase()}`);
}
return parts.join(' · ');
}
export default function ConsignmentTimeline({ consignmentId }) {
const { data, isLoading, isError } = useConsignmentHistory(consignmentId);
const events = Array.isArray(data?.data) ? data.data : [];
if (!consignmentId) {
return <p className="text-body-sm text-ink-3">No parcel yet — the timeline starts once the order is picked up.</p>;
}
if (isLoading) return <p className="text-body-sm text-ink-3">Loading timeline…</p>;
if (isError) return <p className="text-body-sm text-ink-3">The timeline could not be loaded.</p>;
if (!events.length) return <p className="text-body-sm text-ink-3">Nothing has been recorded for this parcel yet.</p>;
return (
<ol className="relative space-y-3 border-l border-line pl-4" aria-label="Parcel timeline">
{events.map((event) => {
const detail = eventDetail(event);
return (
<li key={event.historyid} className="relative">
<span
aria-hidden="true"
className={`absolute -left-[21px] top-1.5 h-2.5 w-2.5 rounded-full ring-2 ring-surface ${TONE[event.eventstatus] || 'bg-ink-3'}`}
/>
<p className="text-body-sm font-medium text-ink-1">{eventLabel(event.eventstatus)}</p>
<p className="text-caption text-ink-3">
{formatDoormileTimestamp(event.createdat)}
{detail ? ` · ${detail}` : ''}
</p>
{event.remarks ? <p className="text-caption text-ink-2">{event.remarks}</p> : null}
</li>
);
})}
</ol>
);
}

View File

@@ -108,6 +108,9 @@ const BOOKING_KEYS = [
['doormile', 'bookings'],
['doormile', 'deliveries'],
['doormile', 'delivery-counts'],
// A parcel's timeline (ConsignmentTimeline), so a status change made from
// Deliveries shows in an open order drawer at once.
['doormile', 'returns', 'history'],
];
/* ── Dashboard, reports, profile ──────────────────────────────────────────── */
@@ -557,6 +560,25 @@ export const useReturns = (params = {}, options) =>
...options,
});
/* Both live under the returns key, so every return action above refreshes them. */
export const useReturnsSummary = (params = {}, options) =>
useQuery({
queryKey: ['doormile', 'returns', 'summary', params],
queryFn: () => api.getReturnsSummary(params),
placeholderData: (previous) => previous,
staleTime: 60_000,
...options,
});
export const useConsignmentHistory = (consignmentId, options) =>
useQuery({
queryKey: ['doormile', 'returns', 'history', String(consignmentId)],
queryFn: () => api.getConsignmentHistory(consignmentId),
enabled: !!consignmentId,
staleTime: 15_000,
...options,
});
export const useInitiateRto = () =>
useDoormileMutation({
mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }),

View File

@@ -19,6 +19,7 @@ import { pickupSourceTypeLabel } from '@/lib/orderFlow';
import { summariseRouting } from '@/lib/routingSummary';
import { useAuth } from '@/lib/AuthContext';
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
/**
* Deliveries — orders that have moved past merely being created.
@@ -910,6 +911,12 @@ function OrderDetailDrawer({ row, onClose }) {
)}
</div>
{/* Attempts, base hand-overs and any return, in order. */}
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Timeline</p>
<ConsignmentTimeline consignmentId={row?.consignmentid} />
</div>
{row?.notes ? (
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Notes</p>

View File

@@ -1,15 +1,16 @@
import React, { useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { FileSpreadsheet, PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
import { Clock, FileSpreadsheet, History, PackageCheck, Percent, RotateCcw, Truck, Undo2 } from 'lucide-react';
import {
Alert, Button, DataTable, EmptyState, IconButton, PageHeader, Pagination, SearchInput, Stack,
Alert, Button, DataTable, Drawer, EmptyState, Grid, IconButton, KpiCard, PageHeader, Pagination, SearchInput, Stack,
StatusBadge, Surface, Tabs,
} from '@/components/ds';
import { DateRangeFields } from '@/components/ds/DateRangeFields';
import { useAuth } from '@/lib/AuthContext';
import { useReturns } from '@/lib/doormileHooks';
import { useReturns, useReturnsSummary } from '@/lib/doormileHooks';
import { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
/**
* Returns — parcels going back to the sender (RTO) and those already back.
@@ -38,6 +39,93 @@ export function returnAgeDays(row, now = dayjs()) {
return Math.max(0, end.diff(dayjs(row.returninitiatedat), 'day'));
}
/** "12.5%" — a rate the server already rounded to one decimal. */
const pct = (n) => `${Number(n) || 0}%`;
/**
* How often parcels come back, and why (reverse logistics plan, C7). Of the
* parcels created in the selected period, the share that went into a return,
* per client and per reason. A client login gets its own figures only, so the
* per-client table is left out for it.
*/
export function ReturnsSummary({ range, isClient }) {
const { data, isLoading, isError } = useReturnsSummary({ from: range.from || undefined, to: range.to || undefined });
const s = data?.data;
if (isError) return null; // the list below already reports a server problem
const clients = s?.by_client || [];
const reasons = s?.by_reason || [];
return (
<Stack space="default">
<Grid cols={4}>
<KpiCard icon={Percent} value={pct(s?.return_rate)} label="Return rate" tone="warning" index={0} loading={isLoading} />
<KpiCard icon={Truck} value={s?.in_return_now ?? s?.in_return ?? 0} label="In return now" tone="accent" index={1} loading={isLoading} />
<KpiCard icon={PackageCheck} value={s?.returned ?? 0} label="Returned" tone="navy" index={2} loading={isLoading} />
<KpiCard
icon={Clock}
value={s?.avg_return_days == null ? '—' : `${s.avg_return_days} d`}
label="Average time to return"
tone="neutral"
index={3}
loading={isLoading}
/>
</Grid>
{!isLoading && s?.total > 0 && (
<div className={`grid grid-cols-1 gap-4 ${isClient ? '' : 'lg:grid-cols-2'}`}>
{!isClient && (
<Surface variant="subtle" padding="sm" radius="lg">
<p className="mb-2 text-overline uppercase text-ink-4">Return rate by client</p>
<table className="w-full text-body-sm">
<thead>
<tr className="text-caption uppercase tracking-wide text-ink-3">
<th className="pb-1 text-left font-medium">Client</th>
<th className="pb-1 text-right font-medium">Parcels</th>
<th className="pb-1 text-right font-medium">Returns</th>
<th className="pb-1 text-right font-medium">Rate</th>
</tr>
</thead>
<tbody>
{clients.map((c) => (
<tr key={c.tenantid} className="border-t border-line">
<td className="py-1.5 text-ink-1">{orDash(c.tenantname)}</td>
<td className="py-1.5 text-right text-ink-2">{c.total}</td>
<td className="py-1.5 text-right text-ink-2">{c.in_return + c.returned}</td>
<td className="py-1.5 text-right font-semibold text-ink-1">{pct(c.return_rate)}</td>
</tr>
))}
</tbody>
</table>
</Surface>
)}
<Surface variant="subtle" padding="sm" radius="lg">
<p className="mb-2 text-overline uppercase text-ink-4">Why parcels came back</p>
{reasons.length === 0 ? (
<p className="text-body-sm text-ink-3">No returns in this period.</p>
) : (
<ul className="space-y-1.5">
{reasons.map((r) => (
<li key={r.reason} className="flex items-center justify-between gap-3 text-body-sm">
<span className="text-ink-1">{r.reason}</span>
<span className="font-semibold text-ink-1">{r.count}</span>
</li>
))}
</ul>
)}
</Surface>
</div>
)}
{!isLoading && s && (
<p className="text-caption text-ink-3">
Based on {s.total} parcel{s.total === 1 ? '' : 's'} created in this period (cancelled ones excluded).
</p>
)}
</Stack>
);
}
export default function Returns() {
const { isClient } = useAuth();
const [status, setStatus] = useState('initiated');
@@ -46,6 +134,7 @@ export default function Returns() {
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search);
const [resolve, setResolve] = useState({ row: null, mode: 'cancel' });
const [historyRow, setHistoryRow] = useState(null);
const params = useMemo(
() => ({ status, from: range.from || undefined, to: range.to || undefined, pageno: page, pagesize: PAGE_SIZE }),
@@ -86,34 +175,34 @@ export default function Returns() {
},
{ key: 'milername', header: 'Miler', accessor: (r) => orDash(r.milername) },
{ key: 'pincodes', header: 'Pickup → Drop', accessor: (r) => `${orDash(r.pickuppincode)} → ${orDash(r.deliverypincode)}` },
...(isClient
? []
: [
{
key: 'actions',
header: 'Actions',
align: 'right',
cell: (r) =>
r.status === 'RTO_Initiated' ? (
<div className="flex justify-end gap-1">
<IconButton
label="Re-attempt delivery"
icon={RotateCcw}
variant="ghost"
size="sm"
onClick={() => setResolve({ row: r, mode: 'cancel' })}
/>
<IconButton
label="Mark returned"
icon={PackageCheck}
variant="ghost"
size="sm"
onClick={() => setResolve({ row: r, mode: 'complete' })}
/>
</div>
) : null,
},
]),
{
key: 'actions',
header: 'Actions',
align: 'right',
cell: (r) => (
<div className="flex justify-end gap-1">
<IconButton label="History" icon={History} variant="ghost" size="sm" onClick={() => setHistoryRow(r)} />
{!isClient && r.status === 'RTO_Initiated' ? (
<>
<IconButton
label="Re-attempt delivery"
icon={RotateCcw}
variant="ghost"
size="sm"
onClick={() => setResolve({ row: r, mode: 'cancel' })}
/>
<IconButton
label="Mark returned"
icon={PackageCheck}
variant="ghost"
size="sm"
onClick={() => setResolve({ row: r, mode: 'complete' })}
/>
</>
) : null}
</div>
),
},
];
const exportColumns = [
@@ -166,6 +255,8 @@ export default function Returns() {
</div>
</div>
<ReturnsSummary range={range} isClient={isClient} />
{isError && (
<Alert tone="destructive" role="alert" title="Returns could not be loaded">
{error?.response?.status === 404
@@ -204,6 +295,16 @@ export default function Returns() {
)}
<ResolveRtoModal row={resolve.row} mode={resolve.mode} onClose={() => setResolve({ row: null, mode: 'cancel' })} />
<Drawer
open={Boolean(historyRow)}
onOpenChange={(next) => !next && setHistoryRow(null)}
title={`Parcel ${historyRow?.trackingno || ''}`}
description={[historyRow?.tenantname, historyRow?.returnreason].filter(Boolean).join(' · ') || undefined}
icon={History}
>
<ConsignmentTimeline consignmentId={historyRow?.consignmentid} />
</Drawer>
</Stack>
);
}