updates on the reverse logistics

This commit is contained in:
2026-10-06 17:30:06 +05:30
parent 806f4a2b3f
commit 6e73b59ca9
12 changed files with 1069 additions and 21 deletions

View File

@@ -25,6 +25,7 @@ const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries'));
const Returns = lazy(() => import('@/pages/doormile/returns/Returns'));
const Tenants = lazy(() => import('@/pages/doormile/clients/Tenants'));
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
@@ -122,6 +123,8 @@ const AuthenticatedApp = () => {
<Route path="bookings" element={<Bookings />} />
<Route path="deliveries" element={<Deliveries />} />
{/* Reverse logistics: parcels going back to the sender (RTO). */}
<Route path="returns" element={<Returns />} />
<Route path="tenants" element={<Tenants />} />
<Route path="clients/create" element={<CreateClient />} />

View File

@@ -859,3 +859,46 @@ export const getAiDecisions = async ({ type, before, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);
return response.data.data;
};
/* ── Reverse logistics (RTO) ─────────────────────────────────────────────────
Start, cancel (re-attempt delivery) and close a return to sender; list
returns. Start/cancel/close are Doormile-staff actions (a client login gets
403); a client login may read its own returns. Plan:
docs/reverse-logistics-plan.md. */
/** Reasons the backend accepts for starting a return. */
export const RTO_REASONS = [
{ value: 'receiver_refused', label: 'Receiver refused' },
{ value: 'address_not_found', label: 'Address not found' },
{ value: 'customer_unavailable', label: 'Customer unavailable' },
{ value: 'attempts_exhausted', label: 'Delivery attempts exhausted' },
{ value: 'damaged', label: 'Damaged in transit' },
{ value: 'other', label: 'Other (describe)' },
];
/** @param {number|string} consignmentId @param {{reason: string, note?: string}} body */
export const initiateRto = async (consignmentId, body) => {
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto`, body);
return response.data;
};
/** Cancel a return and put the parcel back to delivery. */
export const cancelRto = async (consignmentId, note = '') => {
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto/cancel`, { note });
return response.data;
};
/** Ops confirm the parcel is back with the sender. */
export const completeRto = async (consignmentId, note = '') => {
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto/complete`, { note });
return response.data;
};
/**
* Parcels in or through a return, newest first.
* @param {{status?: 'initiated'|'returned'|'all', from?: string, to?: string, tenantid?: number, pageno?: number, pagesize?: number}} params
*/
export const getReturns = async (params = {}) => {
const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`);
return response.data;
};

View File

@@ -108,6 +108,10 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = {
canceled: 'cancelled',
skipped: 'skipped',
rto: 'skipped',
// Reverse logistics: the backend's real RTO statuses get their own tabs.
// (The generic legacy keys rto/returned above keep their old mapping.)
rto_initiated: 'rto',
returned_to_sender: 'returned',
returned: 'cancelled',
failed: 'cancelled'
};
@@ -1182,7 +1186,9 @@ export const fetchCountAPI = async () => {
activeLength: data.active || 0,
coveredLength: data.delivered || 0,
cancelLength: data.cancelled || 0,
skippedLength: data.skipped || 0
skippedLength: data.skipped || 0,
rtoLength: data.rto || 0,
returnedLength: data.returned || 0
};
};

View File

