diff --git a/docs/reverse-logistics-plan.md b/docs/reverse-logistics-plan.md new file mode 100644 index 0000000..3f6d3a3 --- /dev/null +++ b/docs/reverse-logistics-plan.md @@ -0,0 +1,279 @@ +# Reverse Logistics — Analysis & Implementation Plan + +Status: **Phases 1–3 built 2026-10-05 (uncommitted, not deployed)** — see §10. +Drafted 2026-10-05. +Scope: `doormile_backend` (rules + endpoints), `krow_talent_app` (ops console), +and the rider app (Flutter, separate team). The customer app is touched only +where noted. + +Defaults below are **proposals**; every one marked ⚑ is an open decision +listed in §9. + +--- + +## 1. What exists today (verified in code) + +| Area | Finding | Where | +|---|---|---| +| Return statuses | `RTO_Initiated`, `Returned_to_Sender` are defined and **allowed by the DB constraint** — but **nothing in the code ever sets them** | `doormile_backend/constants/constants.go:91-92`, `migrations/migrate.go:124` | +| Return fields on the parcel | `Consignment` already has `Returnreason`, `Returninitiatedat`, `Returndeliveredat`, `Parentconsignmentid` — **all unused** | `doormile_backend/models/audit.go:70-73` | +| Failed delivery | Rider taps *skip* → `MilerSkipDelivery` adds 1 to `Attemptcount`, logs `Delivery_Skipped` history. After **3** attempts it opens an `Undeliverable` exception — and stops there. The parcel stays `Out_for_Delivery` with the rider; no return leg, no owner, client not told | `doormile_backend/controllers/milerAppController.go:990-1070` | +| Exception types | `Receiver_Refused`, `Undeliverable`, `Damaged`, `Lost`, `Misrouted`, `Missing_Contents` exist | `constants.go:186-195` | +| Rider "what next" | `nextActionForConsignment` maps status → rider action. `RTO`/`Returned` fall into `none` ("past this rider's leg") — so a rider is never asked to bring a parcel back | `controllers/logisticsHandoverController.go:233-251` | +| Admin status change | `PUT /admin/consignments/:id/status` accepts **any** status string with no transition rules (a separate bug: it can set `Delivered` on a cancelled parcel) | `controllers/adminController.go:3133-3190` | +| Console | Only traces: an `rto` badge tone (`components/ds/StatusBadge.jsx:47`), `rto → skipped` mapping (`api/doormile/queries.js:110`), a comment in `lib/orderFlow.js:108`. Status update offers only Out_for_Delivery / Delivered / Cancelled | `krow_talent_app/src/...` | +| Pricing | `Pricing` has base/per-km/per-kg/handling — **no return charge** | `models` Pricing | +| Customer returns (reverse pickup) | Not present anywhere | — | + +**Conclusion:** the data model is mostly ready; the lifecycle, the rules, the +endpoints, the console screens and the rider task are all missing. + +--- + +## 2. Scope + +| Flow | Description | Phase | +|---|---|---| +| **A. RTO — Return to Origin** | Delivery fails → parcel goes back to the sender's pickup point (⚑ or a hub) | **Phase 1–3 (this plan)** | +| **B. Customer return** | Receiver sends a delivered item back; rider picks up from receiver, returns to sender | Phase 5 (later) | +| **C. Exchange** | Deliver new + collect old in one visit | Out of scope for now | + +--- + +## 3. RTO lifecycle (proposed) + +``` +Collected_By_Miler / Out_for_Delivery + │ failed attempt (rider skip) → Attemptcount++ (as today) + │ + ├─ attempts ≥ N (⚑ default 3) ─┐ + ├─ ops "Initiate RTO" (console) ─┼─▶ RTO_Initiated + └─ (⚑ later) client request ───┘ returnreason, returninitiatedat set + history: RTO_Initiated + rider next action: return_to_sender + │ + ┌──────────────┼───────────────────────────┐ + ▼ ▼ ▼ + rider returns ops "Re-attempt delivery" ops "Mark returned" + to sender (cancels the RTO) (manual close, e.g. hub drop) + │ │ │ + ▼ ▼ ▼ + Returned_to_Sender Out_for_Delivery Returned_to_Sender + returndeliveredat (attempts kept) returndeliveredat +``` + +Rules: +- RTO only from `Collected_By_Miler`, `Out_for_Delivery` (or `Inwarded_at_Hub` ⚑). +- `Returned_to_Sender` is terminal. +- Every transition writes `ConsignmentHistory` with actor + reason. +- Opening RTO resolves the linked `Undeliverable` exception (if any) with + resolution "RTO initiated". +- COD: a returned parcel collects nothing; `Codcollected` stays 0. + +--- + +## 4. Backend — `doormile_backend` + +| # | Change | Detail | +|---|---|---| +| B1 | **Transition guard** | One function `canTransition(from, to)` used by every consignment status write; `PUT /admin/consignments/:id/status` refuses illegal moves (also fixes the any-status bug). | +| B2 | `POST /admin/consignments/:id/rto` `{reason, note}` | Staff only. Sets `RTO_Initiated`, `returnreason`, `returninitiatedat`; history; resolves the Undeliverable exception; publishes NATS `consignment.rto_initiated`; notifies the rider. Idempotent. | +| B3 | `POST /admin/consignments/:id/rto/cancel` `{note}` | Back to `Out_for_Delivery` (re-attempt). | +| B4 | `POST /admin/consignments/:id/rto/complete` `{note}` | Ops closes it manually → `Returned_to_Sender`, `returndeliveredat`. | +| B5 | `GET /admin/returns?status&from&to&tenantid&hubid&pageno` | List for the Returns page: tracking no, client, sender, reason, attempts, initiated/returned times, rider, age. Tenant-scoped like other admin lists. | +| B6 | **Auto-RTO** in `MilerSkipDelivery` | When `Attemptcount ≥ RTO_AUTO_AFTER_ATTEMPTS` (env, ⚑ default 3; `0` = off) → call the same B2 logic instead of only opening an exception. | +| B7 | **Rider task** | New next action `return_to_sender`; `nextActionForConsignment(RTO_Initiated)` returns it; rider queue includes the return stop (sender's pickup coords). New `POST /miler/consignments/:id/return-complete` `{lat, lon, photourl?, receivedby}` → `Returned_to_Sender`. **Behind flag `MILER_RTO_FLOW_ENABLED`** (default off) — the deployed rider app doesn't know `return_to_sender`, same pattern as `MILER_HUB_HANDOVER_ENABLED`. | +| B8 | Booking/customer stage | For customer-app bookings, record the return in `cxstage`. ⚠ The customer app renders unknown stage keys as `booked`, so a new "returning" stage needs a client release — until then do **not** add a new key. | +| B9 | Tests | Transition table, each endpoint's gates (staff/tenant/owner), auto-RTO at N attempts, flag off/on rider queue, idempotency. Postgres-gated tests for the SQL. | + +Schema: **none for Phase 1–3** (columns exist). ⚑ Return-to-hub needs one +additive nullable column (`returnhubid`) — Phase 4. + +--- + +## 5. Console — `krow_talent_app` + +| # | Change | Where | +|---|---|---| +| C1 | API + hooks: `initiateRto`, `cancelRto`, `completeRto`, `getReturns` + `useInitiateRto` … with invalidation of deliveries/returns keys | `src/api/doormile/endpoints.js`, `src/lib/doormileHooks.js` | +| C2 | **Deliveries page actions**: on failed / out-for-delivery rows — *Initiate RTO* (reason picker: Receiver refused, Address not found, Customer unavailable, Attempts exhausted, Other + note), *Re-attempt*, *Mark returned*. Show attempt count | `src/pages/doormile/deliveries/Deliveries.jsx` | +| C3 | **Status tabs**: add *RTO* and *Returned* tabs/counts (today `rto` collapses into `skipped`) | `queries.js` status map, `StatusBadge` | +| C4 | **Returns page** `/doormile/returns`: tabs Initiated · In return · Returned; filters (date, client, hub, reason); age/SLA column; XLSX export; nav entry under Fleet Ops (staff) | new `src/pages/doormile/returns/Returns.jsx`, `App.jsx`, `AdminLayout.jsx` | +| C5 | **Exceptions page**: an `Undeliverable` / `Receiver_Refused` exception gets a *Start RTO* button | `src/pages/doormile/exceptions/Exceptions.jsx` | +| C6 | **Order / consignment timeline** shows attempts, RTO start, return | booking detail drawer | +| C7 | **Reports**: return rate per client and per reason on Orders Summary | `src/pages/doormile/reports/` | +| C8 | Client (tenant) logins: read-only view of their own returns (no actions) | Returns page + role check | +| C9 | Tests: actions call the right endpoints, reason required, tabs/counts, Returns filters, client read-only | `tests/integration/` | + +--- + +## 6. Rider app (Flutter — separate team) + +- Handle next action `return_to_sender`: show the return stop, navigate to the + sender, "Returned" button → `POST /miler/consignments/:id/return-complete` + with location (+ optional photo / receiver name). +- Release before turning on `MILER_RTO_FLOW_ENABLED`. Until then, ops close + returns from the console (B4) and riders are told by push. + +--- + +## 7. Phases & order + +| Phase | Content | Repos | Depends on | +|---|---|---|---| +| **1** | B1 guard, B2–B5 endpoints, B9 tests | backend | — | +| **2** | C1–C5, C9 | console | Phase 1 deployed | +| **3** | B6 auto-RTO, B7 rider task (flag off) + rider app release, then flag on | backend + rider app | Phase 1 | +| **4** | Return-to-hub option (`returnhubid`), C6 timeline, C7 reports, C8 client view, return charges | all | decisions ⚑ | +| **5** | Flow B — customer returns (reverse pickup booking linked by `Parentconsignmentid`) | all + customer app | product spec | + +Rough effort: Phase 1 ≈ 1.5–2 days · Phase 2 ≈ 2 days · Phase 3 ≈ 1 day backend +(+ rider app team) · Phase 4/5 to estimate after decisions. + +--- + +## 8. Risks + +- **Rider app compatibility** — a new next action unknown to the deployed app + must stay behind a flag (B7). +- **Customer app stage keys** — unknown keys render as `booked` (B8). +- **Ops discipline** — until the rider task ships, returns depend on ops + closing them in the console. +- **Existing any-status endpoint** — B1 changes its behaviour: callers that + relied on setting arbitrary statuses will now get 400. The console only + sends Out_for_Delivery / Delivered / Cancelled, which stay allowed. +- **Deploy** — no migration in Phase 1–3; Phase 4 adds one nullable column + (needs approval). + +--- + +## 9. Open decisions ⚑ + +1. **Flows first:** A (RTO) only, then B? *(proposed: yes)* +2. **Return destination:** sender's pickup point, nearest hub, or per client? + *(proposed: sender for Phase 1; hub option in Phase 4)* +3. **Who starts RTO:** auto after N attempts (N = ?), ops, client, or all? + *(proposed: ops + auto after 3; client later)* +4. **Can RTO start from a hub** (`Inwarded_at_Hub`)? *(proposed: yes)* +5. **Return charges:** billed to the client? Same rate, fixed fee, or free? + *(proposed: decide before Phase 4; Phase 1–3 record only)* +6. **Rider app:** is the Flutter team available for B7, and when? +7. **Client visibility:** may tenants see their own returns? *(proposed: read-only)* + +--- + +## 10. Implementation status (2026-10-05) + +Built with the proposed defaults: return to the **sender**, started by **ops or +automatically after 3 failed attempts**, rider task **behind a flag (off)**. +Nothing is committed or pushed. + +### Backend — `doormile_backend` +| Item | Where | +|---|---| +| RTO core: `startRTO`, `completeRTO`, cancel → previous status (recorded as `[from:]` in the history remark), exception auto-resolve, rider push | `controllers/consignmentReturn.go` | +| `POST /admin/consignments/:id/rto` · `/rto/cancel` · `/rto/complete` (Doormile staff only) | `routes/routes.go` | +| `GET /admin/returns?status=initiated\|returned\|all&from&to&pageno&pagesize` (tenant-scoped; client logins read their own) | same | +| `POST /miler/consignments/:id/return-complete` — 403 `RTO_FLOW_DISABLED` unless `MILER_RTO_FLOW_ENABLED=true` | same | +| Status guard on `PUT /admin/consignments/:id/status` (unknown status, leaving Delivered/Cancelled/Returned, RTO statuses → 400) | `controllers/adminController.go` | +| Auto-RTO after `RTO_AUTO_AFTER_ATTEMPTS` (default 3, `0` = off) | `MilerSkipDelivery` in `controllers/milerAppController.go` | +| `MilerSkipDelivery` ownership now multi-destination safe (`milerConsignmentForRider`) — fixes skip on orders 2..N | same | +| Next action `return_to_sender` (flagged); rider consignment read adds `returning`, `can_return`, `return_reason`, `return_to` | `constants`, `logisticsHandoverController.go`, `milerAppController.go` | +| Tests: 7 unit + 5 route-gate tests | `controllers/consignmentReturn_test.go`, `routes/routes_rto_test.go` | + +New env vars: `RTO_AUTO_AFTER_ATTEMPTS` (default 3), `MILER_RTO_FLOW_ENABLED` +(default off). **No schema change.** No customer-app stage key added (B8). + +### Console — `krow_talent_app` +| Item | Where | +|---|---| +| API + hooks: `initiateRto`, `cancelRto`, `completeRto`, `getReturns`, `RTO_REASONS`; `useReturns`, `useInitiateRto`, `useCancelRto`, `useCompleteRto` | `src/api/doormile/endpoints.js`, `src/lib/doormileHooks.js` | +| Status mapping: `RTO_Initiated → rto`, `Returned_to_Sender → returned` (legacy `rto`/`returned` keys unchanged) + counts | `src/api/doormile/queries.js` | +| Shared dialogs: Start return (reason + note, pre-select), Re-attempt / Mark returned | `src/components/doormile/RtoDialogs.jsx` | +| Deliveries: **In return** + **Returned** tabs; Return to sender / Re-attempt / Mark returned (staff only); status-update and cancel hidden on returning rows | `src/pages/doormile/deliveries/Deliveries.jsx` | +| **Returns page** `/doormile/returns` (tabs, date range, search, export, pagination; read-only for clients); nav under Fleet Ops | `src/pages/doormile/returns/Returns.jsx`, `App.jsx`, `AdminLayout.jsx` | +| Exceptions: **Start return** on open Undeliverable / Receiver_Refused | `src/pages/doormile/exceptions/Exceptions.jsx` | +| Tests: 13 new; deliveries counts test updated | `tests/integration/returns.test.jsx`, `tests/api/deliveries.test.js` | + +### Verified / not verified +- ✅ `go build`, `go vet`, all 17 backend packages; console suites pass except + the 13 Agent Studio tests already failing since the 2026-09-30 redesign; + console production build OK. +- ✅ **End-to-end on a real database (2026-10-05).** This ran on a throwaway + local stack: PostgreSQL 17.10 on 127.0.0.1, the backend built from this tree + with `MILER_RTO_FLOW_ENABLED=true`, and the console on a local port pointed + at it. Production was not touched. + - Browser, as staff: + - Return to sender (reason + note), then Re-attempt (back to + Out_for_Delivery), then Start again, then Mark returned. Result: + `Returned_to_Sender`, the rider's assignment `Completed`, and the full + status history. + - Exceptions → Start return pre-selects the reason, and the exception + leaves the list. + - The Returns page lists rows with correct India times. + - API: + - 3 skips start RTO automatically and resolve the Undeliverable exception. + - The rider read gives `next_action=return_to_sender` with `return_to` + set to the sender's coordinates. + - Rider return-complete closes the assignment. + - Every guard answers 400 / `INVALID_STATE` as designed. + - Client login: no return buttons on Deliveries; Returns is read-only; + `rto/cancel` and `rto/complete` return 403. +- Bugs the browser test found, all fixed: + 1. Notify rider showed on returned and delivered rows. It is now hidden. + 2. Exceptions didn't refresh after Start return. `RETURN_KEYS` now includes + the exceptions key. + 3. The dialog subtitle from Exceptions read "Order consignment #…". It now + uses a `label` prop. +- Backend review (2026-10-05), fixed: + 1. **Race conditions.** Start, complete and cancel used to read the parcel + and then save the whole row. A rider delivering at the same moment, or + a double click, could be overwritten. Each one is now a compare-and-set + (`moveConsignment`: update only if the status is still the one read). + A lost race either becomes the same no-op as a repeat, or returns + "changed, refresh". + 2. **Re-attempt clears the return.** `returnreason` and + `returninitiatedat` are cleared, so a parcel delivered afterwards doesn't + carry a stale reason. The history keeps the reason. + 3. **"OTHER" check.** A reason sent as "OTHER" or "Other " skipped the + note-required check. The check now uses the normalised reason + (`rtoReasonText`). + 4. **Note length.** The 500 limit is counted in characters, not bytes. + Before, 200 Tamil characters were refused even though the console + allows 500. + 5. **Wrong rider notified.** Starting a return on a parcel already handed + over at a base pushed "do not attempt delivery" to its old pickup rider. + Now only a rider holding the parcel is notified + (`riderHoldsParcel`: Created, Collected_By_Miler, Out_for_Delivery). +- Impact on existing users (checked 2026-10-05). Decide these before + deploying: + - **Rider app (deployed build):** it shows a parcel from the *booking* + status, which a return doesn't change. So a parcel in return still looks + deliverable; tapping deliver or skip returns a 400. The rider also can't go + off duty until ops press **Mark returned**, because the assignment stays + open. Automatic return is **on by default** (3 attempts), so this starts + on deploy. Either set `RTO_AUTO_AFTER_ATTEMPTS=0` until the rider app + supports returns, or make sure ops close returns daily. + - **Console "Update status":** Delivered and Cancelled parcels can no longer + be changed (400). Before, ops could undo a wrong "Delivered" or reopen a + cancelled parcel. That is now refused by design. + - **Customer app:** a returned parcel keeps showing "Out for delivery" + (there is no customer stage for returns). + - **Rider pay:** closing a return marks the assignment Completed with 0 km + and 0 charges. The return trip isn't paid. + - **Not affected:** rider delivered counts and reports (Delivered only), the + hub console (a parcel in return simply drops out of its status-based + lists), the B2C booking flow, the existing status values, and the + database schema (no change). +- Tests added: `TestRiderHoldsParcel`, `TestRTOReasonText`, plus `controllers/consignmentReturn_pg_test.go` + (5 tests on real Postgres: lifecycle, refusals, no overwrite of a + concurrent delivery, 10 simultaneous starts → one return, cancel). These + skip unless `REGISTRY_TEST_DSN` is set, and passed on PostgreSQL 17. +- Timestamps stay `time.Now()`, not `utils.DBNow()`. `AutoMigrate` creates + `timestamptz` columns, where `DBNow()` would store a time 5 h 30 m ahead. + `time.Now()` is correct for both column types because the Dockerfile sets + `TZ=Asia/Kolkata`. +- Seen, not fixed (pre-existing, outside RTO): the Exceptions page shows + "Raised" times about 6 h ahead of IST. +- Not built: Phase 4 (return-to-hub, timeline, reports, charges), Phase 5 + (customer returns), the rider-app UI (Flutter team). diff --git a/src/App.jsx b/src/App.jsx index d71422f..9075d3c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 = () => { } /> } /> + {/* Reverse logistics: parcels going back to the sender (RTO). */} + } /> } /> } /> diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index f4ddd39..6950a42 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -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; +}; diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index c477cb1..071fcff 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -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 }; }; diff --git a/src/components/doormile/RtoDialogs.jsx b/src/components/doormile/RtoDialogs.jsx new file mode 100644 index 0000000..84a7db5 --- /dev/null +++ b/src/components/doormile/RtoDialogs.jsx @@ -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 ". */ +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 ( + !open && onClose()} + title="Return to sender" + description={subtitleFor(row)} + icon={Undo2} + busy={start.isPending} + footer={ + <> + + + + } + > + {error && ( + + {error} + + )} +

+ The parcel goes back to the sender's pickup point instead of being delivered. The rider is notified. +

+ + + + +