updates on the reverse logistics
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
196
src/components/doormile/RtoDialogs.jsx
Normal file
196
src/components/doormile/RtoDialogs.jsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
209
src/pages/doormile/returns/Returns.jsx
Normal file
209
src/pages/doormile/returns/Returns.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user