@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import { PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
import { Alert, Button, Field, Modal, Textarea } from '@/components/ds';
import { inputVariants } from '@/components/ui/input';
import { RTO_REASONS } from '@/api/doormile/endpoints';
import { useCancelRto, useCompleteRto, useInitiateRto } from '@/lib/doormileHooks';
/**
* Reverse logistics (RTO) dialogs, shared by Deliveries, Returns and
* Exceptions. Plan: docs/reverse-logistics-plan.md.
*
* A return can start while the parcel is in a rider's hands or at a base; the
* backend refuses anything else (delivered, cancelled, already returning) and
* its message is shown in the dialog. Every action needs the CONSIGNMENT id —
* a booking that has not been picked up has no parcel to return yet.
*/
/** Delivery-list statuses (queries.js) a return can be started from. */
export const RTO_STARTABLE = ['picked', 'active', 'skipped'];
/** What a row may do, from its delivery-list status. */
export function rtoActionsFor(row) {
const status = String(row?.orderstatus || '').toLowerCase();
const hasParcel = row?.consignmentid != null && row?.consignmentid !== '';
return {
canStart: hasParcel && RTO_STARTABLE.includes(status),
canResolve: hasParcel && status === 'rto',
};
}
const errorText = (err, fallback) => err?.response?.data?.message || err?.message || fallback;
/** The dialog subtitle: a caller's own `label`, else "Order <no>". */
const subtitleFor = (row) =>
row ? row.label || `Order ${row.orderid ?? row.trackingno ?? row.consignmentid}` : undefined;
/** Start a return to sender. `row` needs `consignmentid` and an order label. */
export function StartRtoModal({ row, onClose }) {
const start = useInitiateRto();
const [reason, setReason] = useState('');
const [note, setNote] = useState('');
const [error, setError] = useState('');
// `row.defaultReason` pre-selects a reason (e.g. from an exception's type).
useEffect(() => {
setReason(row?.defaultReason || '');
setNote('');
setError('');
}, [row]);
const noteRequired = reason === 'other';
const canSubmit = Boolean(reason) && (!noteRequired || note.trim().length > 0);
const submit = () => {
if (!canSubmit) return;
setError('');
start.mutate(
{ consignmentId: row.consignmentid, reason, note: note.trim() },
{
onSuccess: (res) => {
if (res?.success !== false) onClose();
else setError(res?.message || 'The return was not started.');
},
onError: (err) => setError(errorText(err, 'The return was not started.')),
}
);
};
return (
<Modal
open={Boolean(row)}
onOpenChange={(open) => !open && onClose()}
title="Return to sender"
description={subtitleFor(row)}
icon={Undo2}
busy={start.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={start.isPending}>
Keep delivering
</Button>
<Button variant="destructive" onClick={submit} loading={start.isPending} disabled={!canSubmit}>
Start return
</Button>
</>
}
>
{error && (
<Alert tone="destructive" className="mb-3" role="alert">
{error}
</Alert>
)}
<p className="mb-3 text-body-sm text-ink-2">
The parcel goes back to the sender&apos;s pickup point instead of being delivered. The rider is notified.
</p>
<Field label="Reason" required>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
aria-label="Return reason"
className={inputVariants()}
>
<option value="">Choose a reason</option>
{RTO_REASONS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</Field>
<Field label={noteRequired ? 'Describe the reason' : 'Note (optional)'} required={noteRequired} className="mt-3">
<Textarea rows={3} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} aria-label="Return note" />
</Field>
</Modal>
);
}
const RESOLVE_COPY = {
cancel: {
title: 'Re-attempt delivery',
icon: RotateCcw,
body: 'The return is cancelled and the parcel goes back to delivery, where it was before the return started.',
confirm: 'Re-attempt delivery',
variant: 'default',
fail: 'The return was not cancelled.',
},
complete: {
title: 'Mark returned to sender',
icon: PackageCheck,
body: 'Confirms the parcel is back with the sender. This closes the return and the rider’s stop, and cannot be undone.',
confirm: 'Mark returned',
variant: 'destructive',
fail: 'The parcel was not marked returned.',
},
};
/** Close a return: `mode` is 'cancel' (re-attempt) or 'complete' (returned). */
export function ResolveRtoModal({ row, mode, onClose }) {
const cancel = useCancelRto();
const complete = useCompleteRto();
const mutation = mode === 'complete' ? complete : cancel;
const copy = RESOLVE_COPY[mode] || RESOLVE_COPY.cancel;
const [note, setNote] = useState('');
const [error, setError] = useState('');
useEffect(() => {
setNote('');
setError('');
}, [row, mode]);
const submit = () => {
setError('');
mutation.mutate(
{ consignmentId: row.consignmentid, note: note.trim() },
{
onSuccess: (res) => {
if (res?.success !== false) onClose();
else setError(res?.message || copy.fail);
},
onError: (err) => setError(errorText(err, copy.fail)),
}
);
};
return (
<Modal
open={Boolean(row)}
onOpenChange={(open) => !open && onClose()}
title={copy.title}
description={subtitleFor(row)}
icon={copy.icon}
busy={mutation.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={mutation.isPending}>
Back
</Button>
<Button variant={copy.variant} onClick={submit} loading={mutation.isPending}>
{copy.confirm}
</Button>
</>
}
>
{error && (
<Alert tone="destructive" className="mb-3" role="alert">
{error}
</Alert>
)}
<p className="mb-3 text-body-sm text-ink-2">{copy.body}</p>
{row?.returnreason && <p className="mb-3 text-caption text-ink-3">Return reason: {row.returnreason}</p>}
<Field label="Note (optional)">
<Textarea rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} aria-label="Resolution note" />
</Field>
</Modal>
);
}

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import {
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, UserPlus, Warehouse,
ShieldAlert, Undo2, User, UserCheck, UserPlus, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_MARK_URL } from '@/assets/brand';
@@ -66,6 +66,7 @@ const NAV_GROUPS = [
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
{ label: 'Returns', path: '/doormile/returns', icon: Undo2 },
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
{ label: 'Agents', path: '/doormile/agents', icon: Bot },

View File

@@ -541,6 +541,43 @@ export const useCancelDelivery = () =>
successMessage: 'Delivery cancelled',
});
/* ── Reverse logistics (RTO) ──────────────────────────────────────────────── */
// Exceptions too: starting a return resolves the parcel's Undeliverable /
// Receiver_Refused exception on the server, and the list must show that at
// once rather than on its next poll.
const RETURN_KEYS = [...BOOKING_KEYS, ['doormile', 'returns'], KEYS.exceptions];
export const useReturns = (params = {}, options) =>
useQuery({
queryKey: ['doormile', 'returns', params],
queryFn: () => api.getReturns(params),
placeholderData: (previous) => previous,
staleTime: 15_000,
...options,
});
export const useInitiateRto = () =>
useDoormileMutation({
mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }),
invalidates: RETURN_KEYS,
successMessage: 'Return to sender started',
});
export const useCancelRto = () =>
useDoormileMutation({
mutationFn: ({ consignmentId, note }) => api.cancelRto(consignmentId, note),
invalidates: RETURN_KEYS,
successMessage: 'Return cancelled — delivery will be re-attempted',
});
export const useCompleteRto = () =>
useDoormileMutation({
mutationFn: ({ consignmentId, note }) => api.completeRto(consignmentId, note),
invalidates: RETURN_KEYS,
successMessage: 'Marked returned to sender',
});
/* ── Consignments ─────────────────────────────────────────────────────────── */
export const useConsignments = (options) =>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { Ban, Bell, Bike, Clock, FileSpreadsheet, PackageSearch, Truck } from 'lucide-react';
import { Ban, Bell, Bike, Clock, FileSpreadsheet, PackageCheck, PackageSearch, RotateCcw, Truck, Undo2 } from 'lucide-react';
import {
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
@@ -17,6 +17,8 @@ import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebounce
import { toDateRange } from '@/lib/dateRange';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
import { summariseRouting } from '@/lib/routingSummary';
import { useAuth } from '@/lib/AuthContext';
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
/**
* Deliveries — orders that have moved past merely being created.
@@ -45,6 +47,9 @@ const STATUS_TABS = [
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' },
// Reverse logistics: parcels going back to the sender, and those back.
{ value: 'rto', label: 'In return', countKey: 'rtoLength' },
{ value: 'returned', label: 'Returned', countKey: 'returnedLength' },
];
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
@@ -89,6 +94,11 @@ export default function Deliveries() {
const [nextStatus, setNextStatus] = useState('delivered');
const [cancelRow, setCancelRow] = useState(null);
const [cancelReason, setCancelReason] = useState('');
// Reverse logistics. Starting/closing a return is a Doormile-staff action;
// a client login sees the statuses but no buttons (the server refuses too).
const { isClient } = useAuth();
const [rtoRow, setRtoRow] = useState(null);
const [resolveRto, setResolveRto] = useState({ row: null, mode: 'cancel' });
const dateParams = useMemo(() => toDateRange(selectedDate), [selectedDate]);
@@ -309,7 +319,7 @@ export default function Deliveries() {
key: 'actions',
header: 'Actions',
align: 'right',
width: '130px',
width: '170px',
cell: (row) => (
<div className="flex justify-end gap-1">
<IconButton
@@ -320,7 +330,10 @@ export default function Deliveries() {
onClick={() => setDetailRow(row)}
/>
{row.orderstatus !== 'delivered' && row.milerprofileid ? (
{/* Not on parcels in or through a return: the generic "process
deliveries" text is wrong for them, and starting a return
already pushes the rider a return-specific message. */}
{!['delivered', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) && row.milerprofileid ? (
<IconButton
label="Notify rider"
icon={Bell}
@@ -349,18 +362,51 @@ export default function Deliveries() {
/>
) : null}
<IconButton
label="Update status"
icon={Truck}
variant="ghost"
size="sm"
onClick={() => {
setStatusRow(row);
setNextStatus('delivered');
}}
/>
{!isClient && rtoActionsFor(row).canStart ? (
<IconButton
label="Return to sender"
icon={Undo2}
variant="ghost"
size="sm"
onClick={() => setRtoRow(row)}
/>
) : null}
{!['delivered', 'cancelled', 'canceled'].includes(String(row.orderstatus || '').toLowerCase()) ? (
{!isClient && rtoActionsFor(row).canResolve ? (
<>
<IconButton
label="Re-attempt delivery"
icon={RotateCcw}
variant="ghost"
size="sm"
onClick={() => setResolveRto({ row, mode: 'cancel' })}
/>
<IconButton
label="Mark returned"
icon={PackageCheck}
variant="ghost"
size="sm"
onClick={() => setResolveRto({ row, mode: 'complete' })}
/>
</>
) : null}
{/* A parcel in or through a return is moved only by the return
actions above — the server refuses a plain status change. */}
{!['rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? (
<IconButton
label="Update status"
icon={Truck}
variant="ghost"
size="sm"
onClick={() => {
setStatusRow(row);
setNextStatus('delivered');
}}
/>
) : null}
{!['delivered', 'cancelled', 'canceled', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? (
<IconButton
label="Cancel delivery"
icon={Ban}
@@ -376,7 +422,7 @@ export default function Deliveries() {
),
},
],
[notifyMiler]
[notifyMiler, isClient]
);
const exportColumns = [
@@ -697,6 +743,13 @@ export default function Deliveries() {
</Field>
</Modal>
<StartRtoModal row={rtoRow} onClose={() => setRtoRow(null)} />
<ResolveRtoModal
row={resolveRto.row}
mode={resolveRto.mode}
onClose={() => setResolveRto({ row: null, mode: 'cancel' })}
/>
<OrderDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
</Stack>
);

View File

@@ -7,6 +7,7 @@ import {
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
import { StartRtoModal } from '@/components/doormile/RtoDialogs';
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
@@ -40,6 +41,10 @@ const EMPTY_FORM = {
const NO_HUB = 'none';
// Exceptions whose natural resolution is sending the parcel back (RTO), with
// the return reason each one implies.
const RTO_FROM_EXCEPTION = { Undeliverable: 'attempts_exhausted', Receiver_Refused: 'receiver_refused' };
export default function Exceptions() {
const { data: exceptions = [], isLoading, isFetching } = useExceptions();
const { data: hubs = [] } = useHubs();
@@ -53,6 +58,7 @@ export default function Exceptions() {
const [errors, setErrors] = useState({});
const [resolveRow, setResolveRow] = useState(null);
const [rtoRow, setRtoRow] = useState(null);
const [resolveStatus, setResolveStatus] = useState('Resolved');
const [resolution, setResolution] = useState('');
@@ -199,9 +205,26 @@ export default function Exceptions() {
isClosed(row) ? (
<span className="text-caption text-ink-4">Closed</span>
) : (
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
Resolve
</Button>
<div className="flex justify-end gap-1.5">
{RTO_FROM_EXCEPTION[row.exceptiontype] && row.consignmentid ? (
<Button
variant="outline"
size="sm"
onClick={() =>
setRtoRow({
consignmentid: row.consignmentid,
label: `Consignment #${row.consignmentid}`,
defaultReason: RTO_FROM_EXCEPTION[row.exceptiontype],
})
}
>
Start return
</Button>
) : null}
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
Resolve
</Button>
</div>
),
},
],
@@ -427,6 +450,10 @@ export default function Exceptions() {
</Field>
</Stack>
</Modal>
{/* Starting a return resolves the parcel's open Undeliverable /
Receiver_Refused exception on the server. */}
<StartRtoModal row={rtoRow} onClose={() => setRtoRow(null)} />
</Stack>
);
}

View File

@@ -0,0 +1,209 @@
import React, { useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { FileSpreadsheet, PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
import {
Alert, Button, DataTable, EmptyState, IconButton, 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 { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
/**
* Returns — parcels going back to the sender (RTO) and those already back.
*
* Reads GET /admin/returns (newest first, server-paged). A return is started
* from Deliveries or Exceptions; here ops follow it up: re-attempt delivery or
* confirm the parcel is back. A client login sees its own returns, read-only.
* Plan: docs/reverse-logistics-plan.md.
*/
const STATUS_TABS = [
{ value: 'initiated', label: 'In return' },
{ value: 'returned', label: 'Returned' },
{ value: 'all', label: 'All' },
];
const PAGE_SIZE = 50;
const STATUS_KEY = { RTO_Initiated: 'rto', Returned_to_Sender: 'returned' };
const fmt = (iso) => (iso ? dayjs(iso).format('DD MMM YYYY, h:mm A') : '—');
/** Whole days a return has been open (or took, once returned). */
export function returnAgeDays(row, now = dayjs()) {
if (!row?.returninitiatedat) return null;
const end = row.returndeliveredat ? dayjs(row.returndeliveredat) : now;
return Math.max(0, end.diff(dayjs(row.returninitiatedat), 'day'));
}
export default function Returns() {
const { isClient } = useAuth();
const [status, setStatus] = useState('initiated');
const [range, setRange] = useState({ from: dayjs().subtract(29, 'day').format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') });
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search);
const [resolve, setResolve] = useState({ row: null, mode: 'cancel' });
const params = useMemo(
() => ({ status, from: range.from || undefined, to: range.to || undefined, pageno: page, pagesize: PAGE_SIZE }),
[status, range, page]
);
const { data, isLoading, isFetching, isError, error } = useReturns(params);
const rows = useMemo(() => data?.data || [], [data]);
const total = Number(data?.total) || 0;
const visible = useMemo(
() =>
rows.filter((r) =>
matchesQuery(r, ['trackingno', 'tenantname', 'returnreason', 'milername', 'deliverypincode', 'pickuppincode'], debouncedSearch)
),
[rows, debouncedSearch]
);
const columns = [
{ key: 'trackingno', header: 'Tracking no', accessor: (r) => orDash(r.trackingno) },
{ key: 'tenantname', header: 'Client', accessor: (r) => orDash(r.tenantname) },
{
key: 'status',
header: 'Status',
cell: (r) => <StatusBadge status={STATUS_KEY[r.status] || r.status} dot size="sm" />,
},
{ key: 'returnreason', header: 'Reason', accessor: (r) => orDash(r.returnreason) },
{ key: 'attemptcount', header: 'Attempts', accessor: (r) => r.attemptcount ?? 0, align: 'right' },
{ key: 'returninitiatedat', header: 'Return started', accessor: (r) => fmt(r.returninitiatedat) },
{ key: 'returndeliveredat', header: 'Returned', accessor: (r) => fmt(r.returndeliveredat) },
{
key: 'age',
header: 'Days',
align: 'right',
accessor: (r) => {
const d = returnAgeDays(r);
return d == null ? '—' : d;
},
},
{ 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,
},
]),
];
const exportColumns = [
{ key: 'trackingno', header: 'Tracking no' },
{ key: 'tenantname', header: 'Client' },
{ key: 'status', header: 'Status' },
{ key: 'returnreason', header: 'Reason' },
{ key: 'attemptcount', header: 'Attempts' },
{ key: 'returninitiatedat', header: 'Return started', value: (r) => fmt(r.returninitiatedat) },
{ key: 'returndeliveredat', header: 'Returned', value: (r) => fmt(r.returndeliveredat) },
{ key: 'milername', header: 'Miler' },
{ key: 'pickuppincode', header: 'Pickup pincode' },
{ key: 'deliverypincode', header: 'Drop pincode' },
];
return (
<Stack space="lg">
<PageHeader
title="Returns"
subtitle="Parcels going back to the sender, and those already returned."
actions={
<Button
variant="outline"
disabled={!visible.length}
onClick={() => exportRows(visible, exportColumns, `returns-${status}-${range.from || 'start'}-${range.to || 'now'}`)}
>
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
</Button>
}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<Tabs
tabs={STATUS_TABS}
value={status}
onChange={(v) => {
setStatus(v);
setPage(1);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<DateRangeFields
value={range}
onChange={(next) => {
setRange(next);
setPage(1);
}}
/>
<SearchInput value={search} onChange={setSearch} placeholder="Tracking no, client, reason, miler" className="w-full sm:w-64" />
</div>
</div>
{isError && (
<Alert tone="destructive" role="alert" title="Returns could not be loaded">
{error?.response?.status === 404
? 'This API server does not have returns yet — deploy the current doormile_backend build.'
: error?.response?.data?.message || error?.message || 'Try again shortly.'}
</Alert>
)}
<DataTable
columns={columns}
rows={visible}
getRowId={(r) => r.consignmentid}
loading={isLoading}
refreshing={isFetching && !isLoading}
isFiltered={Boolean(debouncedSearch)}
onClearFilters={() => setSearch('')}
emptyState={
<EmptyState
icon={Undo2}
title={status === 'returned' ? 'No returned parcels in this period' : 'No parcels in return'}
description="Start a return from Deliveries (Return to sender) or from an Undeliverable exception."
/>
}
/>
{total > PAGE_SIZE && (
<Surface variant="subtle" padding="sm" radius="lg">
<Pagination
page={page}
pageCount={Math.max(1, Math.ceil(total / PAGE_SIZE))}
pageSize={PAGE_SIZE}
totalItems={total}
onPageChange={setPage}
/>
</Surface>
)}
<ResolveRtoModal row={resolve.row} mode={resolve.mode} onClose={() => setResolve({ row: null, mode: 'cancel' })} />
</Stack>
);
}