updates on the reverse logistics
This commit is contained in:
@@ -277,3 +277,24 @@ New env vars: `RTO_AUTO_AFTER_ATTEMPTS` (default 3), `MILER_RTO_FLOW_ENABLED`
|
|||||||
"Raised" times about 6 h ahead of IST.
|
"Raised" times about 6 h ahead of IST.
|
||||||
- Not built: Phase 4 (return-to-hub, timeline, reports, charges), Phase 5
|
- Not built: Phase 4 (return-to-hub, timeline, reports, charges), Phase 5
|
||||||
(customer returns), the rider-app UI (Flutter team).
|
(customer returns), the rider-app UI (Flutter team).
|
||||||
|
|
||||||
|
## 11. Phase 4, first part (2026-10-07)
|
||||||
|
|
||||||
|
Built the two Phase 4 items that need no product decision. Uncommitted.
|
||||||
|
|
||||||
|
| Item | Where |
|
||||||
|
|---|---|
|
||||||
|
| `GET /admin/consignments/:id/history`: every event of one parcel, oldest first, with who did it and at which base. The `[from:<status>]` bookkeeping tag is removed from the remark and returned as `fromstatus`. Client logins read their own parcels only. | `doormile_backend/controllers/returnInsights.go`, `routes/routes.go` |
|
||||||
|
| `GET /admin/returns/summary?from&to`: of the parcels created in the period (cancelled ones excluded), the return rate overall, per client and per reason, plus the average days a completed return took. Defaults to the last 30 days. Client logins get their own figures only. | same |
|
||||||
|
| **C6 timeline:** shared `ConsignmentTimeline`, shown in the Deliveries order drawer and behind a **History** button on every Returns row (clients included). | `src/components/doormile/ConsignmentTimeline.jsx`, `Deliveries.jsx`, `Returns.jsx` |
|
||||||
|
| **C7 report:** a summary on the **Returns page** (KPIs, return rate by client, reasons), driven by the page's date range. Clients don't get the per-client table. Placed on Returns rather than Orders Summary so that clients see it too. | `src/pages/doormile/returns/Returns.jsx` |
|
||||||
|
| Tests: 2 Postgres route tests (timeline order, actor, hub, scoping; summary rates, reasons, window, scoping) and 4 console tests. | `routes/routes_return_insights_pg_test.go`, `tests/integration/returns.test.jsx` |
|
||||||
|
|
||||||
|
Verified in the browser against a throwaway local database: summary figures, per-client and per-reason tables, and the timeline drawer.
|
||||||
|
|
||||||
|
Decision 2026-10-07 on return charges (open decision 5): **none for now.** Returns are free for every client. Revisit after 2–3 months using the return rates the Returns summary now shows. If a charge is introduced later, it should be per client, only for receiver- or client-caused returns, and never for damage or Doormile errors.
|
||||||
|
|
||||||
|
Still open in Phase 4 (needs a decision ⚑): return-to-hub (the `returnhubid` column).
|
||||||
|
|
||||||
|
Decision 2026-10-07 on Phase 5 (customer returns after delivery): **deferred.** Current clients are mainly food and medicine businesses, where returns after delivery are rare, and refunds are handled by the client. Build it when a client asks for it or when parcel/e-commerce clients are onboarded. Until then, ops handle the rare case by booking a normal order with the customer's address as pickup and the client as drop. The return button appears only on active orders (RTO, before delivery); `Delivered` stays terminal.
|
||||||
|
|
||||||
|
|||||||
@@ -902,3 +902,15 @@ export const getReturns = async (params = {}) => {
|
|||||||
const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`);
|
const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`);
|
||||||
return response.data;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
89
src/components/doormile/ConsignmentTimeline.jsx
Normal file
89
src/components/doormile/ConsignmentTimeline.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,6 +108,9 @@ const BOOKING_KEYS = [
|
|||||||
['doormile', 'bookings'],
|
['doormile', 'bookings'],
|
||||||
['doormile', 'deliveries'],
|
['doormile', 'deliveries'],
|
||||||
['doormile', 'delivery-counts'],
|
['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 ──────────────────────────────────────────── */
|
/* ── Dashboard, reports, profile ──────────────────────────────────────────── */
|
||||||
@@ -557,6 +560,25 @@ export const useReturns = (params = {}, options) =>
|
|||||||
...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 = () =>
|
export const useInitiateRto = () =>
|
||||||
useDoormileMutation({
|
useDoormileMutation({
|
||||||
mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }),
|
mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
|||||||
import { summariseRouting } from '@/lib/routingSummary';
|
import { summariseRouting } from '@/lib/routingSummary';
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
|
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
|
||||||
|
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliveries — orders that have moved past merely being created.
|
* Deliveries — orders that have moved past merely being created.
|
||||||
@@ -910,6 +911,12 @@ function OrderDetailDrawer({ row, onClose }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{row?.notes ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-overline uppercase text-ink-4">Notes</p>
|
<p className="mb-2 text-overline uppercase text-ink-4">Notes</p>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import dayjs from 'dayjs';
|
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 {
|
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,
|
StatusBadge, Surface, Tabs,
|
||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
import { DateRangeFields } from '@/components/ds/DateRangeFields';
|
import { DateRangeFields } from '@/components/ds/DateRangeFields';
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
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 { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||||
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
|
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
|
||||||
|
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns — parcels going back to the sender (RTO) and those already back.
|
* 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'));
|
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() {
|
export default function Returns() {
|
||||||
const { isClient } = useAuth();
|
const { isClient } = useAuth();
|
||||||
const [status, setStatus] = useState('initiated');
|
const [status, setStatus] = useState('initiated');
|
||||||
@@ -46,6 +134,7 @@ export default function Returns() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const debouncedSearch = useDebouncedValue(search);
|
const debouncedSearch = useDebouncedValue(search);
|
||||||
const [resolve, setResolve] = useState({ row: null, mode: 'cancel' });
|
const [resolve, setResolve] = useState({ row: null, mode: 'cancel' });
|
||||||
|
const [historyRow, setHistoryRow] = useState(null);
|
||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({ status, from: range.from || undefined, to: range.to || undefined, pageno: page, pagesize: PAGE_SIZE }),
|
() => ({ 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: 'milername', header: 'Miler', accessor: (r) => orDash(r.milername) },
|
||||||
{ key: 'pincodes', header: 'Pickup → Drop', accessor: (r) => `${orDash(r.pickuppincode)} → ${orDash(r.deliverypincode)}` },
|
{ key: 'pincodes', header: 'Pickup → Drop', accessor: (r) => `${orDash(r.pickuppincode)} → ${orDash(r.deliverypincode)}` },
|
||||||
...(isClient
|
{
|
||||||
? []
|
key: 'actions',
|
||||||
: [
|
header: 'Actions',
|
||||||
{
|
align: 'right',
|
||||||
key: 'actions',
|
cell: (r) => (
|
||||||
header: 'Actions',
|
<div className="flex justify-end gap-1">
|
||||||
align: 'right',
|
<IconButton label="History" icon={History} variant="ghost" size="sm" onClick={() => setHistoryRow(r)} />
|
||||||
cell: (r) =>
|
{!isClient && r.status === 'RTO_Initiated' ? (
|
||||||
r.status === 'RTO_Initiated' ? (
|
<>
|
||||||
<div className="flex justify-end gap-1">
|
<IconButton
|
||||||
<IconButton
|
label="Re-attempt delivery"
|
||||||
label="Re-attempt delivery"
|
icon={RotateCcw}
|
||||||
icon={RotateCcw}
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setResolve({ row: r, mode: 'cancel' })}
|
||||||
onClick={() => setResolve({ row: r, mode: 'cancel' })}
|
/>
|
||||||
/>
|
<IconButton
|
||||||
<IconButton
|
label="Mark returned"
|
||||||
label="Mark returned"
|
icon={PackageCheck}
|
||||||
icon={PackageCheck}
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setResolve({ row: r, mode: 'complete' })}
|
||||||
onClick={() => setResolve({ row: r, mode: 'complete' })}
|
/>
|
||||||
/>
|
</>
|
||||||
</div>
|
) : null}
|
||||||
) : null,
|
</div>
|
||||||
},
|
),
|
||||||
]),
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const exportColumns = [
|
const exportColumns = [
|
||||||
@@ -166,6 +255,8 @@ export default function Returns() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReturnsSummary range={range} isClient={isClient} />
|
||||||
|
|
||||||
{isError && (
|
{isError && (
|
||||||
<Alert tone="destructive" role="alert" title="Returns could not be loaded">
|
<Alert tone="destructive" role="alert" title="Returns could not be loaded">
|
||||||
{error?.response?.status === 404
|
{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' })} />
|
<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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ jest.mock('@/api/doormile', () => ({
|
|||||||
cancelRto: jest.fn(),
|
cancelRto: jest.fn(),
|
||||||
completeRto: jest.fn(),
|
completeRto: jest.fn(),
|
||||||
getReturns: jest.fn(),
|
getReturns: jest.fn(),
|
||||||
|
getReturnsSummary: jest.fn(),
|
||||||
|
getConsignmentHistory: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@/api/doormile/notify', () => ({
|
jest.mock('@/api/doormile/notify', () => ({
|
||||||
@@ -54,6 +56,8 @@ beforeEach(() => {
|
|||||||
api.initiateRto.mockResolvedValue({ success: true, data: { started: true } });
|
api.initiateRto.mockResolvedValue({ success: true, data: { started: true } });
|
||||||
api.cancelRto.mockResolvedValue({ success: true, data: {} });
|
api.cancelRto.mockResolvedValue({ success: true, data: {} });
|
||||||
api.completeRto.mockResolvedValue({ success: true, data: {} });
|
api.completeRto.mockResolvedValue({ success: true, data: {} });
|
||||||
|
api.getReturnsSummary.mockResolvedValue({ success: true, data: { total: 0, by_client: [], by_reason: [] } });
|
||||||
|
api.getConsignmentHistory.mockResolvedValue({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('which rows may start or close a return', () => {
|
describe('which rows may start or close a return', () => {
|
||||||
@@ -172,7 +176,73 @@ describe('Returns page', () => {
|
|||||||
wrap(<Returns />);
|
wrap(<Returns />);
|
||||||
await screen.findByText('DMX00000061');
|
await screen.findByText('DMX00000061');
|
||||||
expect(screen.queryByRole('button', { name: 'Mark returned' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Mark returned' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Re-attempt delivery' })).not.toBeInTheDocument();
|
||||||
|
// A client may still read each parcel's history.
|
||||||
|
expect(screen.getAllByRole('button', { name: 'History' })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUMMARY = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total: 8, delivered: 4, in_return: 1, in_return_now: 2, returned: 3, return_rate: 50, avg_return_days: 1.5,
|
||||||
|
by_client: [
|
||||||
|
{ tenantid: 1, tenantname: 'Acme Foods', total: 6, in_return: 1, returned: 3, return_rate: 66.7 },
|
||||||
|
{ tenantid: 2, tenantname: 'Beta Co', total: 2, in_return: 0, returned: 0, return_rate: 0 },
|
||||||
|
],
|
||||||
|
by_reason: [
|
||||||
|
{ reason: 'Receiver refused', count: 3 },
|
||||||
|
{ reason: 'Address not found', count: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('summarises the return rate per client and per reason for the chosen period', async () => {
|
||||||
|
api.getReturns.mockResolvedValue(RETURNS);
|
||||||
|
api.getReturnsSummary.mockResolvedValue(SUMMARY);
|
||||||
|
wrap(<Returns />);
|
||||||
|
expect(await screen.findByText('Return rate by client')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('66.7%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1.5 d')).toBeInTheDocument();
|
||||||
|
// "In return now" counts every open return, not only parcels created in the window.
|
||||||
|
expect(screen.getByText('In return now').parentElement).toHaveTextContent('2');
|
||||||
|
expect(screen.getByText('Address not found')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Based on 8 parcels/)).toBeInTheDocument();
|
||||||
|
// Same window as the list.
|
||||||
|
const listParams = api.getReturns.mock.calls[0][0];
|
||||||
|
expect(api.getReturnsSummary.mock.calls[0][0]).toEqual({ from: listParams.from, to: listParams.to });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show a client the per-client table, only its own reasons', async () => {
|
||||||
|
mockAuth = { user: { email: 'ops@acme.test', role: 'manager', tenantid: 1 }, isClient: true };
|
||||||
|
api.getReturns.mockResolvedValue(RETURNS);
|
||||||
|
api.getReturnsSummary.mockResolvedValue(SUMMARY);
|
||||||
|
wrap(<Returns />);
|
||||||
|
expect(await screen.findByText('Why parcels came back')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Return rate by client')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a parcel's timeline from History", async () => {
|
||||||
|
api.getReturns.mockResolvedValue(RETURNS);
|
||||||
|
api.getConsignmentHistory.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{ historyid: 1, eventstatus: 'Out_for_Delivery', remarks: '', createdat: '2026-10-01T09:00:00+05:30' },
|
||||||
|
{ historyid: 2, eventstatus: 'Delivery_Skipped', remarks: 'Customer unavailable', createdat: '2026-10-01T09:30:00+05:30' },
|
||||||
|
{
|
||||||
|
historyid: 3, eventstatus: 'RTO_Initiated', remarks: 'Receiver refused: gate closed',
|
||||||
|
fromstatus: 'Out_for_Delivery', actorname: 'Ops Priya', createdat: '2026-10-01T10:00:00+05:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
wrap(<Returns />);
|
||||||
|
await screen.findByText('DMX00000061');
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: 'History' })[0]);
|
||||||
|
expect(await screen.findByText('Return to sender started')).toBeInTheDocument();
|
||||||
|
expect(api.getConsignmentHistory).toHaveBeenCalledWith(61);
|
||||||
|
expect(screen.getByText('Delivery attempt failed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Receiver refused: gate closed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/by Ops Priya · was out for delivery/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('says plainly when the backend has no returns yet', async () => {
|
it('says plainly when the backend has no returns yet', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user