updates on the reverse logistics
This commit is contained in:
279
docs/reverse-logistics-plan.md
Normal file
279
docs/reverse-logistics-plan.md
Normal file
@@ -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:<status>]` 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).
|
||||||
@@ -25,6 +25,7 @@ const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
|
|||||||
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
|
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
|
||||||
|
|
||||||
const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries'));
|
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 Tenants = lazy(() => import('@/pages/doormile/clients/Tenants'));
|
||||||
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
|
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
|
||||||
@@ -122,6 +123,8 @@ const AuthenticatedApp = () => {
|
|||||||
<Route path="bookings" element={<Bookings />} />
|
<Route path="bookings" element={<Bookings />} />
|
||||||
|
|
||||||
<Route path="deliveries" element={<Deliveries />} />
|
<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="tenants" element={<Tenants />} />
|
||||||
<Route path="clients/create" element={<CreateClient />} />
|
<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 })}`);
|
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);
|
||||||
return response.data.data;
|
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',
|
canceled: 'cancelled',
|
||||||
skipped: 'skipped',
|
skipped: 'skipped',
|
||||||
rto: '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',
|
returned: 'cancelled',
|
||||||
failed: 'cancelled'
|
failed: 'cancelled'
|
||||||
};
|
};
|
||||||
@@ -1182,7 +1186,9 @@ export const fetchCountAPI = async () => {
|
|||||||
activeLength: data.active || 0,
|
activeLength: data.active || 0,
|
||||||
coveredLength: data.delivered || 0,
|
coveredLength: data.delivered || 0,
|
||||||
cancelLength: data.cancelled || 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 {
|
import {
|
||||||
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
|
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
|
||||||
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
|
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
|
||||||
ShieldAlert, User, UserCheck, UserPlus, Warehouse,
|
ShieldAlert, Undo2, User, UserCheck, UserPlus, Warehouse,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { DOORMILE_MARK_URL } from '@/assets/brand';
|
import { DOORMILE_MARK_URL } from '@/assets/brand';
|
||||||
@@ -66,6 +66,7 @@ const NAV_GROUPS = [
|
|||||||
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
|
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
|
||||||
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
|
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
|
||||||
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
|
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
|
||||||
|
{ label: 'Returns', path: '/doormile/returns', icon: Undo2 },
|
||||||
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
|
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
|
||||||
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
|
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
|
||||||
{ label: 'Agents', path: '/doormile/agents', icon: Bot },
|
{ label: 'Agents', path: '/doormile/agents', icon: Bot },
|
||||||
|
|||||||
@@ -541,6 +541,43 @@ export const useCancelDelivery = () =>
|
|||||||
successMessage: 'Delivery cancelled',
|
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 ─────────────────────────────────────────────────────────── */
|
/* ── Consignments ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export const useConsignments = (options) =>
|
export const useConsignments = (options) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import dayjs from 'dayjs';
|
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 {
|
import {
|
||||||
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
||||||
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
|
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 { toDateRange } from '@/lib/dateRange';
|
||||||
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
||||||
import { summariseRouting } from '@/lib/routingSummary';
|
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.
|
* Deliveries — orders that have moved past merely being created.
|
||||||
@@ -45,6 +47,9 @@ const STATUS_TABS = [
|
|||||||
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
|
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
|
||||||
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
|
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
|
||||||
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' },
|
{ 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');
|
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 [nextStatus, setNextStatus] = useState('delivered');
|
||||||
const [cancelRow, setCancelRow] = useState(null);
|
const [cancelRow, setCancelRow] = useState(null);
|
||||||
const [cancelReason, setCancelReason] = useState('');
|
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]);
|
const dateParams = useMemo(() => toDateRange(selectedDate), [selectedDate]);
|
||||||
|
|
||||||
@@ -309,7 +319,7 @@ export default function Deliveries() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: '130px',
|
width: '170px',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -320,7 +330,10 @@ export default function Deliveries() {
|
|||||||
onClick={() => setDetailRow(row)}
|
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
|
<IconButton
|
||||||
label="Notify rider"
|
label="Notify rider"
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
@@ -349,18 +362,51 @@ export default function Deliveries() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<IconButton
|
{!isClient && rtoActionsFor(row).canStart ? (
|
||||||
label="Update status"
|
<IconButton
|
||||||
icon={Truck}
|
label="Return to sender"
|
||||||
variant="ghost"
|
icon={Undo2}
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
setStatusRow(row);
|
onClick={() => setRtoRow(row)}
|
||||||
setNextStatus('delivered');
|
/>
|
||||||
}}
|
) : 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
|
<IconButton
|
||||||
label="Cancel delivery"
|
label="Cancel delivery"
|
||||||
icon={Ban}
|
icon={Ban}
|
||||||
@@ -376,7 +422,7 @@ export default function Deliveries() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[notifyMiler]
|
[notifyMiler, isClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportColumns = [
|
const exportColumns = [
|
||||||
@@ -697,6 +743,13 @@ export default function Deliveries() {
|
|||||||
</Field>
|
</Field>
|
||||||
</Modal>
|
</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)} />
|
<OrderDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||||
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
|
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
|
||||||
|
import { StartRtoModal } from '@/components/doormile/RtoDialogs';
|
||||||
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
|
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
|
||||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||||
@@ -40,6 +41,10 @@ const EMPTY_FORM = {
|
|||||||
|
|
||||||
const NO_HUB = 'none';
|
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() {
|
export default function Exceptions() {
|
||||||
const { data: exceptions = [], isLoading, isFetching } = useExceptions();
|
const { data: exceptions = [], isLoading, isFetching } = useExceptions();
|
||||||
const { data: hubs = [] } = useHubs();
|
const { data: hubs = [] } = useHubs();
|
||||||
@@ -53,6 +58,7 @@ export default function Exceptions() {
|
|||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const [resolveRow, setResolveRow] = useState(null);
|
const [resolveRow, setResolveRow] = useState(null);
|
||||||
|
const [rtoRow, setRtoRow] = useState(null);
|
||||||
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
||||||
const [resolution, setResolution] = useState('');
|
const [resolution, setResolution] = useState('');
|
||||||
|
|
||||||
@@ -199,9 +205,26 @@ export default function Exceptions() {
|
|||||||
isClosed(row) ? (
|
isClosed(row) ? (
|
||||||
<span className="text-caption text-ink-4">Closed</span>
|
<span className="text-caption text-ink-4">Closed</span>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
|
<div className="flex justify-end gap-1.5">
|
||||||
Resolve
|
{RTO_FROM_EXCEPTION[row.exceptiontype] && row.consignmentid ? (
|
||||||
</Button>
|
<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>
|
</Field>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Starting a return resolves the parcel's open Undeliverable /
|
||||||
|
Receiver_Refused exception on the server. */}
|
||||||
|
<StartRtoModal row={rtoRow} onClose={() => setRtoRow(null)} />
|
||||||
</Stack>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -444,7 +444,10 @@ describe('deliveries data layer', () => {
|
|||||||
activeLength: 1,
|
activeLength: 1,
|
||||||
coveredLength: 1,
|
coveredLength: 1,
|
||||||
cancelLength: 1,
|
cancelLength: 1,
|
||||||
skippedLength: 0
|
skippedLength: 0,
|
||||||
|
// Reverse logistics tabs (In return / Returned).
|
||||||
|
rtoLength: 0,
|
||||||
|
returnedLength: 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
191
tests/integration/returns.test.jsx
Normal file
191
tests/integration/returns.test.jsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse logistics (RTO) in the console: the shared dialogs, the Returns page
|
||||||
|
* and the delivery-status mapping. HTTP is mocked with the backend's shapes
|
||||||
|
* (POST /admin/consignments/:id/rto[/cancel|/complete], GET /admin/returns).
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('lucide-react', () =>
|
||||||
|
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('@/api/doormile', () => ({
|
||||||
|
initiateRto: jest.fn(),
|
||||||
|
cancelRto: jest.fn(),
|
||||||
|
completeRto: jest.fn(),
|
||||||
|
getReturns: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/api/doormile/notify', () => ({
|
||||||
|
OpenToast: jest.fn(),
|
||||||
|
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockAuth = { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false };
|
||||||
|
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
|
||||||
|
|
||||||
|
import * as api from '@/api/doormile';
|
||||||
|
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
|
||||||
|
import Returns, { returnAgeDays } from '@/pages/doormile/returns/Returns';
|
||||||
|
import { deriveDeliveryStatus } from '@/api/doormile/queries';
|
||||||
|
|
||||||
|
const wrap = (ui) => {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{ui}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW = { consignmentid: 61, orderid: 'DM-000123', orderstatus: 'active' };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockAuth = { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false };
|
||||||
|
api.initiateRto.mockResolvedValue({ success: true, data: { started: true } });
|
||||||
|
api.cancelRto.mockResolvedValue({ success: true, data: {} });
|
||||||
|
api.completeRto.mockResolvedValue({ success: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('which rows may start or close a return', () => {
|
||||||
|
it('starts only from a parcel in hand; closes only one in return', () => {
|
||||||
|
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'active' })).toEqual({ canStart: true, canResolve: false });
|
||||||
|
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'picked' }).canStart).toBe(true);
|
||||||
|
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'skipped' }).canStart).toBe(true);
|
||||||
|
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'delivered' }).canStart).toBe(false);
|
||||||
|
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'pending' }).canStart).toBe(false);
|
||||||
|
// No parcel yet (not picked up) — nothing to return.
|
||||||
|
expect(rtoActionsFor({ consignmentid: null, orderstatus: 'active' }).canStart).toBe(false);
|
||||||
|
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'rto' })).toEqual({ canStart: false, canResolve: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the backend RTO statuses to their own tabs, leaving legacy keys alone', () => {
|
||||||
|
expect(deriveDeliveryStatus('RTO_Initiated')).toBe('rto');
|
||||||
|
expect(deriveDeliveryStatus('Returned_to_Sender')).toBe('returned');
|
||||||
|
expect(deriveDeliveryStatus('rto')).toBe('skipped');
|
||||||
|
expect(deriveDeliveryStatus('returned')).toBe('cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Start return dialog', () => {
|
||||||
|
it('needs a reason, and a note for Other, then starts the return', async () => {
|
||||||
|
wrap(<StartRtoModal row={ROW} onClose={jest.fn()} />);
|
||||||
|
const start = screen.getByRole('button', { name: /Start return/ });
|
||||||
|
expect(start).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Return reason'), { target: { value: 'other' } });
|
||||||
|
expect(start).toBeDisabled(); // Other needs a description
|
||||||
|
fireEvent.change(screen.getByLabelText('Return note'), { target: { value: 'Shop closed for good' } });
|
||||||
|
expect(start).not.toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(start);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.initiateRto).toHaveBeenCalledWith(61, { reason: 'other', note: 'Shop closed for good' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a caller-given label as the subtitle (Exceptions passes "Consignment #id")', () => {
|
||||||
|
wrap(<StartRtoModal row={{ consignmentid: 9106, label: 'Consignment #9106' }} onClose={jest.fn()} />);
|
||||||
|
expect(screen.getByText('Consignment #9106')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Order consignment/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-selects a reason when given one', () => {
|
||||||
|
wrap(<StartRtoModal row={{ ...ROW, defaultReason: 'receiver_refused' }} onClose={jest.fn()} />);
|
||||||
|
expect(screen.getByLabelText('Return reason')).toHaveValue('receiver_refused');
|
||||||
|
expect(screen.getByRole('button', { name: /Start return/ })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the server refusal and stays open', async () => {
|
||||||
|
api.initiateRto.mockRejectedValue({ response: { status: 400, data: { message: 'a parcel that is delivered cannot be returned' } } });
|
||||||
|
const onClose = jest.fn();
|
||||||
|
wrap(<StartRtoModal row={ROW} onClose={onClose} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Return reason'), { target: { value: 'receiver_refused' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Start return/ }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('cannot be returned');
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resolve return dialog', () => {
|
||||||
|
it.each([
|
||||||
|
['cancel', 'Re-attempt delivery', 'cancelRto'],
|
||||||
|
['complete', 'Mark returned', 'completeRto'],
|
||||||
|
])('%s calls the matching endpoint', async (mode, button, fn) => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
wrap(<ResolveRtoModal row={{ ...ROW, orderstatus: 'rto' }} mode={mode} onClose={onClose} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('Resolution note'), { target: { value: 'checked with client' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: button }));
|
||||||
|
await waitFor(() => expect(api[fn]).toHaveBeenCalledWith(61, 'checked with client'));
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Returns page', () => {
|
||||||
|
const RETURNS = {
|
||||||
|
success: true,
|
||||||
|
total: 2,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
consignmentid: 61, trackingno: 'DMX00000061', tenantname: 'Acme Foods', status: 'RTO_Initiated',
|
||||||
|
returnreason: 'Receiver refused', attemptcount: 1, returninitiatedat: '2026-10-01T10:00:00+05:30',
|
||||||
|
returndeliveredat: null, milername: 'Ravi K', pickuppincode: '641001', deliverypincode: '641002',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
consignmentid: 62, trackingno: 'DMX00000062', tenantname: 'Beta Co', status: 'Returned_to_Sender',
|
||||||
|
returnreason: 'Delivery attempts exhausted', attemptcount: 3, returninitiatedat: '2026-09-28T10:00:00+05:30',
|
||||||
|
returndeliveredat: '2026-09-30T10:00:00+05:30', milername: '', pickuppincode: '641005', deliverypincode: '641009',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('lists returns, starting on "In return", with actions only for open ones', async () => {
|
||||||
|
api.getReturns.mockResolvedValue(RETURNS);
|
||||||
|
wrap(<Returns />);
|
||||||
|
expect(await screen.findByText('DMX00000061')).toBeInTheDocument();
|
||||||
|
expect(api.getReturns.mock.calls[0][0]).toMatchObject({ status: 'initiated', pageno: 1, pagesize: 50 });
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Mark returned' })).toHaveLength(1);
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Re-attempt delivery' })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks the server for the chosen tab', async () => {
|
||||||
|
api.getReturns.mockResolvedValue(RETURNS);
|
||||||
|
wrap(<Returns />);
|
||||||
|
await screen.findByText('DMX00000061');
|
||||||
|
// The tab row renders before the table, whose header also says Returned.
|
||||||
|
fireEvent.click(screen.getAllByText('Returned')[0]);
|
||||||
|
await waitFor(() => expect(api.getReturns.mock.calls.at(-1)[0]).toMatchObject({ status: 'returned', pageno: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is read-only for a client login', async () => {
|
||||||
|
mockAuth = { user: { email: 'ops@acme.test', role: 'manager', tenantid: 7 }, isClient: true };
|
||||||
|
api.getReturns.mockResolvedValue(RETURNS);
|
||||||
|
wrap(<Returns />);
|
||||||
|
await screen.findByText('DMX00000061');
|
||||||
|
expect(screen.queryByRole('button', { name: 'Mark returned' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says plainly when the backend has no returns yet', async () => {
|
||||||
|
api.getReturns.mockRejectedValue({ response: { status: 404 } });
|
||||||
|
wrap(<Returns />);
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('deploy the current doormile_backend build');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts days a return has been open, or took', () => {
|
||||||
|
const now = new Date('2026-10-05T10:00:00+05:30');
|
||||||
|
const dayjs = jest.requireActual('dayjs');
|
||||||
|
expect(returnAgeDays(RETURNS.data[0], dayjs(now))).toBe(4);
|
||||||
|
expect(returnAgeDays(RETURNS.data[1], dayjs(now))).toBe(2);
|
||||||
|
expect(returnAgeDays({}, dayjs(now))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user