From e6ad37b20fcae5decb63651715829b4457a94f9e Mon Sep 17 00:00:00 2001 From: Thiru-tenext Date: Sat, 4 Jul 2026 17:08:16 +0530 Subject: [PATCH] added the api for the real data --- .env.example | 21 + docs/Doormile-Hub-Console-API-Reference.md | 889 +++++++++++++++++++++ src/App.jsx | 16 +- src/api/client.js | 106 +++ src/api/hub.js | 147 ++++ src/auth/ProtectedRoute.jsx | 14 + src/auth/session.js | 57 ++ src/layout/MainLayout/Header.jsx | 151 ++-- src/layout/MainLayout/Sidebar.jsx | 14 +- src/menu/navItems.jsx | 13 +- src/pages/Dashboard.jsx | 177 ++-- src/pages/auth/Login.jsx | 72 +- src/pages/operations/Dispatch.jsx | 185 +++-- src/pages/operations/HubSettings.jsx | 314 ++++++++ src/pages/operations/Inbound.jsx | 167 +++- src/pages/operations/Inventory.jsx | 285 ------- src/pages/operations/OrderAssignment.jsx | 284 +++++-- src/pages/operations/RiderRoutes.jsx | 197 +++-- src/pages/operations/Riders.jsx | 227 +++--- src/pages/operations/Routing.jsx | 267 +++---- src/pages/operations/TrackingMap.jsx | 299 ++++--- vite.config.js | 13 +- 22 files changed, 2854 insertions(+), 1061 deletions(-) create mode 100644 .env.example create mode 100644 docs/Doormile-Hub-Console-API-Reference.md create mode 100644 src/api/client.js create mode 100644 src/api/hub.js create mode 100644 src/auth/ProtectedRoute.jsx create mode 100644 src/auth/session.js create mode 100644 src/pages/operations/HubSettings.jsx delete mode 100644 src/pages/operations/Inventory.jsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..58fd30b --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Doormile Hub Console — frontend configuration +# Copy to `.env` (or `.env.local`) only if you need to override the defaults. + +# ── Development ────────────────────────────────────────────────────────────── +# By default the dev server proxies every /api request to the backend +# (see `server.proxy` in vite.config.js). This makes browser requests +# SAME-ORIGIN, so the backend's localhost-only CORS allowlist never applies — +# it works whether you open the app on localhost, a LAN IP, or a hostname. +# +# Leave VITE_API_BASE_URL UNSET in dev so the proxy is used. To point dev at a +# different backend, change the proxy `target` in vite.config.js. + +# ── Production ─────────────────────────────────────────────────────────────── +# A production build calls the API host directly (default below). The backend +# must allow your deployed origin via CORS, or serve the app behind a reverse +# proxy that forwards /api. Override the host here if needed: +# VITE_API_BASE_URL=https://api.doormile.com + +# Shared key for the internal assignment engine (auto-assign / reassign). +# Sent as the `X-Internal-Key` header instead of a Bearer token. +# VITE_INTERNAL_KEY=doormile-internal-2024 diff --git a/docs/Doormile-Hub-Console-API-Reference.md b/docs/Doormile-Hub-Console-API-Reference.md new file mode 100644 index 0000000..593a061 --- /dev/null +++ b/docs/Doormile-Hub-Console-API-Reference.md @@ -0,0 +1,889 @@ +# Doormile Hub Console — Frontend Integration Guide + +**Base URL:** `https://api.doormile.com` +**Auth:** Bearer token in `Authorization` header on every authenticated request +**Content-Type:** `application/json` on all POST / PATCH calls + +## How the flow works + +1. Staff opens the console → Login page +2. `POST /api/v1/hub/login` → receives JWT + hub context +3. Store token + hub context in localStorage +4. Every page reads hub name/city from stored context (no extra call) +5. Every API call sends `Authorization: Bearer ` +6. On 401 → clear localStorage → redirect to `/login` + +--- + +## 1. Authentication + +### POST /api/v1/hub/login +No auth header needed. + +Request body: + +```json +{ + "email": "hub.coimbatore@doormile.in", + "password": "password123" +} +``` + +Response: + +```json +{ + "success": true, + "token": "eyJhbGci...", + "hub": { + "hubid": 1, + "hubname": "Coimbatore Jupiter Hub", + "hubtype": "sorting_center", + "city": "Coimbatore", + "capacity": 50 + }, + "staff": { + "displayname": "Coimbatore Hub Staff", + "email": "hub.coimbatore@doormile.in", + "role": 6 + }, + "is_doormile_staff": true +} +``` + +What to store in localStorage after login: + +```js +localStorage.setItem('hub_token', data.token) +localStorage.setItem('hub_context', JSON.stringify(data.hub)) +localStorage.setItem('hub_staff', JSON.stringify(data.staff)) +localStorage.setItem('hub_is_doormile', String(data.is_doormile_staff)) +``` + +Test accounts: + +| Email | Password | Hub | Access | +|---|---|---|---| +| hub.coimbatore@doormile.in | password123 | Coimbatore Jupiter Hub | Full (Doormile staff) | +| hub.hyderabad@doormile.in | password123 | Hyderabad hub | Full (Doormile staff) | +| hub.bangalore@doormile.in | password123 | Bangalore hub | Full (Doormile staff) | +| hub.chennai@doormile.in | password123 | Chennai hub | Full (Doormile staff) | +| hub@kpmtravels.in | password123 | Hub 1 | Restricted (partner — no hub management) | + +--- + +## 2. Dashboard + +### GET /api/v1/hub/dashboard +Returns live KPI stats for the logged-in staff's hub. + +Response: + +```json +{ + "success": true, + "data": { + "parcels_received_today": 142, + "milers_available": 6, + "milers_on_duty": 12, + "pending_pickups": 8, + "batches_sent_today": 3, + "exceptions": 2 + } +} +``` + +Which UI element uses which field: + +| Dashboard card | Field | +|---|---| +| Total Parcels Received | `parcels_received_today` | +| Available Milers | `milers_available` | +| Milers on Duty | `milers_on_duty` | +| Pending Pickups | `pending_pickups` | +| Batches Sent Today | `batches_sent_today` | +| Needs Checking | `exceptions` | + +**Note:** Hub name shown in the header/title comes from localStorage (`hub_context.hubname`) — not from this endpoint. No extra call needed. + +--- + +## 3. Inbound — Receive Parcels + +### GET /api/v1/hub/inbound/today +Returns all parcels inbounded at this hub today. Now returns `sendername`, `originname`, `destinationname`, `temperature` (readable names instead of raw IDs and pincodes). + +Response: + +```json +{ + "success": true, + "data": [ + { + "bookingid": 15, + "trackingnumber": "DM-882201", + "sendername": "Acme Corp", + "originname": "Mumbai Hub", + "destinationname": "Dwarka Sec 12, Coimbatore", + "weight": "2.4 kg", + "condition": "Good", + "temperature": "N/A", + "shelf": "Zone A (Shelf 1)", + "inboundedat": "2026-07-04T09:15:00Z" + } + ], + "total": 1 +} +``` + +### POST /api/v1/hub/bookings/:id/inbound +Scan a parcel in. `:id` is the booking/consignment ID. + +Request body: + +```json +{ + "tracking_id": "DM-882201", + "condition": "Good", + "temperature": "N/A", + "shelf": "Zone A (Shelf 1)", + "weight": "2.4 kg" +} +``` + +Condition values: `Good` · `Damaged Box` · `Wet / Crushed` · `Missing Label` +Temperature: Free text — "4.1°C" for cold chain items, "N/A" for dry goods + +Response: + +```json +{ + "success": true, + "data": { + "bookingid": 15, + "trackingnumber": "DM-882201", + "recommended_shelf": "Zone A (Shelf 1)", + "status": "At_Hub" + } +} +``` + +Shelf recommendation logic (backend handles this automatically): + +- Condition contains Damaged / Wet / Missing → Exception Area +- Temperature is not N/A → Zone C (Cold Room) +- Otherwise → Zone A or Zone B based on destination + +Error — tracking ID not found: + +```json +{ + "success": false, + "message": "booking not found" +} +``` + +HTTP 404. + +--- + +## 4. Order Assignment — Pickup Requests + +### GET /api/v1/hub/bookings/unassigned +Returns all pending pickup requests for this hub's city. + +Response: + +```json +{ + "success": true, + "data": [ + { + "bookingid": 21, + "customerName": "Ramesh K.", + "pickupaddress": "Gandhipuram, Coimbatore", + "deliveryaddress": "Hitech City, Hyderabad", + "packagedescription": "Small Box, 2kg", + "createdat": "2026-07-04T08:45:00Z", + "status": "Pending_Pickup" + } + ], + "total": 3 +} +``` + +For assigning a miler to these bookings, see **Section 14 — Hub Assignment Override**. + +--- + +## 5. Dispatch & Batches + +### GET /api/v1/hub/batches +Returns all outgoing batches for this hub. + +Response: + +```json +{ + "success": true, + "data": [ + { + "tripsheetid": 1, + "batchlabel": "BATCH-9281", + "batchkind": "transfer", + "destinationlabel": "Mumbai Hub (BOM-02)", + "vehicle": "Truck DL-01-BZ-8055", + "itemcount": 154, + "status": "Draft", + "createdat": "2026-07-04T08:00:00Z", + "dispatchtime": null + } + ], + "total": 1 +} +``` + +Status values: `Draft` → `Ready` → `Dispatched` +Batchkind values: `local` · `transfer` + +### POST /api/v1/hub/batches +Create a new outgoing batch. + +Request body: + +```json +{ + "route": "Transfer to Mumbai Hub", + "destination": "Mumbai Hub (BOM-02)", + "vehicle": "Truck DL-01-BZ-8055", + "parcels_count": 154, + "kind": "transfer" +} +``` + +Response: + +```json +{ + "success": true, + "data": { + "tripsheetid": 2, + "batchlabel": "BATCH-9282", + "status": "Draft" + } +} +``` + +### PATCH /api/v1/hub/batches/:id/status +Move a batch through its status flow. + +Request body — mark as ready: + +```json +{ "status": "Ready" } +``` + +Request body — dispatch (send out): + +```json +{ "status": "Dispatched" } +``` + +Response: + +```json +{ + "success": true, + "data": { + "tripsheetid": 1, + "status": "Ready", + "dispatchtime": null + } +} +``` + +Invalid status transition → 400: + +```json +{ + "success": false, + "message": "invalid status" +} +``` + +--- + +## 6. Milers + +### GET /api/v1/hub/milers +Returns all milers assigned to this hub, now with operational stats per miler. + +Response: + +```json +{ + "success": true, + "data": [ + { + "userid": 4, + "displayname": "Rajesh Kumar", + "phone": "+91 98765 43210", + "vehicleid": 1, + "hubid": 1, + "availabilitystatus": "Available", + "rating": 4.8, + "completedorders": 45, + "cancelledorders": 2, + "currentlat": 11.0168, + "currentlon": 76.9558, + "device_token": "fcm_token_here", + "zones": ["641001", "641012"], + "assignedload": 2, + "capacity": 30, + "pickupspending": 1, + "codcollected": 4500.00, + "codpending": 1200.00, + "checkinat": "2026-07-04T08:12:00Z", + "hoursactive": 6.4, + "isverified": true + } + ], + "total": 6 +} +``` + +Availability status values: `Available` · `Assigned` · `On_Break` · `Offline` + +The `zones`, `assignedload`, `capacity`, `pickupspending`, `codcollected`, `codpending`, `checkinat`, `hoursactive`, `isverified` fields were the empty fields the Milers page was trying to show. Now populated from real data. + +### POST /api/v1/admin/milers +Create (onboard) a new miler. Hub JWT is accepted here. + +Request body: + +```json +{ + "displayname": "Muthu Kumar", + "phone": "+91 90011 22334", + "hubid": 1, + "vehicleid": 2, + "availabilitystatus": "Available" +} +``` + +Response: + +```json +{ + "success": true, + "data": { + "userid": 10, + "displayname": "Muthu Kumar" + } +} +``` + +### PATCH /api/v1/admin/milers/:id +Update a miler's details or status. + +Request body (any subset of fields): + +```json +{ + "availabilitystatus": "On_Break", + "hubid": 2 +} +``` + +### DELETE /api/v1/admin/milers/:id +Remove a miler from the roster. + +Response: + +```json +{ + "success": true, + "message": "miler removed" +} +``` + +--- + +## 7. Routing — Where Does It Go? + +### GET /api/v1/hub/routing/:trackingno +Scan a parcel and get its sort destination. `:trackingno` is the tracking number scanned at the sort station. + +Response: + +```json +{ + "success": true, + "data": { + "trackingno": "DM-TRK-0015", + "destination": "Peelamedu, 641004", + "recommendedshelf": "Zone B", + "nexthop": "Local delivery", + "condition": "Good", + "iscoldchain": false, + "weight": "2.4 kg", + "customername": "Ramesh K.", + "bookingid": 15 + } +} +``` + +`nexthop` values: `Local delivery` · `Transfer to Mumbai Hub` (or whichever destination hub). +Use `recommendedshelf` to show the big shelf indicator UI. + +Error — tracking number not found → 404. Show a "Parcel not found" error state. + +```json +{ + "success": false, + "message": "parcel not found" +} +``` + +--- + +## 8. Rider Routes + +### GET /api/v1/hub/rider-routes +Returns all milers at this hub with their planned stops today. + +### GET /api/v1/hub/milers/:id/route +Returns the planned stops for a single miler. `:id` is the miler user ID. + +Response (same `data[]` shape for both; the single-miler endpoint returns one entry): + +```json +{ + "success": true, + "data": [ + { + "mileruserid": 4, + "milername": "Rajesh Kumar", + "mode": "pickup", + "totalstops": 5, + "completedstops": 2, + "totaldistance_km": 12.4, + "stops": [ + { "seq": 1, "address": "Gandhipuram, Coimbatore", "lat": 11.0168, "lon": 76.9558, "items": 1, "bookingid": 20, "status": "completed", "eta_minutes": 0 }, + { "seq": 2, "address": "RS Puram, Coimbatore", "lat": 11.001, "lon": 76.951, "items": 2, "bookingid": 21, "status": "pending", "eta_minutes": 12 } + ] + } + ] +} +``` + +`mode` values: `pickup` · `delivery` +Stop status values: `pending` · `in_progress` · `completed` + +Pass `stops[].lat/lon` to OSRM to draw the route line — the OSRM call stays client-side. + +--- + +## 9. Live Trucks + +### GET /api/v1/hub/tripsheets/in-transit +Returns transfer trucks currently in transit, with interpolated live positions. +Poll this every 5 seconds alongside miler locations. + +Response: + +```json +{ + "success": true, + "data": [ + { + "tripsheetid": 3, + "tripsheetno": "DM-TS-0003", + "label": "Chennai Jupiter Hub → Coimbatore Jupiter Hub", + "originlat": 13.0827, "originlon": 80.2707, + "destlat": 11.0168, "destlon": 76.9558, + "currentlat": 12.1, "currentlon": 78.9, + "status": "In_Transit", + "progresspct": 40, + "vehicleno": "TN-04-AX-8822", + "itemcount": 154, + "eta_minutes": 180 + } + ] +} +``` + +`currentlat/lon` gives the truck's position on the map. Replace the fake animated truck with these real coordinates. + +--- + +## 10. Arriving Vehicles + +### GET /api/v1/hub/inbound/vehicles +Returns trucks arriving at this hub (Dashboard → "Trucks Arriving" table). + +Response: + +```json +{ + "success": true, + "data": [ + { + "vehicleno": "TN-04-AX-8822", + "origin": "Chennai Jupiter Hub", + "tripsheetid": 3, + "status": "On the way", + "eta": "2 hrs 30 min", + "unloadedpct": 0, + "itemcount": 154 + } + ] +} +``` + +Status values: `On the way` · `Unloading` +(Dispatched maps to `On the way`, Arrived maps to `Unloading`.) + +--- + +## 11. Activity Feed + +### GET /api/v1/hub/activity?limit=10 +Returns a real event feed for this hub (UNION across 4 sources: inbound, dispatch, exceptions, sorting). + +Response: + +```json +{ + "success": true, + "data": [ + { "time": "2026-07-04T11:24:00Z", "type": "exception", "text": "Exception raised: Damaged on DM-TRK-999999" }, + { "time": "2026-07-04T11:22:00Z", "type": "inbound", "text": "Received parcel DM-TRK-999999 from Coimbatore Jupiter Hub" }, + { "time": "2026-07-04T11:20:00Z", "type": "dispatch", "text": "Dispatched batch DM-TS-0003 to Mumbai Hub" } + ], + "total": 3 +} +``` + +Type values: `inbound` · `dispatch` · `exception` · `sorting` +Use `type` to pick the icon color — same color scheme as the existing mock. + +--- + +## 12. Zones + +### GET /api/v1/hub/zones +Returns the delivery zone breakdown for this hub (Dashboard → "Delivery Areas"). + +Response: + +```json +{ + "success": true, + "data": [ + { "zone": "641001", "zonename": "Gandhipuram", "parcels": 12, "milers": 2, "status": "Active" }, + { "zone": "641004", "zonename": "Peelamedu", "parcels": 8, "milers": 1, "status": "Active" }, + { "zone": "641012", "zonename": "Jupiter Nagar","parcels": 3, "milers": 0, "status": "Need Milers" } + ] +} +``` + +Status values: `Active` · `Need Milers` — use this to color the status chip. + +--- + +## 13. Notifications + +### GET /api/v1/hub/notifications +Returns notifications for the header bell icon, generated from real events. + +Response: + +```json +{ + "success": true, + "data": [ + { "id": 1, "title": "Exception: Damaged", "type": "exception", "time": "3 min ago", "read": false }, + { "id": 2, "title": "Truck arriving from Chennai Hub", "type": "inbound", "time": "5 min ago", "read": false }, + { "id": 3, "title": "Pickup waiting 30+ min", "type": "alert", "time": "12 min ago", "read": false } + ], + "total": 3 +} +``` + +Type values: `exception` · `inbound` · `warning` · `alert` + +### PATCH /api/v1/hub/notifications/:id/read +Mark a notification as read. + +Response: + +```json +{ "success": true } +``` + +On bell click → `GET /hub/notifications`. On item click → `PATCH /hub/notifications/:id/read`, then mark it read in local state. + +--- + +## 14. Hub Assignment Override + +The old admin assign endpoint (`POST /admin/bookings/:id/assign-miler`) returned **403** for hub staff. Two new hub-scoped endpoints replace it. + +### POST /api/v1/hub/bookings/:id/assign-miler +Manual assign — hub staff picks the miler. + +Request body: + +```json +{ "mileruserid": 4 } +``` + +Response: + +```json +{ + "success": true, + "data": { + "bookingid": 21, + "mileruserid": 4, + "milername": "Rajesh Kumar", + "status": "Miler_Assigned" + } +} +``` + +Use this on the **Pickup Requests** page when staff manually selects a miler from the dialog. + +### POST /api/v1/hub/bookings/:id/auto-assign +Auto-assign — AI engine picks the miler. + +Request body: + +```json +{} +``` + +Response (success): + +```json +{ + "success": true, + "data": { + "mileruserid": 4, + "milername": "Rajesh Kumar", + "distance_km": 0.4 + } +} +``` + +Response (no miler) — HTTP 422: + +```json +{ + "success": false, + "message": "No eligible miler found in range" +} +``` + +Response (timeout) — HTTP 202: + +```json +{ + "success": true, + "message": "assignment in progress" +} +``` + +Use this for the **Auto-Assign** bulk button. Loop over selected booking IDs and call this for each one. + +**Backend implementation notes:** shared `AssignMilerToBooking()` service (`booking_assignment_service.go`), `TryAssignOnce()` single-attempt wrapper on `crm_assignment`, `assignedbyuserid` is nullable for hub-initiated assignments, and `AdminAssignMiler` now sends FCM to the miler. + +--- + +## 15. Hub Management (Doormile staff only) + +These endpoints return 403 for partner accounts (`is_doormile_staff = false`). +Use the `is_doormile_staff` flag from localStorage to show/hide the UI for these. + +### GET /api/v1/hub/hubs +List all hubs in the logged-in staff's city. Now includes `lat`/`lon` for map pins. + +Response: + +```json +{ + "success": true, + "data": [ + { + "hubid": 1, + "hubname": "Coimbatore Jupiter Hub", + "hubtype": "sorting_center", + "capacity": 50, + "status": "active", + "has_staff": true, + "lat": 11.0168, + "lon": 76.9558 + }, + { + "hubid": 2, + "hubname": "Coimbatore Spoke 1", + "hubtype": "spoke", + "capacity": 50, + "status": "active", + "has_staff": false, + "lat": 11.0210, + "lon": 76.9610 + } + ] +} +``` + +Use `lat/lon` to place hub pins on the Live Map. + +### POST /api/v1/hub/hubs +Create a new hub in the staff's city. + +Request body: + +```json +{ + "hubname": "Coimbatore RS Puram Hub", + "hubtype": "spoke", + "capacity": 30, + "contact": "+91 98765 00000", + "address": "RS Puram, Coimbatore", + "pincode": "641002" +} +``` + +**Note:** `applocationid` (city) is set automatically from the logged-in staff's hub — cannot be overridden. + +### POST /api/v1/hub/staff +Create a hub staff login for any hub in the city. + +Request body: + +```json +{ + "hubid": 2, + "email": "hub.rspuram@doormile.in", + "password": "securepassword", + "displayname": "RS Puram Hub Staff", + "tenantid": null +} +``` + +Set `tenantid` to the partner's tenant ID for partner accounts, `null` for Doormile staff. + +Response: + +```json +{ + "success": true, + "data": { + "staffid": 6, + "email": "hub.rspuram@doormile.in", + "hubid": 2 + } +} +``` + +--- + +## Error response shape + +All errors follow the same shape: + +```json +{ + "success": false, + "message": "human readable error description" +} +``` + +| HTTP code | Meaning | +|---|---| +| 400 | Bad request — invalid body or status transition | +| 401 | Token expired or missing → redirect to /login | +| 202 | Accepted — async assignment in progress | +| 403 | Forbidden — partner account trying Doormile-only endpoint | +| 404 | Resource not found (booking ID, miler ID, tracking no etc.) | +| 422 | Unprocessable — no eligible miler found in range | +| 500 | Server error — show generic "something went wrong" toast | + +--- + +## Page-to-endpoint map (quick reference) + +| Page | Endpoints called | +|---|---| +| Login | POST /hub/login | +| Dashboard | GET /hub/dashboard · GET /hub/inbound/vehicles · GET /hub/activity · GET /hub/zones | +| Receive Parcels | GET /hub/inbound/today · POST /hub/bookings/:id/inbound | +| Pickup Requests | GET /hub/bookings/unassigned · GET /hub/milers · POST /hub/bookings/:id/assign-miler · POST /hub/bookings/:id/auto-assign | +| Where Does It Go? (Routing) | GET /hub/routing/:trackingno | +| Dispatch | GET /hub/batches · POST /hub/batches · PATCH /hub/batches/:id/status | +| Milers | GET /hub/milers · POST /admin/milers · PATCH /admin/milers/:id · DELETE /admin/milers/:id | +| Rider Routes | GET /hub/rider-routes · GET /hub/milers/:id/route | +| Live Map | GET /admin/milers/locations (poll 5s) · GET /hub/tripsheets/in-transit (poll 5s) · GET /hub/hubs | +| Header — Notifications | GET /hub/notifications · PATCH /hub/notifications/:id/read | +| Hub Settings | GET /hub/hubs · POST /hub/hubs · POST /hub/staff | + +--- + +## Frontend implementation notes + +**Token storage:** + +```js +// On login success: +localStorage.setItem('hub_token', response.token) +localStorage.setItem('hub_context', JSON.stringify(response.hub)) +localStorage.setItem('hub_staff', JSON.stringify(response.staff)) +localStorage.setItem('hub_is_doormile', String(response.is_doormile_staff)) + +// On every API call: +headers: { 'Authorization': `Bearer ${localStorage.getItem('hub_token')}` } + +// On logout or 401: +localStorage.clear() +navigate('/login') +``` + +**Show hub name dynamically (not hardcoded):** + +```js +const hub = JSON.parse(localStorage.getItem('hub_context')) +// hub.hubname → "Coimbatore Jupiter Hub" +// hub.city → "Coimbatore" +// hub.hubid → 1 +``` + +**Hide hub management UI for partners:** + +```js +const isDoormile = localStorage.getItem('hub_is_doormile') === 'true' +// Show "Add New Hub" / "Add Staff" buttons only if isDoormile === true +// KPM partner account gets false → those buttons hidden +``` + +**On 401 — token expired, force re-login:** + +```js +if (response.status === 401) { + localStorage.clear() + window.location.href = '/login' +} +``` + +**Polling intervals:** + +- Miler locations → every 5 seconds +- Truck positions → every 5 seconds (same call cycle) +- Dashboard stats → every 30 seconds +- Notifications → every 60 seconds + +--- + +*All endpoints live at `https://api.doormile.com` · Hub Console Backend v1.0 complete* diff --git a/src/App.jsx b/src/App.jsx index 6eea70c..1e51028 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import { Box, CircularProgress } from '@mui/material'; import MainLayout from '@/layout/MainLayout'; import MinimalLayout from '@/layout/MinimalLayout'; +import ProtectedRoute from '@/auth/ProtectedRoute'; const load = (factory) => { const C = lazy(factory); @@ -23,17 +24,26 @@ const load = (factory) => { export default function App() { return ( - {/* Shell pages */} - }> + {/* Shell pages — require a valid hub session */} + + + + } + > import('@/pages/Dashboard'))} /> import('@/pages/operations/Inbound'))} /> import('@/pages/operations/Routing'))} /> import('@/pages/operations/Dispatch'))} /> - import('@/pages/operations/Inventory'))} /> import('@/pages/operations/TrackingMap'))} /> import('@/pages/operations/OrderAssignment'))} /> import('@/pages/operations/Riders'))} /> import('@/pages/operations/RiderRoutes'))} /> + {load(() => import('@/pages/operations/HubSettings'))}} + /> {/* Full-bleed pages */} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..bae3a03 --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,106 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Low-level HTTP client for the Doormile Hub Console. +// +// Every authenticated call automatically attaches `Authorization: Bearer ` +// (read from localStorage) and `Content-Type: application/json`. A 401 clears the +// stored session and bounces the user to /login. Pages should never call fetch() +// directly — import the typed functions from `./hub` instead. +// ───────────────────────────────────────────────────────────────────────────── + +import { getToken, clearSession } from '@/auth/session'; + +// In dev, default to same-origin ('') so requests hit `/api/...` and Vite proxies +// them to the backend (avoids the backend's localhost-only CORS allowlist). +// In production, default to the real API host. Either can be overridden with +// VITE_API_BASE_URL. +const BASE_URL = + import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.DEV ? '' : 'https://api.doormile.com'); +// The internal assignment engine expects a shared key instead of a Bearer token. +const INTERNAL_KEY = import.meta.env.VITE_INTERNAL_KEY || 'doormile-internal-2024'; + +export class ApiError extends Error { + constructor(message, status, data) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } +} + +function redirectToLogin() { + clearSession(); + if (window.location.pathname !== '/login') { + window.location.assign('/login'); + } +} + +/** + * Perform an API request. + * @param {string} path Path beginning with `/` (e.g. `/api/v1/hub/dashboard`). + * @param {object} options + * @param {string} [options.method='GET'] + * @param {object} [options.body] Serialised to JSON when present. + * @param {boolean} [options.auth=true] Attach the Bearer token. + * @param {boolean} [options.internal=false] Send the X-Internal-Key header instead of Bearer. + */ +export async function request(path, { method = 'GET', body, auth = true, internal = false, headers = {} } = {}) { + const opts = { method, headers: { ...headers } }; + + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + + if (internal) { + opts.headers['X-Internal-Key'] = INTERNAL_KEY; + } else if (auth) { + const token = getToken(); + if (token) opts.headers.Authorization = `Bearer ${token}`; + } + + let res; + try { + res = await fetch(`${BASE_URL}${path}`, opts); + } catch { + throw new ApiError('Could not reach the server. Check your connection and try again.', 0); + } + + // 401 → session is dead. Clear and send to login before anyone reads the body. + if (res.status === 401 && !internal) { + redirectToLogin(); + throw new ApiError('Your session has expired. Please sign in again.', 401); + } + + // Some endpoints (e.g. DELETE) may return an empty body. + let data = null; + const text = await res.text(); + if (text) { + try { + data = JSON.parse(text); + } catch { + data = null; + } + } + + if (!res.ok) { + const message = + (data && data.message) || + (res.status === 403 + ? 'You do not have permission to do that.' + : res.status === 404 + ? 'The requested resource was not found.' + : res.status >= 500 + ? 'Something went wrong on our side. Please try again.' + : `Request failed (${res.status}).`); + throw new ApiError(message, res.status, data); + } + + return data; +} + +export const http = { + get: (path, options) => request(path, { ...options, method: 'GET' }), + post: (path, body, options) => request(path, { ...options, method: 'POST', body }), + patch: (path, body, options) => request(path, { ...options, method: 'PATCH', body }), + del: (path, options) => request(path, { ...options, method: 'DELETE' }) +}; diff --git a/src/api/hub.js b/src/api/hub.js new file mode 100644 index 0000000..7e265fb --- /dev/null +++ b/src/api/hub.js @@ -0,0 +1,147 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Doormile Hub Console — API surface. +// One function per backend endpoint. Pages import from here only; they never +// build URLs or call fetch() themselves. All functions return the parsed +// response body and throw an ApiError on failure. +// ───────────────────────────────────────────────────────────────────────────── + +import { http } from './client'; + +const V1 = '/api/v1'; + +// ── 1. Authentication ──────────────────────────────────────────────────────── +export function login(email, password) { + // No auth header on login. + return http.post(`${V1}/hub/login`, { email, password }, { auth: false }); +} + +// ── 2. Dashboard ───────────────────────────────────────────────────────────── +export function getDashboard() { + return http.get(`${V1}/hub/dashboard`); +} + +// ── 3. Inbound — receive parcels ───────────────────────────────────────────── +export function getInboundToday() { + return http.get(`${V1}/hub/inbound/today`); +} + +/** + * Scan a parcel in. + * @param {number|string} bookingId Booking / consignment ID (the path :id). + * @param {object} payload { tracking_id, condition, temperature, shelf, weight } + */ +export function inboundBooking(bookingId, payload) { + return http.post(`${V1}/hub/bookings/${bookingId}/inbound`, payload); +} + +// ── 4. Order assignment — pickup requests ──────────────────────────────────── +export function getUnassignedBookings() { + return http.get(`${V1}/hub/bookings/unassigned`); +} + +/** Manual assign — hub staff picks the miler (hub-scoped, accepts the hub JWT). */ +export function assignMiler(bookingId, mileruserid) { + return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid }); +} + +/** Auto-assign — the AI engine picks the miler. 200 ok · 422 no miler · 202 in progress. */ +export function autoAssignBooking(bookingId) { + return http.post(`${V1}/hub/bookings/${bookingId}/auto-assign`, {}); +} + +// ── 5. Dispatch & batches ──────────────────────────────────────────────────── +export function getBatches() { + return http.get(`${V1}/hub/batches`); +} + +/** + * Create an outgoing batch. + * @param {object} payload { route, destination, vehicle, parcels_count, kind } + */ +export function createBatch(payload) { + return http.post(`${V1}/hub/batches`, payload); +} + +/** Move a batch through Draft → Ready → Dispatched. */ +export function updateBatchStatus(tripsheetId, status) { + return http.patch(`${V1}/hub/batches/${tripsheetId}/status`, { status }); +} + +// ── 6. Milers ──────────────────────────────────────────────────────────────── +export function getMilers() { + return http.get(`${V1}/hub/milers`); +} + +/** Onboard a miler. Hub JWT is accepted on the admin endpoint. */ +export function createMiler(payload) { + return http.post(`${V1}/admin/milers`, payload); +} + +export function updateMiler(userId, payload) { + return http.patch(`${V1}/admin/milers/${userId}`, payload); +} + +export function deleteMiler(userId) { + return http.del(`${V1}/admin/milers/${userId}`); +} + +// ── 7. Live map ────────────────────────────────────────────────────────────── +export function getMilerLocations() { + return http.get(`${V1}/admin/milers/locations`); +} + +// ── 8. Hub management (Doormile staff only) ────────────────────────────────── +export function getHubs() { + return http.get(`${V1}/hub/hubs`); +} + +export function createHub(payload) { + return http.post(`${V1}/hub/hubs`, payload); +} + +/** Create a hub staff login. `tenantid` = partner tenant ID, or null for Doormile. */ +export function createStaff(payload) { + return http.post(`${V1}/hub/staff`, payload); +} + +// ── 9. Routing — Where Does It Go? ─────────────────────────────────────────── +/** Scan a parcel and get its sort destination. 404 if the tracking number is unknown. */ +export function getRouting(trackingno) { + return http.get(`${V1}/hub/routing/${encodeURIComponent(trackingno)}`); +} + +// ── 10. Rider Routes ───────────────────────────────────────────────────────── +export function getRiderRoutes() { + return http.get(`${V1}/hub/rider-routes`); +} + +export function getMilerRoute(milerUserId) { + return http.get(`${V1}/hub/milers/${milerUserId}/route`); +} + +// ── 11. Live trucks (map) ──────────────────────────────────────────────────── +export function getTripsheetsInTransit() { + return http.get(`${V1}/hub/tripsheets/in-transit`); +} + +// ── 12. Dashboard panels ───────────────────────────────────────────────────── +export function getInboundVehicles() { + return http.get(`${V1}/hub/inbound/vehicles`); +} + +export function getActivity(limit = 10) { + return http.get(`${V1}/hub/activity?limit=${limit}`); +} + +export function getZones() { + return http.get(`${V1}/hub/zones`); +} + +// ── 13. Notifications ──────────────────────────────────────────────────────── +export function getNotifications() { + return http.get(`${V1}/hub/notifications`); +} + +export function markNotificationRead(id) { + return http.patch(`${V1}/hub/notifications/${id}/read`, {}); +} diff --git a/src/auth/ProtectedRoute.jsx b/src/auth/ProtectedRoute.jsx new file mode 100644 index 0000000..ccfa71d --- /dev/null +++ b/src/auth/ProtectedRoute.jsx @@ -0,0 +1,14 @@ +import { Navigate } from 'react-router-dom'; +import { isAuthenticated, isDoormileStaff } from './session'; + +// Guards the authenticated app shell. Without a token → bounce to /login. +// `requireDoormile` additionally blocks partner accounts from Doormile-only pages. +export default function ProtectedRoute({ children, requireDoormile = false }) { + if (!isAuthenticated()) { + return ; + } + if (requireDoormile && !isDoormileStaff()) { + return ; + } + return children; +} diff --git a/src/auth/session.js b/src/auth/session.js new file mode 100644 index 0000000..7bf3bf5 --- /dev/null +++ b/src/auth/session.js @@ -0,0 +1,57 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Session storage helpers. The hub JWT + context live in localStorage so every +// page can read the hub name / staff / role without an extra network call. +// Keys are namespaced with `hub_` to match the backend integration guide. +// ───────────────────────────────────────────────────────────────────────────── + +const KEYS = { + token: 'hub_token', + context: 'hub_context', + staff: 'hub_staff', + isDoormile: 'hub_is_doormile' +}; + +function readJson(key) { + const raw = localStorage.getItem(key); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Persist everything returned by POST /api/v1/hub/login. */ +export function setSession({ token, hub, staff, is_doormile_staff }) { + localStorage.setItem(KEYS.token, token || ''); + localStorage.setItem(KEYS.context, JSON.stringify(hub || {})); + localStorage.setItem(KEYS.staff, JSON.stringify(staff || {})); + localStorage.setItem(KEYS.isDoormile, String(Boolean(is_doormile_staff))); +} + +export function clearSession() { + Object.values(KEYS).forEach((k) => localStorage.removeItem(k)); +} + +export function getToken() { + return localStorage.getItem(KEYS.token); +} + +/** The hub the logged-in staff belongs to: { hubid, hubname, hubtype, city, capacity }. */ +export function getHubContext() { + return readJson(KEYS.context) || {}; +} + +/** The signed-in staff member: { displayname, email, role }. */ +export function getStaff() { + return readJson(KEYS.staff) || {}; +} + +/** Doormile staff see hub-management UI; partner accounts do not. */ +export function isDoormileStaff() { + return localStorage.getItem(KEYS.isDoormile) === 'true'; +} + +export function isAuthenticated() { + return Boolean(getToken()); +} diff --git a/src/layout/MainLayout/Header.jsx b/src/layout/MainLayout/Header.jsx index 671a137..38e24f8 100644 --- a/src/layout/MainLayout/Header.jsx +++ b/src/layout/MainLayout/Header.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { AppBar, @@ -32,23 +32,36 @@ import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; import ChatIcon from '@mui/icons-material/Chat'; import LogoutIcon from '@mui/icons-material/Logout'; import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; -import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; -import AcUnitIcon from '@mui/icons-material/AcUnit'; -import TwoWheelerIcon from '@mui/icons-material/TwoWheeler'; import DoneAllIcon from '@mui/icons-material/DoneAll'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import SendIcon from '@mui/icons-material/Send'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; import Logo from '@/components/Logo'; +import { getStaff, getHubContext, clearSession } from '@/auth/session'; +import { getNotifications, markNotificationRead } from '@/api/hub'; const RED = '#C01227'; -const INITIAL_NOTIFICATIONS = [ - { id: 1, icon: LocalShippingOutlinedIcon, title: 'Linehaul MH-04-AX-8822 from Mumbai Hub arrived', time: '5 min ago', to: '/inbound', read: false }, - { id: 2, icon: QrCodeScannerIcon, title: 'Manifest #MNF-2940 sorted & sealed', time: '15 min ago', to: '/dispatch', read: false }, - { id: 3, icon: TwoWheelerIcon, title: 'Last-mile Miler Deepak went online', time: '30 min ago', to: '/dispatch', read: false }, - { id: 4, icon: AcUnitIcon, title: 'Cold Storage Zone C temperature stabilized at 4.2°C', time: '1 hr ago', to: '/inventory', read: true } -]; +// Map a notification `type` to an icon component (real API sends type, not an icon). +const NOTIF_ICON = { + exception: WarningAmberIcon, + inbound: LocalShippingOutlinedIcon, + dispatch: LocalShippingOutlinedIcon, + warning: WarningAmberIcon, + alert: NotificationsActiveIcon +}; + +// Build initials from a display name — falls back to a sensible default. +const toInitials = (name) => + (name || '') + .split(' ') + .filter(Boolean) + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() || 'HB'; const MESSAGES = [ { id: 1, name: 'Devendra (Gate Supervisor)', text: 'Jaipur vehicle is backing into Bay 4 now.', time: '3 min ago', initials: 'DS' }, @@ -56,57 +69,6 @@ const MESSAGES = [ { id: 3, name: 'Neha (Last-mile Dispatcher)', text: 'We need 2 more milers for South Delhi route.', time: '45 min ago', initials: 'ND' } ]; -const NOTIFICATION_DETAILS = { - 1: { - title: 'Linehaul Arrival: Mumbai Hub', - desc: 'Linehaul vehicle MH-04-AX-8822 has checked in at gate terminal Bay 4. Manifest includes 480 mixed corporate shipments.', - stats: [ - { label: 'Carrier Plate', value: 'MH-04-AX-8822' }, - { label: 'Origin Node', value: 'Mumbai Hub (BOM-02)' }, - { label: 'Inbound Sorters', value: 'Allocated (Zone A & C)' }, - { label: 'Cold Chain items', value: '18 items binned' } - ], - actionText: 'Go to Inbound Station', - to: '/inbound' - }, - 2: { - title: 'Sealed Outbound Manifest #MNF-2940', - desc: 'Sorting manifest #MNF-2940 has been closed, sealed, and audited by supervisor Suresh. Packages are loaded in transit cart.', - stats: [ - { label: 'Manifest ID', value: 'MNF-2940' }, - { label: 'Route Zone', value: 'West Delhi (Dwarka)' }, - { label: 'Packages Count', value: '42 items loaded' }, - { label: 'Seal ID Code', value: 'SEAL-DEL-98402' } - ], - actionText: 'Go to Outbound Dispatch', - to: '/dispatch' - }, - 3: { - title: 'Miler Deepak Sharma Online', - desc: 'Last-mile EV miler Deepak Sharma logged into transit terminal app. Courier battery capacity 98% (Fully Charged).', - stats: [ - { label: 'Miler Name', value: 'Deepak Sharma' }, - { label: 'Vehicle Class', value: 'EV Two-wheeler' }, - { label: 'Capacity Limit', value: '35 kg' }, - { label: 'Assigned Route', value: 'South Delhi (Saket)' } - ], - actionText: 'Go to Outbound Dispatch', - to: '/dispatch' - }, - 4: { - title: 'Cold Storage Compliance Logged', - desc: 'Sensor Zone C logged audit snapshot at 12:00 PM. Climate check parameters successfully verified at 4.2°C.', - stats: [ - { label: 'Chamber Area', value: 'Cold Zone Chamber C' }, - { label: 'Sensor Reading', value: '4.2°C (Target: 2-8°C)' }, - { label: 'Log ID', value: 'LOG-C-98248102' }, - { label: 'Status', value: 'Optimal Compliance' } - ], - actionText: 'Go to Storage & Bins', - to: '/inventory' - } -}; - const INITIAL_CHATS = { 1: { name: 'Devendra (Gate Supervisor)', @@ -139,6 +101,16 @@ const INITIAL_CHATS = { export default function Header({ onToggle }) { const navigate = useNavigate(); + const staff = getStaff(); + const hub = getHubContext(); + const staffName = staff.displayname || 'Hub Staff'; + const hubName = hub.hubname || 'Doormile Hub'; + + const handleLogout = () => { + clearSession(); + navigate('/login'); + }; + const [account, setAccount] = useState(null); const [notifAnchor, setNotifAnchor] = useState(null); const [msgAnchor, setMsgAnchor] = useState(null); @@ -149,22 +121,53 @@ export default function Header({ onToggle }) { const [chats, setChats] = useState(INITIAL_CHATS); const [typedMessage, setTypedMessage] = useState(''); - const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS); + const [notifications, setNotifications] = useState([]); const [search, setSearch] = useState(''); const unread = notifications.filter((n) => !n.read).length; - const openNotif = (e) => setNotifAnchor(e.currentTarget); + // Load real notifications from the API (map `type` → an icon component). + const loadNotifications = useCallback(async () => { + try { + const res = await getNotifications(); + setNotifications( + (res?.data || []).map((n) => ({ + id: n.id, + title: n.title, + time: n.time, + read: Boolean(n.read), + type: n.type, + icon: NOTIF_ICON[n.type] || NotificationsNoneIcon + })) + ); + } catch { + // Non-fatal: leave the bell empty if it can't load. + setNotifications([]); + } + }, []); + + useEffect(() => { + loadNotifications(); + const t = setInterval(loadNotifications, 60000); // refresh every 60s + return () => clearInterval(t); + }, [loadNotifications]); + + const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); }; const closeNotif = () => setNotifAnchor(null); - const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); - - const onNotifClick = (n) => { + + const markAllRead = async () => { + const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id); + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + await Promise.allSettled(unreadIds.map((id) => markNotificationRead(id))); + }; + + const onNotifClick = async (n) => { setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x))); closeNotif(); - if (NOTIFICATION_DETAILS[n.id]) { - setSelectedNotif(NOTIFICATION_DETAILS[n.id]); - } else { - navigate(n.to); + try { + await markNotificationRead(n.id); + } catch { + /* best effort */ } }; @@ -283,13 +286,13 @@ export default function Header({ onToggle }) { '&:hover': { bgcolor: 'grey.100' } }} > - RK + {toInitials(staffName)} - Rajesh Kumar + {staffName} - Delhi Hub Manager + {hubName} @@ -380,7 +383,7 @@ export default function Header({ onToggle }) { anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} PaperProps={{ sx: { mt: 1, minWidth: 200 } }} > - { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}> + { setAccount(null); handleLogout(); }} sx={{ color: 'error.main' }}> Logout diff --git a/src/layout/MainLayout/Sidebar.jsx b/src/layout/MainLayout/Sidebar.jsx index 743cea1..f5a5f9a 100644 --- a/src/layout/MainLayout/Sidebar.jsx +++ b/src/layout/MainLayout/Sidebar.jsx @@ -19,6 +19,7 @@ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; import navItems from '@/menu/navItems'; import Logo from '@/components/Logo'; +import { isDoormileStaff } from '@/auth/session'; export const DRAWER_WIDTH = 240; export const MINI_WIDTH = 72; @@ -97,6 +98,17 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) { const location = useLocation(); const navigate = useNavigate(); const expanded = open || isMobile; + const doormile = isDoormileStaff(); + + // Partner accounts don't see Doormile-only groups/items (e.g. Hub Settings). + const groups = useMemo( + () => + navItems + .filter((g) => !g.doormileOnly || doormile) + .map((g) => ({ ...g, items: g.items.filter((i) => !i.doormileOnly || doormile) })) + .filter((g) => g.items.length > 0), + [doormile] + ); const isActive = (url) => !!(url && location.pathname.startsWith(url)); @@ -156,7 +168,7 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) { } }} > - {navItems.map((grp) => ( + {groups.map((grp) => ( {expanded && ( - - {s.value} - + {loading ? ( + + ) : ( + + {s.value} + + )} {s.sub} diff --git a/src/pages/auth/Login.jsx b/src/pages/auth/Login.jsx index 6face46..479cddc 100644 --- a/src/pages/auth/Login.jsx +++ b/src/pages/auth/Login.jsx @@ -11,7 +11,9 @@ import { Button, Checkbox, FormControlLabel, - Link + Link, + Alert, + CircularProgress } from '@mui/material'; import Visibility from '@mui/icons-material/Visibility'; import VisibilityOff from '@mui/icons-material/VisibilityOff'; @@ -20,15 +22,37 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined'; import Logo from '@/components/Logo'; +import { login as loginRequest } from '@/api/hub'; +import { setSession } from '@/auth/session'; export default function Login() { const navigate = useNavigate(); const [show, setShow] = useState(false); - const [auth, setAuth] = useState('hub.delhi@doormile.in'); + const [auth, setAuth] = useState('hub.coimbatore@doormile.in'); const [pwd, setPwd] = useState('password123'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); - const handleSignIn = () => { - navigate('/dashboard'); + const handleSignIn = async () => { + if (loading) return; + setError(''); + if (!auth.trim() || !pwd) { + setError('Enter your email and password.'); + return; + } + setLoading(true); + try { + const data = await loginRequest(auth.trim(), pwd); + if (!data?.success || !data?.token) { + throw new Error(data?.message || 'Login failed.'); + } + setSession(data); + navigate('/dashboard'); + } catch (err) { + setError(err?.message || 'Unable to sign in. Please try again.'); + } finally { + setLoading(false); + } }; return ( @@ -68,7 +92,7 @@ export default function Login() {
handled with ease. - Receive parcels, sort them, and send them out for delivery or transfer to another city — all from one simple screen. + Receive parcels, sort them, and send them out for delivery or transfer to another city all from one simple screen. @@ -129,11 +153,12 @@ export default function Login() { Username / Email - setAuth(e.target.value)} + setAuth(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSignIn()} />
@@ -147,6 +172,7 @@ export default function Login() { placeholder="Enter your password" value={pwd} onChange={(e) => setPwd(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSignIn()} InputProps={{ endAdornment: ( @@ -169,26 +195,34 @@ export default function Login() { - diff --git a/src/pages/operations/Dispatch.jsx b/src/pages/operations/Dispatch.jsx index 46d639a..0a0b7a9 100644 --- a/src/pages/operations/Dispatch.jsx +++ b/src/pages/operations/Dispatch.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Box, Typography, @@ -25,7 +25,8 @@ import { Alert, Avatar, Divider, - useMediaQuery + useMediaQuery, + CircularProgress } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; @@ -38,12 +39,8 @@ import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined'; import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined'; import PageHeader from '@/components/PageHeader'; - -const INITIAL_MANIFESTS = [ - { id: 'BATCH-9281', route: 'Transfer to Mumbai Hub', vehicle: 'Transfer Truck: DL-01-BZ-8055', packagesCount: 154, status: 'Preparing', time: 'Created 1 hr ago', origin: 'Delhi Hub', currentLoc: 'Delhi Hub (Dispatch Dock)', destination: 'Mumbai Hub', kind: 'transfer' }, - { id: 'BATCH-9282', route: 'Local Delivery: Dwarka', vehicle: 'Miler: Deepak Sharma (Two-wheeler)', packagesCount: 12, status: 'Sent', time: 'Sent out 20 min ago', origin: 'Delhi Hub', currentLoc: 'On the way', destination: 'Dwarka, Delhi', kind: 'local' }, - { id: 'BATCH-9283', route: 'Local Delivery: Saket', vehicle: 'Miler: Karthik S. (EV)', packagesCount: 5, status: 'Ready', time: 'Ready 45 min ago', origin: 'Delhi Hub', currentLoc: 'Delhi Hub (Dispatch Dock)', destination: 'Saket, Delhi', kind: 'local' } -]; +import { getBatches, createBatch, updateBatchStatus } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; const STATUS_META = { Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' }, @@ -51,75 +48,147 @@ const STATUS_META = { Sent: { color: '#1E8E3E', bg: '#E6F4EA', label: 'Sent' } }; +// API batch status (Draft/Ready/Dispatched) → the label set this page renders. +const API_TO_UI_STATUS = { Draft: 'Preparing', Ready: 'Ready', Dispatched: 'Sent' }; + +const timeAgo = (iso, verb = 'Created') => { + if (!iso) return `${verb} recently`; + const then = new Date(iso).getTime(); + if (Number.isNaN(then) || then < 1420070400000) return `${verb} recently`; + const mins = Math.round((Date.now() - then) / 60000); + if (mins < 1) return `${verb} just now`; + if (mins < 60) return `${verb} ${mins} min ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${verb} ${hrs} hr ago`; + return `${verb} ${Math.round(hrs / 24)} d ago`; +}; + export default function Dispatch() { const theme = useTheme(); const isMdDown = useMediaQuery(theme.breakpoints.down('md')); + const hub = getHubContext(); + const hubName = hub.hubname || 'this hub'; - const [manifests, setManifests] = useState(INITIAL_MANIFESTS); + // Real backend: tripsheetno / route / destination / item_count / kind (no vehicle field). + const mapBatch = useCallback( + (b) => ({ + tripsheetid: b.tripsheetid, + id: b.batchlabel || b.tripsheetno || `BATCH-${b.tripsheetid}`, + route: b.route || b.destinationlabel || '', + vehicle: b.vehicle || '—', + packagesCount: b.item_count ?? b.itemcount ?? 0, + status: API_TO_UI_STATUS[b.status] || 'Preparing', + time: b.dispatchtime ? timeAgo(b.dispatchtime, 'Sent out') : timeAgo(b.createdat, 'Created'), + origin: hubName, + currentLoc: b.status === 'Dispatched' ? 'On the way' : `${hubName} (Dispatch Dock)`, + destination: b.destination || b.destinationlabel || b.route || '—', + kind: b.kind || b.batchkind || 'transfer' + }), + [hubName] + ); + + const [manifests, setManifests] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); const [openModal, setOpenModal] = useState(false); + const [creating, setCreating] = useState(false); + const [busyId, setBusyId] = useState(null); // Create form state const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub'); const [newDestination, setNewDestination] = useState(''); const [newVehicle, setNewVehicle] = useState(''); const [newPkgsCount, setNewPkgsCount] = useState('5'); - + // Toast state const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); - const handleCreateManifest = (e) => { + const load = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const res = await getBatches(); + setManifests((res?.data || []).map(mapBatch)); + } catch (err) { + setLoadError(err?.message || 'Could not load batches.'); + } finally { + setLoading(false); + } + }, [mapBatch]); + + useEffect(() => { + load(); + }, [load]); + + const handleCreateManifest = async (e) => { e.preventDefault(); - if (!newVehicle) { - setToast({ open: true, msg: 'Please input Miler or Vehicle info', severity: 'warning' }); + if (creating) return; + if (!newVehicle.trim() || !newDestination.trim()) { + setToast({ open: true, msg: 'Enter the destination and the miler / vehicle.', severity: 'warning' }); return; } - - const randomNum = Math.floor(1000 + Math.random() * 9000); - const newManifest = { - id: `BATCH-${randomNum}`, - route: newRoute, - vehicle: newVehicle, - packagesCount: parseInt(newPkgsCount, 10), - status: 'Preparing', - time: 'Created just now', - origin: 'Delhi Hub', - currentLoc: 'Delhi Hub (Dispatch Dock)', - destination: newDestination || newRoute - }; - - setManifests([newManifest, ...manifests]); - setOpenModal(false); - setNewVehicle(''); - setNewDestination(''); - setToast({ open: true, msg: `Batch ${newManifest.id} created successfully`, severity: 'success' }); + const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local'; + setCreating(true); + try { + const res = await createBatch({ + route: newRoute, + destination: newDestination.trim(), + vehicle: newVehicle.trim(), + parcels_count: parseInt(newPkgsCount, 10) || 0, + kind + }); + const label = res?.data?.batchlabel || 'New batch'; + setOpenModal(false); + setNewVehicle(''); + setNewDestination(''); + setToast({ open: true, msg: `Batch ${label} created successfully`, severity: 'success' }); + load(); + } catch (err) { + setToast({ open: true, msg: err?.message || 'Could not create the batch.', severity: 'error' }); + } finally { + setCreating(false); + } }; - const handleSeal = (id) => { - setManifests((prev) => - prev.map((m) => (m.id === id ? { ...m, status: 'Ready', time: 'Checked & ready just now' } : m)) - ); - setToast({ open: true, msg: `Batch ${id} checked and ready to send.`, severity: 'success' }); + // Move a batch to the next status via PATCH. + const advance = async (m, apiStatus, uiStatus, okMsg) => { + if (busyId) return; + setBusyId(m.tripsheetid); + try { + await updateBatchStatus(m.tripsheetid, apiStatus); + setManifests((prev) => + prev.map((x) => + x.tripsheetid === m.tripsheetid + ? { ...x, status: uiStatus, time: uiStatus === 'Sent' ? 'Sent out just now' : 'Checked & ready just now', currentLoc: uiStatus === 'Sent' ? 'On the way' : x.currentLoc } + : x + ) + ); + setToast({ open: true, msg: okMsg, severity: 'success' }); + } catch (err) { + setToast({ open: true, msg: err?.message || 'Could not update this batch.', severity: 'error' }); + } finally { + setBusyId(null); + } }; - const handleDispatch = (id) => { - setManifests((prev) => - prev.map((m) => (m.id === id ? { ...m, status: 'Sent', time: 'Sent out just now', currentLoc: 'On the way' } : m)) - ); - setToast({ open: true, msg: `Batch ${id} sent out! The miler/driver has been notified.`, severity: 'success' }); - }; + const handleSeal = (m) => advance(m, 'Ready', 'Ready', `Batch ${m.id} checked and ready to send.`); + const handleDispatch = (m) => advance(m, 'Dispatched', 'Sent', `Batch ${m.id} sent out! The miler/driver has been notified.`); // Action button shown for each batch based on its status const BatchAction = ({ m, fullWidth }) => { + const isBusy = busyId === m.tripsheetid; if (m.status === 'Preparing') { return ( - ); } if (m.status === 'Ready') { return ( - ); @@ -156,7 +225,7 @@ export default function Dispatch() { } onClick={() => setOpenModal(true)}> New Batch @@ -165,7 +234,22 @@ export default function Dispatch() { /> - {isMdDown ? ( + {loadError && ( + setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> + {loadError} + + )} + + {loading ? ( + + + + ) : manifests.length === 0 && !loadError ? ( + + + No outgoing batches yet. Create one to get started. + + ) : isMdDown ? ( /* ── MOBILE / TABLET: spacious cards ── */ {manifests.map((m) => { @@ -304,9 +388,10 @@ export default function Dispatch() { - - + diff --git a/src/pages/operations/HubSettings.jsx b/src/pages/operations/HubSettings.jsx new file mode 100644 index 0000000..d1d3429 --- /dev/null +++ b/src/pages/operations/HubSettings.jsx @@ -0,0 +1,314 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Box, Card, CardContent, CardHeader, Button, Stack, Typography, Divider, Avatar, Chip, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, useMediaQuery, + Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, InputAdornment, + IconButton, Snackbar, Alert, CircularProgress +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; +import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'; +import AddIcon from '@mui/icons-material/Add'; +import PersonAddAlt1OutlinedIcon from '@mui/icons-material/PersonAddAlt1Outlined'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; +import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; +import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; + +import PageHeader from '@/components/PageHeader'; +import { getHubs, createHub, createStaff } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; + +const BRAND = '#C01227'; +const HUB_TYPES = [ + { value: 'sorting_center', label: 'Sorting Center' }, + { value: 'delivery_hub', label: 'Delivery Hub' }, + { value: 'spoke', label: 'Spoke' }, + { value: 'warehouse', label: 'Warehouse' } +]; + +const EMPTY_HUB = { hubname: '', hubtype: 'spoke', capacity: '30', contact: '', address: '', pincode: '' }; +const EMPTY_STAFF = { hubid: '', email: '', password: '', displayname: '' }; + +const prettyType = (t) => HUB_TYPES.find((h) => h.value === t)?.label || t || '—'; + +export default function HubSettings() { + const theme = useTheme(); + const isMdDown = useMediaQuery(theme.breakpoints.down('md')); + const hub = getHubContext(); + + const [hubs, setHubs] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); + + const [hubDialog, setHubDialog] = useState(false); + const [hubForm, setHubForm] = useState(EMPTY_HUB); + const [savingHub, setSavingHub] = useState(false); + + const [staffDialog, setStaffDialog] = useState(false); + const [staffForm, setStaffForm] = useState(EMPTY_STAFF); + const [showPwd, setShowPwd] = useState(false); + const [savingStaff, setSavingStaff] = useState(false); + + const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity }); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const res = await getHubs(); + setHubs(res?.data || []); + } catch (err) { + setLoadError(err?.message || 'Could not load hubs.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const openHubDialog = () => { + setHubForm(EMPTY_HUB); + setHubDialog(true); + }; + const openStaffDialog = () => { + setStaffForm({ ...EMPTY_STAFF, hubid: hubs[0]?.hubid ?? '' }); + setShowPwd(false); + setStaffDialog(true); + }; + + const submitHub = async () => { + if (savingHub) return; + if (!hubForm.hubname.trim()) { + notify('Enter a hub name.', 'warning'); + return; + } + setSavingHub(true); + try { + await createHub({ + hubname: hubForm.hubname.trim(), + hubtype: hubForm.hubtype, + capacity: parseInt(hubForm.capacity, 10) || 0, + contact: hubForm.contact.trim(), + address: hubForm.address.trim(), + pincode: hubForm.pincode.trim() + }); + setHubDialog(false); + notify(`Hub "${hubForm.hubname.trim()}" created.`); + load(); + } catch (err) { + notify(err?.message || 'Could not create the hub.', 'error'); + } finally { + setSavingHub(false); + } + }; + + const submitStaff = async () => { + if (savingStaff) return; + if (!staffForm.hubid || !staffForm.email.trim() || !staffForm.password) { + notify('Hub, email and password are all required.', 'warning'); + return; + } + setSavingStaff(true); + try { + await createStaff({ + hubid: Number(staffForm.hubid), + email: staffForm.email.trim(), + password: staffForm.password, + displayname: staffForm.displayname.trim() || staffForm.email.trim(), + tenantid: null // null for Doormile staff; set a tenant ID for partner accounts + }); + setStaffDialog(false); + notify(`Staff login created for ${staffForm.email.trim()}.`); + load(); + } catch (err) { + notify(err?.message || 'Could not create the staff login.', 'error'); + } finally { + setSavingStaff(false); + } + }; + + const StaffChip = ({ has }) => + has ? ( + } label="Has login" + sx={{ fontWeight: 700, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} /> + ) : ( + } label="No login" + sx={{ fontWeight: 700, bgcolor: '#FEF7E0', color: '#B06000', '& .MuiChip-icon': { color: '#B06000' } }} /> + ); + + return ( + + + + + } + action={ + + + + + } + sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }} + /> + + + {loadError && ( + setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> + {loadError} + + )} + + {loading ? ( + + + + ) : hubs.length === 0 && !loadError ? ( + + + No hubs yet. Add your first hub. + + ) : isMdDown ? ( + + {hubs.map((h) => ( + + + + + {h.hubname} + {prettyType(h.hubtype)} · Cap {h.capacity} + + + + + + ))} + + ) : ( + + + + + {['Hub', 'Type', 'Capacity', 'Status', 'Staff Login'].map((h) => ( + + {h} + + ))} + + + + {hubs.map((h) => ( + + {h.hubname} + {prettyType(h.hubtype)} + {h.capacity} + + + + + + ))} + +
+
+ )} +
+ + {/* Create Hub dialog */} + setHubDialog(false)} fullWidth maxWidth="sm"> + Add a New Hub + + + The city is set automatically from your hub — it can’t be changed here. + + + setHubForm((f) => ({ ...f, hubname: e.target.value }))} /> + + setHubForm((f) => ({ ...f, hubtype: e.target.value }))}> + {HUB_TYPES.map((t) => {t.label})} + + setHubForm((f) => ({ ...f, capacity: e.target.value }))} /> + + setHubForm((f) => ({ ...f, contact: e.target.value }))} /> + setHubForm((f) => ({ ...f, address: e.target.value }))} /> + setHubForm((f) => ({ ...f, pincode: e.target.value }))} /> + + + + + + + + + {/* Create Staff dialog */} + setStaffDialog(false)} fullWidth maxWidth="sm"> + Add a Hub Staff Login + + + setStaffForm((f) => ({ ...f, hubid: e.target.value }))}> + {hubs.map((h) => {h.hubname})} + + setStaffForm((f) => ({ ...f, displayname: e.target.value }))} /> + setStaffForm((f) => ({ ...f, email: e.target.value }))} /> + setStaffForm((f) => ({ ...f, password: e.target.value }))} + InputProps={{ + endAdornment: ( + + setShowPwd((s) => !s)} edge="end" size="small"> + {showPwd ? : } + + + ) + }} /> + + + + + + + + + setToast({ ...toast, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> + setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}> + {toast.msg} + + +
+ ); +} diff --git a/src/pages/operations/Inbound.jsx b/src/pages/operations/Inbound.jsx index 8ed99ce..dfaa459 100644 --- a/src/pages/operations/Inbound.jsx +++ b/src/pages/operations/Inbound.jsx @@ -1,9 +1,9 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { Box, Typography, Card, CardContent, Grid, TextField, Button, Stack, MenuItem, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider, - useMediaQuery + useMediaQuery, CircularProgress } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined'; @@ -23,22 +23,50 @@ import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; import PageHeader from '@/components/PageHeader'; +import { getInboundToday, inboundBooking } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone C (Cold Room)', 'Exception Area']; -const INITIAL_INBOUND = [ - { trackingId: 'DM-882201', sender: 'Acme Corp, Mumbai', origin: 'Mumbai Hub', currentLoc: 'Delhi Hub', destination: 'Dwarka, Sec 12, Delhi', weight: '2.4 kg', condition: 'Good', temp: 'N/A', shelf: 'Zone A (Shelf 1)', time: '10 min ago' }, - { trackingId: 'DM-882202', sender: 'Tech Ltd, Mumbai', origin: 'Mumbai Hub', currentLoc: 'Delhi Hub', destination: 'Saket, Block J, Delhi', weight: '1.2 kg', condition: 'Good', temp: '4.1°C', shelf: 'Zone C (Cold Room)', time: '12 min ago' }, - { trackingId: 'DM-882203', sender: 'Crafts India, Jaipur', origin: 'Jaipur Hub', currentLoc: 'Delhi Hub', destination: 'Mayur Vihar Ph 1, Delhi', weight: '8.5 kg', condition: 'Damaged Box', temp: 'N/A', shelf: 'Exception Area', time: '20 min ago' } -]; - const ORIGINS = [ { value: 'Mumbai Hub', label: 'Mumbai Hub (BOM-02)' }, { value: 'Jaipur Hub', label: 'Jaipur Hub (JAI-08)' }, { value: 'Bengaluru Hub', label: 'Bengaluru Hub (BLR-03)' }, - { value: 'Client Pickup', label: 'Direct Client Pickup (Delhi Local)' } + { value: 'Client Pickup', label: 'Direct Client Pickup (Local)' } ]; +// Turn an ISO timestamp into a friendly "10 min ago" label. +const timeAgo = (iso) => { + if (!iso) return 'Recently'; + const then = new Date(iso).getTime(); + if (Number.isNaN(then) || then < 1420070400000) return 'Recently'; // guard zero/0001 dates + const mins = Math.round((Date.now() - then) / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins} min ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + return `${Math.round(hrs / 24)} d ago`; +}; + +// Map an API inbound record onto the shape this page renders. +// Real backend uses consignmentid / trackingno / chargeableweight / deliverypincode. +const mapInbound = (row, hubName) => { + const w = row.chargeableweight ?? row.deadweight ?? row.weight; + return { + bookingid: row.consignmentid ?? row.bookingid, + trackingId: row.trackingno || row.trackingnumber || '—', + sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'), + origin: row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'), + currentLoc: hubName, + destination: row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'), + weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—', + condition: row.condition || 'Good', + temp: row.temperature || 'N/A', + shelf: row.shelf || 'Zone A (Shelf 1)', + time: timeAgo(row.inboundedat || row.createdat) + }; +}; + const shelfStyle = (shelf) => { if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' }; if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' }; @@ -49,7 +77,10 @@ const isGood = (c) => c === 'Good'; export default function Inbound() { const theme = useTheme(); const isMdDown = useMediaQuery(theme.breakpoints.down('md')); + const hub = getHubContext(); + const hubName = hub.hubname || 'this hub'; + const [bookingId, setBookingId] = useState(''); const [trackingId, setTrackingId] = useState(''); const [origin, setOrigin] = useState('Mumbai Hub'); const [customer, setCustomer] = useState(''); @@ -58,10 +89,32 @@ export default function Inbound() { const [weight, setWeight] = useState(''); const [condition, setCondition] = useState('Good'); const [temp, setTemp] = useState(''); + const [submitting, setSubmitting] = useState(false); - const [inboundLogs, setInboundLogs] = useState(INITIAL_INBOUND); + const [inboundLogs, setInboundLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); + const loadInbound = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const res = await getInboundToday(); + setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName))); + } catch (err) { + setLoadError(err?.message || 'Could not load today’s inbound parcels.'); + } finally { + setLoading(false); + } + // hubName is derived from a stable localStorage read; safe to omit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadInbound(); + }, [loadInbound]); + const stats = useMemo(() => { const received = inboundLogs.length; const exceptions = inboundLogs.filter(l => l.condition !== 'Good' || l.shelf === 'Exception Area').length; @@ -73,6 +126,7 @@ export default function Inbound() { const handleRandomScan = () => { const randomNum = Math.floor(100000 + Math.random() * 900000); setTrackingId(`DM-${randomNum}`); + setBookingId(String(Math.floor(1 + Math.random() * 200))); const customers = ['Acme Electronics', 'Delhi Medicos', 'Urban Fashion', 'Astro Retail', 'Fresho Foods']; const senderAddresses = ['Andheri East, Mumbai', 'Malviya Nagar, Jaipur', 'Koramangala, Bengaluru', 'Lajpat Nagar, Delhi']; const destinations = ['Dwarka Sec 4, Delhi', 'Saket Marg, Delhi', 'Karol Bagh, Delhi', 'Connaught Place, Delhi', 'Vasant Kunj, Delhi']; @@ -87,25 +141,58 @@ export default function Inbound() { setOrigin(origins[Math.floor(Math.random() * origins.length)]); }; - const handleSubmit = (e) => { + // Local preview of the shelf the backend is likely to recommend (it decides for real). + const recommendShelf = () => { + if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area'; + if (temp !== 'N/A' && temp !== '') return 'Zone C (Cold Room)'; + return SHELVES[0]; + }; + + const handleSubmit = async (e) => { e.preventDefault(); - if (!trackingId || !destination) { - setToast({ open: true, msg: 'Please scan or fill tracking details', severity: 'warning' }); + if (submitting) return; + if (!bookingId.trim() || !trackingId.trim()) { + setToast({ open: true, msg: 'Enter the booking ID and tracking ID to scan a parcel in.', severity: 'warning' }); return; } - let recommendedShelf = SHELVES[0]; - if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) recommendedShelf = 'Exception Area'; - else if (temp !== 'N/A' && temp !== '') recommendedShelf = 'Zone C (Cold Room)'; - else recommendedShelf = SHELVES[Math.floor(Math.random() * 3)]; - const newLog = { - trackingId, sender: customer || 'Unknown Sender', origin, currentLoc: 'Delhi Hub', - destination, weight: weight || '1.0 kg', condition, temp: temp || 'N/A', - shelf: recommendedShelf, time: 'Just now' - }; - setInboundLogs([newLog, ...inboundLogs]); - setToast({ open: true, msg: `${trackingId} received · routed to ${recommendedShelf}`, severity: 'success' }); - setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp(''); + setSubmitting(true); + try { + const res = await inboundBooking(bookingId.trim(), { + tracking_id: trackingId.trim(), + condition, + temperature: temp || 'N/A', + shelf: recommendShelf(), + weight: weight || 'N/A' + }); + const data = res?.data || {}; + const shelf = data.recommended_shelf || recommendShelf(); + + // Optimistically prepend, then refresh from the server for the source of truth. + setInboundLogs((prev) => [ + { + bookingid: data.bookingid ?? bookingId, + trackingId: data.trackingnumber || trackingId.trim(), + sender: customer || 'Unknown Sender', + origin, + currentLoc: hubName, + destination: destination || '—', + weight: weight || 'N/A', + condition, + temp: temp || 'N/A', + shelf, + time: 'Just now' + }, + ...prev + ]); + setToast({ open: true, msg: `${data.trackingnumber || trackingId.trim()} received · routed to ${shelf}`, severity: 'success' }); + setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp(''); + loadInbound(); + } catch (err) { + setToast({ open: true, msg: err?.message || 'Could not scan this parcel in.', severity: 'error' }); + } finally { + setSubmitting(false); + } }; const fieldSx = { '& .MuiOutlinedInput-root': { borderRadius: 2 } }; @@ -195,7 +282,7 @@ export default function Inbound() { - Record a parcel arriving at Delhi Hub + Record a parcel arriving at {hubName}
@@ -204,6 +291,11 @@ export default function Inbound() { + setBookingId(e.target.value)} sx={fieldSx} required + helperText="Consignment / booking number for this parcel" + InputProps={{ startAdornment: }} /> + setTrackingId(e.target.value)} sx={fieldSx} InputProps={{ @@ -248,10 +340,11 @@ export default function Inbound() { onChange={(e) => setTemp(e.target.value)} sx={fieldSx} InputProps={{ startAdornment: }} /> - @@ -279,8 +372,22 @@ export default function Inbound() { - {/* ── MOBILE: card list ── */} - {isMdDown ? ( + {loadError && ( + setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> + {loadError} + + )} + + {loading ? ( + + + + ) : inboundLogs.length === 0 && !loadError ? ( + + + No parcels received yet today. + + ) : isMdDown ? ( {inboundLogs.map((log, i) => { const ss = shelfStyle(log.shelf); diff --git a/src/pages/operations/Inventory.jsx b/src/pages/operations/Inventory.jsx deleted file mode 100644 index 591c578..0000000 --- a/src/pages/operations/Inventory.jsx +++ /dev/null @@ -1,285 +0,0 @@ -import { useState } from 'react'; -import { - Box, - Typography, - Card, - CardContent, - CardHeader, - Grid, - TextField, - Button, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Chip, - MenuItem, - Select, - FormControl, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Avatar, - Divider, - InputAdornment, - useMediaQuery -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import ViewModuleIcon from '@mui/icons-material/ViewModule'; -import AcUnitIcon from '@mui/icons-material/AcUnit'; -import LocalOfferIcon from '@mui/icons-material/LocalOffer'; -import StorageIcon from '@mui/icons-material/Storage'; -import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; -import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined'; -import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'; -import OpenWithRoundedIcon from '@mui/icons-material/OpenWithRounded'; - -import PageHeader from '@/components/PageHeader'; - -const zoneColor = (zone) => { - if (zone === 'C') return { color: '#00838F', bg: '#E0F7FA' }; - if (zone === 'D') return { color: '#D93025', bg: '#FCE8E6' }; - if (zone === 'B') return { color: '#8E24AA', bg: '#F3E5F5' }; - return { color: '#1A73E8', bg: '#E8F0FE' }; -}; -const onShelf = (s) => s === 'On Shelf'; - -const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone B (Shelf 2)', 'Zone C (Cold Room)', 'Exception Area']; - -const INITIAL_INVENTORY = [ - { id: 'DM-882201', customer: 'Acme Corp', weight: '2.4 kg', shelf: 'Zone A (Shelf 1)', zone: 'A', status: 'On Shelf' }, - { id: 'DM-882202', customer: 'BioPharma India', weight: '1.2 kg', shelf: 'Zone C (Cold Room)', zone: 'C', status: 'On Shelf' }, - { id: 'DM-882203', customer: 'Rajesh Textiles', weight: '8.5 kg', shelf: 'Exception Area', zone: 'D', status: 'Under Review' }, - { id: 'DM-109241', customer: 'Urban Fashion', weight: '3.1 kg', shelf: 'Zone B (Shelf 1)', zone: 'B', status: 'On Shelf' }, - { id: 'DM-402941', customer: 'Astro Retail', weight: '0.5 kg', shelf: 'Zone A (Shelf 2)', zone: 'A', status: 'On Shelf' } -]; - -export default function Inventory() { - const theme = useTheme(); - const isMdDown = useMediaQuery(theme.breakpoints.down('md')); - - const [inventory, setInventory] = useState(INITIAL_INVENTORY); - const [search, setSearch] = useState(''); - - // Relocation modal state - const [editItem, setEditItem] = useState(null); - const [newShelf, setNewShelf] = useState(''); - - const handleOpenRelocate = (item) => { - setEditItem(item); - setNewShelf(item.shelf); - }; - - const handleConfirmRelocate = () => { - if (!editItem) return; - - // Determine new zone label - let zoneLabel = 'A'; - if (newShelf.includes('Zone B')) zoneLabel = 'B'; - else if (newShelf.includes('Zone C') || newShelf.includes('Cold')) zoneLabel = 'C'; - else if (newShelf.includes('Exception')) zoneLabel = 'D'; - - setInventory((prev) => - prev.map((item) => - item.id === editItem.id ? { ...item, shelf: newShelf, zone: zoneLabel } : item - ) - ); - - setEditItem(null); - }; - - // Filter inventory list - const filteredInventory = inventory.filter( - (item) => - item.id.toLowerCase().includes(search.toLowerCase()) || - item.customer.toLowerCase().includes(search.toLowerCase()) || - item.shelf.toLowerCase().includes(search.toLowerCase()) - ); - - return ( - - - - - - {/* Environmental Sensors for Cold-Chain Zone C */} - - {[ - { label: 'Cold Room Temperature', value: '4.2°C', icon: AcUnitIcon, color: '#00838F', bg: '#E0F7FA', chip: 'All good · Within safe range', chipColor: 'success' }, - { label: 'Storage Space', value: '120 Shelves', icon: ViewModuleIcon, color: '#1A73E8', bg: '#E8F0FE', chip: '28% full', chipColor: 'primary' }, - { label: 'Needs Attention', value: '1 Parcel', icon: LocalOfferIcon, color: '#D93025', bg: '#FCE8E6', chip: 'Waiting to be checked', chipColor: 'error' }, - ].map((s, i) => ( - - - - - - - - - {s.label} - - - {s.value} - - - - - ))} - - - {/* Main Inventory Board */} - - setSearch(e.target.value)} - sx={{ width: { xs: 160, sm: 220, md: 280 }, '& .MuiOutlinedInput-root': { borderRadius: 2 } }} - InputProps={{ startAdornment: }} - /> - } - sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }} - /> - - - {isMdDown ? ( - /* ── MOBILE / TABLET: cards ── */ - - {filteredInventory.map((item) => { - const zc = zoneColor(item.zone); - return ( - - - - - - - - - {item.id} - {item.customer} - - - - - - - } label={item.shelf} - sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, '& .MuiChip-icon': { color: zc.color } }} /> - } label={item.weight} - sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} /> - - - - - - ); - })} - {filteredInventory.length === 0 && ( - No parcels found. Try a different search. - )} - - ) : ( - /* ── DESKTOP: spacious table ── */ - - - - - {['Parcel', 'On Shelf', 'Weight', 'Status', 'Action'].map((h, i) => ( - - {h} - - ))} - - - - {filteredInventory.map((item) => { - const zc = zoneColor(item.zone); - return ( - - - - - - - - {item.id} - {item.customer} - - - - - } label={item.shelf} - sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, whiteSpace: 'nowrap', '& .MuiChip-icon': { color: zc.color } }} /> - - {item.weight} - - - - - - - - ); - })} - {filteredInventory.length === 0 && ( - - - No parcels found. Try a different search. - - - )} - -
-
- )} -
- - {/* Relocate Dialog */} - setEditItem(null)} fullWidth maxWidth="xs"> - Move Parcel {editItem?.id} - - - - Choose a new shelf for {editItem?.id} (from {editItem?.customer}). - - - - - - - - - - - -
- ); -} diff --git a/src/pages/operations/OrderAssignment.jsx b/src/pages/operations/OrderAssignment.jsx index 31fca05..c22fa4c 100644 --- a/src/pages/operations/OrderAssignment.jsx +++ b/src/pages/operations/OrderAssignment.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Box, Typography, @@ -27,7 +27,10 @@ import { Radio, Badge, Checkbox, - useMediaQuery + useMediaQuery, + CircularProgress, + Alert, + Snackbar } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; @@ -38,36 +41,97 @@ import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded'; +import StarRoundedIcon from '@mui/icons-material/StarRounded'; import PageHeader from '@/components/PageHeader'; +import { getUnassignedBookings, getMilers, assignMiler, autoAssignBooking } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; -const UNASSIGNED_ORDERS = [ - { id: 'PICK-991', customer: 'Ramesh K.', pickup: 'Dwarka Sec 12, Delhi', drop: 'Mumbai', package: 'Small Box, 2kg', time: '10 mins ago', status: 'Pending Assignment' }, - { id: 'PICK-992', customer: 'Anita P.', pickup: 'Saket, Delhi', drop: 'Hyderabad', package: 'Document, 0.5kg', time: '15 mins ago', status: 'Pending Assignment' }, - { id: 'PICK-993', customer: 'Suresh V.', pickup: 'Rohini, Delhi', drop: 'Bengaluru', package: 'Large Box, 12kg', time: '1 hour ago', status: 'Pending Assignment' } -]; +const PENDING = 'Pending Assignment'; -const AVAILABLE_MILERS = [ - { id: 'M-101', name: 'Deepak Sharma', vehicle: 'Two-Wheeler', area: 'Dwarka', distance: '1.2 km away' }, - { id: 'M-102', name: 'Karthik S.', vehicle: 'EV-Rickshaw', area: 'Saket', distance: '2.5 km away' }, - { id: 'M-103', name: 'Sanjay R.', vehicle: 'Mini Truck', area: 'Rohini', distance: '0.8 km away' } -]; +const timeAgo = (iso) => { + if (!iso) return 'Recently'; + const then = new Date(iso).getTime(); + if (Number.isNaN(then) || then < 1420070400000) return 'Recently'; + const mins = Math.round((Date.now() - then) / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins} min ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs} hr ago`; + return `${Math.round(hrs / 24)} d ago`; +}; + +// Real backend: snake_case fields + a parcels[] array (no packagedescription). +const mapOrder = (b) => { + const parcels = b.parcels || []; + const totalWeight = parcels.reduce((s, p) => s + (p.weight || 0), 0); + const pkg = parcels.length + ? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}` + : b.packagedescription || '—'; + return { + id: b.bookingid, + customer: b.customer_name || b.customerName || 'Customer', + pickup: b.pickup_address || b.pickupaddress || '—', + drop: b.delivery_address || b.deliveryaddress || '—', + package: pkg, + time: timeAgo(b.created_at || b.createdat), + status: PENDING + }; +}; + +const mapMiler = (m) => ({ + id: m.userid, + name: m.displayname || `Miler ${m.userid}`, + status: m.availabilitystatus || 'Available', + rating: m.rating, + completed: m.totalcompletedpickups ?? m.completedorders +}); export default function OrderAssignment() { const theme = useTheme(); const isMdDown = useMediaQuery(theme.breakpoints.down('md')); + const hub = getHubContext(); + + const [orders, setOrders] = useState([]); + const [milers, setMilers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [busy, setBusy] = useState(false); + const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); - const [orders, setOrders] = useState(UNASSIGNED_ORDERS); const [selectedOrders, setSelectedOrders] = useState([]); - + // Single Assign Dialog State const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null); const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [selectedMiler, setSelectedMiler] = useState(''); + const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity }); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const [bookings, milerRes] = await Promise.all([getUnassignedBookings(), getMilers().catch(() => null)]); + setOrders((bookings?.data || []).map(mapOrder)); + if (milerRes?.data) setMilers(milerRes.data.map(mapMiler)); + } catch (err) { + setLoadError(err?.message || 'Could not load pickup requests.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // Milers that can take a new pickup right now. + const availableMilers = milers.filter((m) => ['Available', 'Assigned'].includes(m.status)); + const handleSelectAll = (event) => { if (event.target.checked) { - const pendingIds = orders.filter(o => o.status === 'Pending Assignment').map(o => o.id); + const pendingIds = orders.filter(o => o.status === PENDING).map(o => o.id); setSelectedOrders(pendingIds); } else { setSelectedOrders([]); @@ -88,39 +152,65 @@ export default function OrderAssignment() { setAssignDialogOpen(true); }; - const handleAssign = () => { - if (!selectedMiler) return; - const miler = AVAILABLE_MILERS.find(m => m.id === selectedMiler); - - setOrders(orders.map(o => { - if (o.id === selectedOrderForAssign.id) { - return { ...o, status: `Assigned to ${miler.name}`, assignedMiler: miler }; - } - return o; - })); - - // Remove from selection if it was selected - setSelectedOrders(prev => prev.filter(id => id !== selectedOrderForAssign.id)); - setAssignDialogOpen(false); + // Assign a specific miler to a single booking. + const handleAssign = async () => { + if (!selectedMiler || busy) return; + const miler = availableMilers.find((m) => m.id === selectedMiler); + const order = selectedOrderForAssign; + setBusy(true); + try { + const res = await assignMiler(order.id, selectedMiler); + const name = res?.data?.milername || miler?.name || 'miler'; + setOrders((prev) => prev.map((o) => (o.id === order.id ? { ...o, status: `Assigned to ${name}` } : o))); + setSelectedOrders((prev) => prev.filter((id) => id !== order.id)); + setAssignDialogOpen(false); + notify(`Pickup #${order.id} assigned to ${name}.`); + } catch (err) { + notify(err?.message || 'Could not assign this miler.', 'error'); + } finally { + setBusy(false); + } }; - const handleAutoAssignAll = () => { - if (selectedOrders.length === 0) return; - - setOrders(orders.map(o => { - if (selectedOrders.includes(o.id) && o.status === 'Pending Assignment') { - // Just pick a random miler for auto-assign simulation - const randomMiler = AVAILABLE_MILERS[Math.floor(Math.random() * AVAILABLE_MILERS.length)]; - return { ...o, status: `Assigned to ${randomMiler.name}`, assignedMiler: randomMiler }; + // Let the AI engine assign every selected booking (POST /hub/bookings/:id/auto-assign). + const handleAutoAssignAll = async () => { + if (selectedOrders.length === 0 || busy) return; + const ids = orders.filter((o) => selectedOrders.includes(o.id) && o.status === PENDING).map((o) => o.id); + setBusy(true); + let ok = 0; + let pending = 0; + let failed = 0; + for (const id of ids) { + try { + const res = await autoAssignBooking(id); + // 202 → engine still working: success:true but no miler yet. + const name = res?.data?.milername; + if (name) { + setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: `Assigned to ${name}` } : o))); + ok += 1; + } else { + setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'Assigning…' } : o))); + pending += 1; + } + } catch (err) { + // 422 → no eligible miler in range; other codes → generic failure. + failed += 1; + if (err?.status === 422) { + setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'No miler in range' } : o))); + } } - return o; - })); - - // Clear selection + } setSelectedOrders([]); + setBusy(false); + const parts = []; + if (ok) parts.push(`assigned ${ok}`); + if (pending) parts.push(`${pending} in progress`); + if (failed) parts.push(`${failed} failed`); + notify(`Auto-assign: ${parts.join(', ')}.`, failed ? 'warning' : 'success'); + load(); // refresh from server for the true state }; - const pendingCount = orders.filter(o => o.status === 'Pending Assignment').length; + const pendingCount = orders.filter((o) => o.status === PENDING).length; const isAllSelected = selectedOrders.length > 0 && selectedOrders.length === pendingCount; return ( @@ -134,15 +224,15 @@ export default function OrderAssignment() { } action={ - + + + setToast({ ...toast, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> + setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}> + {toast.msg} + +
); } diff --git a/src/pages/operations/RiderRoutes.jsx b/src/pages/operations/RiderRoutes.jsx index 2540d8e..69da03a 100644 --- a/src/pages/operations/RiderRoutes.jsx +++ b/src/pages/operations/RiderRoutes.jsx @@ -9,6 +9,9 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { getRiderRoutes } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; + import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined'; import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import PauseRoundedIcon from '@mui/icons-material/PauseRounded'; @@ -130,94 +133,53 @@ const moverIcon = (color) => new L.DivIcon({ // // Delivery: hub → customer drops. Pickup: merchant collections → hub. // ════════════════════════════════════════════════════════════════════════════════ -const HUB = { lat: 28.6139, lng: 77.2090, label: 'Delhi Operations Hub' }; -const hubStop = (time) => ({ kind: 'hub', label: HUB.label, lat: HUB.lat, lng: HUB.lng, time }); +// The hub all pickup legs return to. Label comes from the logged-in hub context; +// the coordinate is only the initial map centre (the map auto-fits to the stops). +const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' }; -const RIDERS = [ - { - id: 'RDR-8012', name: 'Muthu Kumar', color: '#1A73E8', vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234', phone: '+91 98765 43210', - delivery: { - startTime: '08:12', endTime: '13:40', distanceKm: 18.4, - stops: [ - hubStop('08:12'), - { kind: 'order', orderId: 'ORD-100231', customer: 'Aarav Mehta', phone: '+91 90011 22334', address: 'Flat 402, Dwarka Sector 12, New Delhi', lat: 28.5921, lng: 77.0460, time: '08:48', status: 'Picked', items: 2, weight: '1.4 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 5.2, instructions: 'Leave at reception if not home.' }, - { kind: 'order', orderId: 'ORD-100244', customer: 'Priya Nair', phone: '+91 90022 33445', address: 'House 18, Janakpuri B-Block, New Delhi', lat: 28.6219, lng: 77.0878, time: '09:36', status: 'Picked', items: 1, weight: '0.6 kg', slot: '09:00–11:00', cod: 1200, payment: 'COP', legKm: 4.1, instructions: 'Call on arrival.' }, - { kind: 'order', orderId: 'ORD-100258', customer: 'Rohit Sethi', phone: '+91 90033 44556', address: 'Shop 7, Rajouri Garden Market, New Delhi', lat: 28.6492, lng: 77.1207, time: '10:25', status: 'Failed', items: 3, weight: '2.8 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 4.6, instructions: 'Customer unreachable — reattempt tomorrow.' }, - { kind: 'order', orderId: 'ORD-100269', customer: 'Sana Kapoor', phone: '+91 90044 55667', address: 'A-22, Karol Bagh, New Delhi', lat: 28.6512, lng: 77.1907, time: '11:30', status: 'Picked', items: 1, weight: '0.9 kg', slot: '11:00–13:00', cod: 850, payment: 'COP', legKm: 4.5, instructions: '' }, - ], - }, +// Palette assigned to milers round-robin so each route line is a distinct colour. +const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F']; + +// Map an API rider-route (from GET /hub/rider-routes) into the structure this +// page renders: a `pickup` trip with a list of order stops. Fields the API does +// not provide (customer, weight, COD, slot, instructions) default gracefully. +const mapRoute = (r, i) => { + const apiStops = Array.isArray(r.stops) ? r.stops : []; + const stops = apiStops.map((s) => ({ + kind: 'order', + orderId: s.bookingid != null ? `BK-${s.bookingid}` : `Stop ${s.seq}`, + bookingid: s.bookingid, + customer: s.customer || '', + phone: s.phone || '', + address: s.address || '—', + lat: s.lat, + lng: s.lon, + time: s.eta_minutes != null ? `${s.eta_minutes} min` : '', + status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending', + items: s.items ?? 0, + weight: '', + slot: '', + cod: 0, + payment: '', + legKm: 0, + instructions: '' + })); + return { + id: `RDR-${r.mileruserid}`, + mileruserid: r.mileruserid, + name: r.milername || `Miler ${r.mileruserid}`, + color: ROUTE_COLORS[i % ROUTE_COLORS.length], + vehicle: '—', + vehicleNo: '—', + phone: '', pickup: { - startTime: '14:10', endTime: '16:50', distanceKm: 11.2, - stops: [ - { kind: 'order', orderId: 'PCK-50118', customer: 'TrendKart Store', phone: '+91 98801 11222', address: 'Tilak Nagar Main Rd, New Delhi', lat: 28.6363, lng: 77.0945, time: '14:35', status: 'Picked', items: 6, weight: '4.2 kg', slot: '14:00–16:00', cod: 0, payment: 'Merchant', legKm: 6.0, instructions: 'Collect from back gate.' }, - { kind: 'order', orderId: 'PCK-50126', customer: 'FreshLeaf Organics', phone: '+91 98802 22333', address: 'Subhash Nagar, New Delhi', lat: 28.6404, lng: 77.1199, time: '15:30', status: 'Picked', items: 3, weight: '5.5 kg', slot: '15:00–17:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' }, - { kind: 'order', orderId: 'PCK-50131', customer: 'GadgetHub', phone: '+91 98803 33444', address: 'Moti Nagar, New Delhi', lat: 28.6580, lng: 77.1450, time: '16:15', status: 'Missed', items: 2, weight: '1.1 kg', slot: '16:00–18:00', cod: 0, payment: 'Merchant', legKm: 2.2, instructions: 'Shop was closed at pickup time.' }, - hubStop('16:50'), - ], - }, - }, - { - id: 'RDR-8041', name: 'Sana Sheikh', color: '#8E24AA', vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199', phone: '+91 98765 43214', - delivery: { - startTime: '08:00', endTime: '14:05', distanceKm: 22.9, - stops: [ - hubStop('08:00'), - { kind: 'order', orderId: 'ORD-100277', customer: 'Imran Qureshi', phone: '+91 90055 66778', address: 'N-Block, Connaught Place, New Delhi', lat: 28.6315, lng: 77.2167, time: '08:30', status: 'Picked', items: 1, weight: '0.4 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 2.1, instructions: '' }, - { kind: 'order', orderId: 'ORD-100283', customer: 'Neha Gupta', phone: '+91 90066 77889', address: 'C-44, Lajpat Nagar II, New Delhi', lat: 28.5677, lng: 77.2433, time: '09:20', status: 'Picked', items: 2, weight: '1.7 kg', slot: '09:00–11:00', cod: 1450, payment: 'COP', legKm: 7.4, instructions: 'Ring twice.' }, - { kind: 'order', orderId: 'ORD-100291', customer: 'Vikas Rao', phone: '+91 90077 88990', address: 'J-Block, Saket, New Delhi', lat: 28.5245, lng: 77.2066, time: '10:40', status: 'Picked', items: 1, weight: '0.8 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 6.2, instructions: '' }, - { kind: 'order', orderId: 'ORD-100305', customer: 'Diya Shah', phone: '+91 90088 99001', address: 'Malviya Nagar Main Market, New Delhi', lat: 28.5355, lng: 77.2110, time: '11:55', status: 'Picked', items: 4, weight: '3.1 kg', slot: '11:00–13:00', cod: 2300, payment: 'COP', legKm: 1.4, instructions: 'Heavy parcel — handle with care.' }, - ], - }, - pickup: { - startTime: '14:30', endTime: '17:20', distanceKm: 14.6, - stops: [ - { kind: 'order', orderId: 'PCK-50140', customer: 'Bloom & Co Florists', phone: '+91 98804 44555', address: 'Greater Kailash I, New Delhi', lat: 28.5494, lng: 77.2426, time: '15:00', status: 'Picked', items: 4, weight: '2.0 kg', slot: '14:30–16:30', cod: 0, payment: 'Merchant', legKm: 8.1, instructions: '' }, - { kind: 'order', orderId: 'PCK-50147', customer: 'BookNook', phone: '+91 98805 55666', address: 'Hauz Khas Village, New Delhi', lat: 28.5535, lng: 77.1944, time: '16:05', status: 'Picked', items: 9, weight: '7.8 kg', slot: '15:30–17:30', cod: 0, payment: 'Merchant', legKm: 4.5, instructions: 'Multiple boxes.' }, - hubStop('17:20'), - ], - }, - }, - { - id: 'RDR-8015', name: 'Rajesh Sharma', color: '#1E8E3E', vehicle: 'Cargo Van', vehicleNo: 'DL-01-CV-9876', phone: '+91 98765 43211', - delivery: { - startTime: '07:50', endTime: '15:10', distanceKm: 31.2, - stops: [ - hubStop('07:50'), - { kind: 'order', orderId: 'ORD-100312', customer: 'Anil Verma', phone: '+91 90099 00112', address: 'Sector 7, Rohini, New Delhi', lat: 28.7042, lng: 77.1025, time: '08:55', status: 'Picked', items: 5, weight: '6.2 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 12.4, instructions: '' }, - { kind: 'order', orderId: 'ORD-100320', customer: 'Meera Iyer', phone: '+91 90100 11223', address: 'NSP, Pitampura, New Delhi', lat: 28.6996, lng: 77.1314, time: '09:50', status: 'Picked', items: 2, weight: '2.0 kg', slot: '09:00–11:00', cod: 990, payment: 'COP', legKm: 3.2, instructions: '' }, - { kind: 'order', orderId: 'ORD-100334', customer: 'Sahil Khan', phone: '+91 90111 22334', address: 'Model Town III, New Delhi', lat: 28.7158, lng: 77.1910, time: '11:10', status: 'Failed', items: 1, weight: '0.7 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 6.1, instructions: 'Wrong address provided.' }, - { kind: 'order', orderId: 'ORD-100349', customer: 'Tara Bose', phone: '+91 90122 33445', address: 'Civil Lines, New Delhi', lat: 28.6796, lng: 77.2240, time: '12:30', status: 'Picked', items: 3, weight: '4.4 kg', slot: '12:00–14:00', cod: 3100, payment: 'COP', legKm: 5.0, instructions: '' }, - ], - }, - pickup: { - startTime: '15:40', endTime: '18:30', distanceKm: 19.8, - stops: [ - { kind: 'order', orderId: 'PCK-50155', customer: 'MegaMart Warehouse', phone: '+91 98806 66777', address: 'Wazirpur Industrial Area, New Delhi', lat: 28.6991, lng: 77.1612, time: '16:20', status: 'Picked', items: 24, weight: '38.0 kg', slot: '16:00–18:00', cod: 0, payment: 'Merchant', legKm: 11.0, instructions: 'Use loading dock 4.' }, - { kind: 'order', orderId: 'PCK-50163', customer: 'HomeStyle Furnishings', phone: '+91 98807 77888', address: 'Ashok Vihar, New Delhi', lat: 28.6924, lng: 77.1760, time: '17:25', status: 'Picked', items: 8, weight: '22.5 kg', slot: '17:00–19:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' }, - hubStop('18:30'), - ], - }, - }, - { - id: 'RDR-8044', name: 'Harpreet Gill', color: '#E8710A', vehicle: 'Cycle', vehicleNo: '—', phone: '+91 98765 43215', - delivery: { - startTime: '09:20', endTime: '13:15', distanceKm: 9.7, - stops: [ - hubStop('09:20'), - { kind: 'order', orderId: 'ORD-100356', customer: 'Kabir Anand', phone: '+91 90133 44556', address: 'Mayur Vihar Phase I, New Delhi', lat: 28.6090, lng: 77.2920, time: '10:05', status: 'Picked', items: 1, weight: '0.5 kg', slot: '10:00–12:00', cod: 600, payment: 'COP', legKm: 9.0, instructions: '' }, - { kind: 'order', orderId: 'ORD-100361', customer: 'Ritu Saxena', phone: '+91 90144 55667', address: 'Mayur Vihar Phase III, New Delhi', lat: 28.6135, lng: 77.3215, time: '11:10', status: 'Picked', items: 2, weight: '1.2 kg', slot: '11:00–13:00', cod: 0, payment: 'Prepaid', legKm: 3.0, instructions: '' }, - { kind: 'order', orderId: 'ORD-100370', customer: 'Farhan Ali', phone: '+91 90155 66778', address: 'Patparganj, New Delhi', lat: 28.6280, lng: 77.2960, time: '12:20', status: 'Picked', items: 1, weight: '0.8 kg', slot: '12:00–14:00', cod: 450, payment: 'COP', legKm: 2.9, instructions: '' }, - ], - }, - pickup: { - startTime: '13:40', endTime: '15:30', distanceKm: 6.4, - stops: [ - { kind: 'order', orderId: 'PCK-50170', customer: 'Cafe Mosaic', phone: '+91 98808 88999', address: 'Mayur Vihar Phase I Market, New Delhi', lat: 28.6055, lng: 77.2985, time: '14:10', status: 'Picked', items: 2, weight: '1.5 kg', slot: '14:00–16:00', cod: 0, payment: 'Merchant', legKm: 4.0, instructions: '' }, - hubStop('15:30'), - ], - }, - }, -]; + startTime: '', + endTime: '', + distanceKm: r.totaldistance_km || 0, + stops + } + }; +}; // ── helpers ──────────────────────────────────────────────────────────────────── const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase(); @@ -227,6 +189,8 @@ const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + const STATUS_META = { Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' }, Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' }, + 'In progress': { color: '#F29900', icon: DeliveryDiningRoundedIcon, label: 'In progress' }, + Pending: { color: '#80868B', icon: ScheduleOutlinedIcon, label: 'Pending' }, }; const MODES = { @@ -395,9 +359,11 @@ function OrderDetailPanel({ data, onBack }) { // ════════════════════════════════════════════════════════════════════════════════ export default function RiderRoutes() { const [mode] = useState('pickup'); // pickups only - const [visible, setVisible] = useState(() => Object.fromEntries(RIDERS.map((r) => [r.id, true]))); + const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes + const [loading, setLoading] = useState(true); + const [visible, setVisible] = useState({}); const [routes, setRoutes] = useState({}); // keyed `${id}__${mode}` - const [expanded, setExpanded] = useState(RIDERS[0].id); + const [expanded, setExpanded] = useState(null); const [focusedStop, setFocusedStop] = useState(null); // `${riderId}-${index}` const [detail, setDetail] = useState(null); // { order, rider, mode, index } const [flyTarget, setFlyTarget] = useState(null); @@ -413,13 +379,29 @@ export default function RiderRoutes() { const modeCfg = MODES[mode]; + // Load today's rider routes for this hub. + useEffect(() => { + let cancelled = false; + getRiderRoutes() + .then((res) => { + if (cancelled) return; + const mapped = (res?.data || []).map(mapRoute); + setRiders(mapped); + setVisible(Object.fromEntries(mapped.map((r) => [r.id, true]))); + setExpanded(mapped[0]?.id ?? null); + }) + .catch(() => !cancelled && setRiders([])) + .finally(() => !cancelled && setLoading(false)); + return () => { cancelled = true; }; + }, []); + // Resolve road routes for the active mode (cached per id+mode). A ref guards // against re-fetching keys we've already resolved when the tab is revisited. useEffect(() => { let cancelled = false; (async () => { await Promise.all( - RIDERS.map(async (r) => { + riders.map(async (r) => { const key = `${r.id}__${mode}`; if (resolvedRef.current[key]) return; resolvedRef.current[key] = true; @@ -432,7 +414,7 @@ export default function RiderRoutes() { ); })(); return () => { cancelled = true; }; - }, [mode]); + }, [mode, riders]); const pathFor = useCallback( (r) => routes[`${r.id}__${mode}`] || r[mode].stops.map((s) => ({ lat: s.lat, lng: s.lng })), @@ -444,14 +426,14 @@ export default function RiderRoutes() { // Auto-fit points = visible riders' stops for the active mode. const fitPoints = useMemo(() => { const pts = []; - RIDERS.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); }); + riders.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); }); return pts; - }, [visible, stopsOf]); + }, [riders, visible, stopsOf]); // ── Analysis KPIs for the active mode ───────────────────────────────────────── const kpi = useMemo(() => { let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0; - RIDERS.forEach((r) => { + riders.forEach((r) => { const trip = r[mode]; const orderStops = trip.stops.filter((s) => s.kind === 'order'); if (orderStops.length) activeRiders += 1; @@ -462,7 +444,7 @@ export default function RiderRoutes() { cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0); }); return { orders, done, fail, km: km.toFixed(1), cod, activeRiders }; - }, [mode, modeCfg]); + }, [riders, mode, modeCfg]); // ── Animation driver ────────────────────────────────────────────────────────── useEffect(() => { @@ -506,7 +488,8 @@ export default function RiderRoutes() { const playState = useMemo(() => { if (!playing) return null; - const rider = RIDERS.find((r) => r.id === playing); + const rider = riders.find((r) => r.id === playing); + if (!rider) return null; const path = pathFor(rider); if (path.length < 2) return null; const totalSegs = path.length - 1; @@ -516,7 +499,7 @@ export default function RiderRoutes() { const pos = lerpPoint(path[i], path[i + 1], frac); const travelled = [...path.slice(0, i + 1), pos]; return { rider, path, travelled, pos }; - }, [playing, progress, pathFor]); + }, [playing, progress, pathFor, riders]); return ( @@ -536,10 +519,20 @@ export default function RiderRoutes() { + {!loading && riders.length === 0 && ( + + + No rider routes today + + Once milers are assigned pickups, their planned stops will show up here. + + + )} + {/* ── Analysis KPI strip ── */} {[ - { icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${RIDERS.length}`, color: '#1A73E8', bg: '#E8F0FE' }, + { icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${riders.length}`, color: '#1A73E8', bg: '#E8F0FE' }, { icon: modeCfg.icon, label: modeCfg.label, value: kpi.orders, sub: 'pickups covered', color: '#C01227', bg: alpha('#C01227', 0.1) }, { icon: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' }, { icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' }, @@ -561,7 +554,7 @@ export default function RiderRoutes() { - {RIDERS.map((rider) => { + {riders.map((rider) => { const trip = rider[mode]; const orders = trip.stops.filter((s) => s.kind === 'order'); const done = orders.filter((o) => o.status === modeCfg.doneStatus).length; @@ -749,7 +742,7 @@ export default function RiderRoutes() { {HUB.label}
Pickups return here
- {RIDERS.map((rider) => { + {riders.map((rider) => { if (!visible[rider.id]) return null; const path = pathFor(rider); const stops = stopsOf(rider); @@ -759,7 +752,7 @@ export default function RiderRoutes() { [p.lat, p.lng])} pathOptions={{ color: rider.color, weight: 5, opacity: dimmed ? 0.15 : 0.85, dashArray: modeCfg.dash }} /> {stops.map((s, i) => { - if (s.kind === 'hub') return null; + if (s.kind === 'hub' || s.lat == null || s.lng == null) return null; const key = `${rider.id}-${i}`; return ( ); })} - {!dimmed && ( + {!dimmed && endStop && ( {mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime} @@ -801,7 +794,7 @@ export default function RiderRoutes() { {/* Legend */} - {RIDERS.map((r) => ( + {riders.map((r) => ( toggleVisible(r.id)}> {r.name} diff --git a/src/pages/operations/Riders.jsx b/src/pages/operations/Riders.jsx index cacbb2c..2b8a296 100644 --- a/src/pages/operations/Riders.jsx +++ b/src/pages/operations/Riders.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { Box, Typography, Card, CardContent, Avatar, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Button, @@ -6,7 +6,7 @@ import { TextField, MenuItem, Divider, InputAdornment, Menu, ListItemIcon, Snackbar, Alert, useMediaQuery, Grid, Select, FormControl, InputLabel, Autocomplete, Switch, FormControlLabel, Paper, ToggleButtonGroup, - ToggleButton, Stepper, Step, StepLabel + ToggleButton, Stepper, Step, StepLabel, CircularProgress } from '@mui/material'; import { useTheme, alpha } from '@mui/material/styles'; @@ -48,6 +48,20 @@ import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; + +// The backend uses snake-case availability values; this page uses friendlier labels. +const API_TO_UI_STATUS = { Available: 'Idle', Assigned: 'On Pickup', On_Break: 'On Break', Offline: 'Offline' }; +const UI_TO_API_STATUS = { + Idle: 'Available', + 'On Pickup': 'Assigned', + 'On Break': 'On_Break', + 'Returning to Hub': 'Assigned', + Offline: 'Offline', + Suspended: 'Offline' +}; + // ── Reference data ────────────────────────────────────────────────────────────── const VEHICLES = { 'Electric Bike': { capacity: 30, icon: ElectricBikeOutlinedIcon }, @@ -74,74 +88,49 @@ const STATUS_META = { let idSeq = 8060; const genId = () => `RDR-${idSeq++}`; -// ── Seed roster ──────────────────────────────────────────────────────────────── -const SEED = [ - { - id: 'RDR-8012', name: 'Muthu Kumar', phone: '+91 98765 43210', hub: 'Delhi Operations Hub', - zones: ['Dwarka', 'Janakpuri'], vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234', - status: 'On Pickup', checkInTime: '08:12', hoursToday: 6.4, - assigned: 24, capacity: 30, pickupsPending: 3, deliveriesPending: 21, - deliveriesDone: 45, deliveriesFailed: 2, codCollected: 4500, codPending: 1200, - rating: 4.8, verified: true, - }, - { - id: 'RDR-8015', name: 'Rajesh Sharma', phone: '+91 98765 43211', hub: 'Delhi Operations Hub', - zones: ['Saket', 'Malviya Nagar'], vehicle: 'Cargo Van', vehicleNo: 'DL-01-CV-9876', - status: 'On Pickup', checkInTime: '07:50', hoursToday: 6.8, - assigned: 88, capacity: 120, pickupsPending: 12, deliveriesPending: 76, - deliveriesDone: 110, deliveriesFailed: 4, codCollected: 12000, codPending: 3400, - rating: 4.9, verified: true, - }, - { - id: 'RDR-8022', name: 'Vikram Singh', phone: '+91 98765 43212', hub: 'Delhi Operations Hub', - zones: ['Rohini'], vehicle: 'Motorcycle', vehicleNo: 'DL-08-MC-4567', - status: 'Idle', checkInTime: '09:05', hoursToday: 5.2, - assigned: 0, capacity: 25, pickupsPending: 0, deliveriesPending: 0, - deliveriesDone: 68, deliveriesFailed: 1, codCollected: 0, codPending: 0, - rating: 4.5, verified: false, - }, - // ... (rest of seed data remains the same) - { - id: 'RDR-8030', name: 'Amit Patel', phone: '+91 98765 43213', hub: 'Delhi Operations Hub', - zones: ['Vasant Kunj'], vehicle: 'Electric Bike', vehicleNo: 'DL-03-EB-7654', - status: 'Returning to Hub', checkInTime: '08:30', hoursToday: 7.1, - assigned: 2, capacity: 30, pickupsPending: 0, deliveriesPending: 2, - deliveriesDone: 55, deliveriesFailed: 6, codCollected: 8500, codPending: 0, - rating: 4.2, verified: true, - }, - { - id: 'RDR-8041', name: 'Sana Sheikh', phone: '+91 98765 43214', hub: 'Delhi Operations Hub', - zones: ['Central Delhi', 'Karol Bagh'], vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199', - status: 'On Pickup', checkInTime: '08:00', hoursToday: 6.6, - assigned: 19, capacity: 25, pickupsPending: 2, deliveriesPending: 17, - deliveriesDone: 73, deliveriesFailed: 0, codCollected: 6200, codPending: 900, - rating: 5.0, verified: true, - }, - { - id: 'RDR-8044', name: 'Harpreet Gill', phone: '+91 98765 43215', hub: 'Delhi Operations Hub', - zones: ['Mayur Vihar'], vehicle: 'Cycle', vehicleNo: '—', - status: 'On Break', checkInTime: '09:20', hoursToday: 3.9, - assigned: 8, capacity: 15, pickupsPending: 1, deliveriesPending: 7, - deliveriesDone: 22, deliveriesFailed: 1, codCollected: 1500, codPending: 300, - rating: 4.6, verified: true, - }, - { - id: 'RDR-8050', name: 'Deepak Yadav', phone: '+91 98765 43216', hub: 'Delhi Operations Hub', - zones: ['Lajpat Nagar'], vehicle: 'Mini Truck', vehicleNo: 'DL-09-MT-3321', - status: 'Offline', checkInTime: '—', hoursToday: 0, - assigned: 0, capacity: 200, pickupsPending: 0, deliveriesPending: 0, - deliveriesDone: 0, deliveriesFailed: 0, codCollected: 0, codPending: 0, - rating: 4.4, verified: false, - }, - { - id: 'RDR-8055', name: 'Iqbal Ahmed', phone: '+91 98765 43217', hub: 'Delhi Operations Hub', - zones: ['Connaught Place'], vehicle: 'Electric Bike', vehicleNo: 'DL-05-EB-8080', - status: 'Suspended', checkInTime: '—', hoursToday: 0, - assigned: 0, capacity: 30, pickupsPending: 0, deliveriesPending: 0, - deliveriesDone: 0, deliveriesFailed: 9, codCollected: 0, codPending: 5600, - rating: 3.1, verified: true, - }, -]; +// Turn a vehicle type name into the 1-based id the backend uses (best effort). +const vehicleIdFor = (vehicle) => Math.max(1, VEHICLE_TYPES.indexOf(vehicle) + 1); + +// Map a raw API miler onto the rich shape this page renders. Fields the API does +// not provide (zones, COD, live load) default to empty/zero so the UI still works. +// Format an ISO check-in timestamp as HH:MM (or "—"). +const checkInLabel = (iso) => { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +}; + +const mapMiler = (m, hubName) => { + // Real backend exposes defaultvehicletype (e.g. "Bike"); fall back to id lookup. + const vehicle = m.defaultvehicletype || VEHICLE_TYPES[(m.vehicleid || 1) - 1] || 'Motorcycle'; + const capacity = m.capacity || VEHICLES[vehicle]?.capacity || 0; + return { + id: m.userid, + userid: m.userid, + vehicleid: m.vehicleid, + hubid: m.hubid, + name: m.displayname || `Miler ${m.userid}`, + phone: m.phone || '—', + hub: hubName, + zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [], + vehicle, + vehicleNo: m.vehicleid ? `VEH-${m.vehicleid}` : '—', + status: API_TO_UI_STATUS[m.availabilitystatus] || 'Idle', + checkInTime: checkInLabel(m.checkinat), + hoursToday: m.hoursactive ?? 0, + assigned: m.assignedload ?? 0, + capacity, + pickupsPending: m.pickupspending ?? 0, + deliveriesPending: m.assignedload ?? 0, + deliveriesDone: m.totalcompletedpickups ?? m.completedorders ?? 0, + deliveriesFailed: m.totalcancelledpickups ?? m.cancelledorders ?? 0, + codCollected: m.codcollected ?? 0, + codPending: m.codpending ?? 0, + rating: m.rating ?? 0, + verified: Boolean(m.isverified ?? m.device_token) + }; +}; // ── Helpers ───────────────────────────────────────────────────────────── const successRate = (r) => { @@ -938,12 +927,35 @@ export default function Riders() { const isMdDown = useMediaQuery(theme.breakpoints.down('md')); const isLgDown = useMediaQuery(theme.breakpoints.down('lg')); - const [riders, setRiders] = useState(SEED); + const hub = getHubContext(); + const hubName = hub.hubname || 'This Hub'; + + const [riders, setRiders] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('All'); const [vehicleFilter, setVehicleFilter] = useState('All'); const [view, setView] = useState('table'); + const loadMilers = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const res = await getMilers(); + setRiders((res?.data || []).map((m) => mapMiler(m, hubName))); + } catch (err) { + setLoadError(err?.message || 'Could not load milers.'); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadMilers(); + }, [loadMilers]); + const [formDialog, setFormDialog] = useState({ open: false, mode: 'add', initial: null }); const [profile, setProfile] = useState(null); const [profileEdit, setProfileEdit] = useState(false); @@ -979,22 +991,43 @@ export default function Riders() { }); }, [riders, search, statusFilter, vehicleFilter]); - const handleSave = (rider) => { - if (formDialog.mode === 'add') { - setRiders(p => [rider, ...p]); - toast(`${rider.name} onboarded successfully`); - } else { - setRiders(p => p.map(r => r.id === rider.id ? rider : r)); - toast(`${rider.name} updated`); + // Build the API payload from the rich form object. + const toApiPayload = (rider) => ({ + displayname: rider.name, + phone: rider.phone, + hubid: rider.hubid || hub.hubid, + vehicleid: rider.vehicleid || vehicleIdFor(rider.vehicle), + availabilitystatus: UI_TO_API_STATUS[rider.status] || 'Available' + }); + + const handleSave = async (rider) => { + try { + if (formDialog.mode === 'add') { + await createMiler(toApiPayload(rider)); + toast(`${rider.name} onboarded successfully`); + } else { + await updateMiler(rider.userid ?? rider.id, toApiPayload(rider)); + toast(`${rider.name} updated`); + } + setFormDialog({ open: false, mode: 'add', initial: null }); + loadMilers(); + } catch (err) { + toast(err?.message || 'Could not save this miler.', 'error'); } - setFormDialog({ open: false, mode: 'add', initial: null }); }; - const handleDelete = () => { - setRiders(p => p.filter(r => r.id !== deleteTarget.id)); - toast(`${deleteTarget.name} removed`, 'info'); - setDeleteTarget(null); - setProfile(null); + const handleDelete = async () => { + const target = deleteTarget; + try { + await deleteMiler(target.userid ?? target.id); + setRiders((p) => p.filter((r) => r.id !== target.id)); + toast(`${target.name} removed`, 'info'); + } catch (err) { + toast(err?.message || 'Could not remove this miler.', 'error'); + } finally { + setDeleteTarget(null); + setProfile(null); + } }; const openAdd = () => setFormDialog({ open: true, mode: 'add', initial: null }); @@ -1009,11 +1042,16 @@ export default function Riders() { setProfile(r); setProfileEdit(false); }; - const saveProfileEdit = (updated) => { - setRiders(p => p.map(r => (r.id === updated.id ? updated : r))); - setProfile(updated); - setProfileEdit(false); - toast(`${updated.name} updated`); + const saveProfileEdit = async (updated) => { + try { + await updateMiler(updated.userid ?? updated.id, toApiPayload(updated)); + setRiders((p) => p.map((r) => (r.id === updated.id ? updated : r))); + setProfile(updated); + setProfileEdit(false); + toast(`${updated.name} updated`); + } catch (err) { + toast(err?.message || 'Could not update this miler.', 'error'); + } }; const openMenu = (e, r) => { setMenuAnchor(e.currentTarget); @@ -1253,7 +1291,16 @@ export default function Riders() { {/* Content Area */} - {filtered.length === 0 ? ( + {loadError && ( + setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> + {loadError} + + )} + {loading ? ( + + + + ) : filtered.length === 0 ? ( No milers match your filters diff --git a/src/pages/operations/Routing.jsx b/src/pages/operations/Routing.jsx index c31d4fc..544454b 100644 --- a/src/pages/operations/Routing.jsx +++ b/src/pages/operations/Routing.jsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Box, Typography, Card, CardContent, CardHeader, Grid, TextField, Button, - Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip + Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip, CircularProgress } from '@mui/material'; import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; @@ -10,76 +10,55 @@ import HelpIcon from '@mui/icons-material/Help'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import HubIcon from '@mui/icons-material/Hub'; +import AcUnitIcon from '@mui/icons-material/AcUnit'; import LocalOfferIcon from '@mui/icons-material/LocalOffer'; -// Mock DB with Advanced Logistics Scenarios -const HUB_NAME = 'Delhi Hub (DEL-01)'; -const LOCAL_ZONES = ['Dwarka', 'Janakpuri', 'Saket', 'Malviya Nagar', 'Rohini', 'Vasant Kunj', 'Central Delhi']; +import { getRouting, getInboundToday } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; -const PACKAGES_DB = { - 'DM-1001': { - id: 'DM-1001', - source: 'Local Pickup', - origin: 'Customer - Dwarka', - destHub: 'Delhi Hub (DEL-01)', - destZone: 'Janakpuri', - weight: '1.2 kg', - status: 'Arrived at Hub' - }, - 'DM-1002': { - id: 'DM-1002', - source: 'Local Pickup', - origin: 'Merchant - Saket', - destHub: 'Mumbai Hub (BOM-02)', - destZone: 'Andheri West', - weight: '3.5 kg', - status: 'Arrived at Hub' - }, - 'DM-1003': { - id: 'DM-1003', - source: 'Arrived from another city', - origin: 'Bengaluru Hub (BLR-02)', - destHub: 'Delhi Hub (DEL-01)', - destZone: 'Rohini', - weight: '0.5 kg', - status: 'Arrived at Hub' - }, - 'DM-1004': { - id: 'DM-1004', - source: 'Local Pickup', - origin: 'Customer - Vasant Kunj', - destHub: 'Delhi Hub (DEL-01)', - destZone: 'Central Delhi', - weight: '2.0 kg', - status: 'Arrived at Hub' - }, - 'DM-1005': { - id: 'DM-1005', - source: 'Arrived from another city', - origin: 'Chennai Hub (MAA-05)', - destHub: 'Delhi Hub (DEL-01)', - destZone: 'Dwarka', - weight: '15.0 kg', - status: 'Damaged Packaging', - exception: 'Box crushed during transit.' - }, - 'DM-1006': { - id: 'DM-1006', - source: 'Arrived from another city', - origin: 'Pune Hub (PNQ-03)', - destHub: 'Pune Hub (PNQ-03)', - destZone: 'Koregaon Park', - weight: '1.0 kg', - status: 'RTS (Return to Sender)', - exception: 'Customer rejected delivery at destination.' - } -}; +// A condition string that signals the parcel needs manual checking. +const isException = (condition) => + /damag|wet|crush|missing|broken/i.test(condition || ''); export default function Routing() { const [searchParams] = useSearchParams(); + const hub = getHubContext(); + const HUB_NAME = hub.hubname || 'This Hub'; + const [searchId, setSearchId] = useState(''); const [matchedPkg, setMatchedPkg] = useState(null); const [searched, setSearched] = useState(false); + const [loading, setLoading] = useState(false); + const [waiting, setWaiting] = useState([]); + + // Populate "Parcels Waiting" from today's inbound parcels at this hub. + useEffect(() => { + getInboundToday() + .then((res) => + setWaiting( + (res?.data || []) + .map((r) => ({ trackingno: r.trackingno || r.trackingnumber, dest: r.destinationname || r.deliverypincode || '—' })) + .filter((r) => r.trackingno) + ) + ) + .catch(() => setWaiting([])); + }, []); + + const handleSearch = useCallback(async (idToSearch) => { + const id = (typeof idToSearch === 'string' ? idToSearch : searchId).trim(); + if (!id) return; + setSearched(true); + setLoading(true); + try { + const res = await getRouting(id); + // Endpoint returns the routing object either at the root or under data. + setMatchedPkg(res?.data || res || null); + } catch { + setMatchedPkg(null); // 404 → not found state + } finally { + setLoading(false); + } + }, [searchId]); useEffect(() => { const query = searchParams.get('q'); @@ -87,58 +66,23 @@ export default function Routing() { setSearchId(query); handleSearch(query); } - }, [searchParams]); + }, [searchParams, handleSearch]); - const handleSearch = (idToSearch) => { - const id = typeof idToSearch === 'string' ? idToSearch : searchId; - setSearched(true); - if (id && PACKAGES_DB[id]) { - setMatchedPkg(PACKAGES_DB[id]); - } else { - setMatchedPkg(null); - } - }; - - // Advanced Sorting Logic: Classify by Next Action + // Classify the next action from the API's nexthop + condition. const determineNextAction = (pkg) => { - if (pkg.status.includes('Exception') || pkg.status === 'Damaged Packaging') { - return { - queue: 'Needs Checking', - action: 'Set aside for a supervisor', - color: '#D93025', bg: '#FCE8E6', icon: - }; + if (isException(pkg.condition)) { + return { queue: 'Needs Checking', action: 'Set aside for a supervisor', color: '#D93025', bg: '#FCE8E6', icon: }; } - if (pkg.status === 'RTS (Return to Sender)') { - return { - queue: 'Send Back', - action: 'Return to the sender', - color: '#F29900', bg: '#FEF7E0', icon: - }; + if (pkg.iscoldchain) { + return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', color: '#00838F', bg: '#E0F7FA', icon: }; } - if (pkg.destHub !== HUB_NAME) { - return { - queue: 'Transfer to Another City', - action: `Send to ${pkg.destHub}`, - color: '#8E24AA', bg: '#F3E5F5', icon: - }; + if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) { + return { queue: 'Transfer to Another City', action: pkg.nexthop, color: '#8E24AA', bg: '#F3E5F5', icon: }; } - // If destHub IS this hub, it's local delivery - if (LOCAL_ZONES.includes(pkg.destZone)) { - // If it was picked up locally but is going to a different area, it's a cross-area delivery. - if (pkg.source === 'Local Pickup' && !pkg.origin.includes(pkg.destZone)) { - return { - queue: 'Local Delivery (Other Area)', - action: `Send to ${pkg.destZone}`, - color: '#1A73E8', bg: '#E8F0FE', icon: - }; - } - return { - queue: 'Local Delivery', - action: `Give to a ${pkg.destZone} miler`, - color: '#1E8E3E', bg: '#E6F4EA', icon: - }; + if ((pkg.nexthop || '').toLowerCase().includes('local')) { + return { queue: 'Local Delivery', action: `Send to ${pkg.destination || 'the delivery lane'}`, color: '#1E8E3E', bg: '#E6F4EA', icon: }; } - return { queue: 'Unknown', action: 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: }; + return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: }; }; return ( @@ -146,7 +90,7 @@ export default function Routing() { Where Does It Go? - Scan a parcel and we'll tell you exactly what to do with it next deliver locally, transfer to another city, or set it aside. + Scan a parcel and we'll tell you exactly what to do with it next — deliver locally, transfer to another city, or set it aside. @@ -173,14 +117,16 @@ export default function Routing() { sx: { borderRadius: 2, bgcolor: '#fff' } }} /> -
@@ -195,33 +141,35 @@ export default function Routing() { - {Object.keys(PACKAGES_DB).map((key) => { - const pkg = PACKAGES_DB[key]; - return ( - { - setSearchId(key); - handleSearch(key); - }} - selected={searchId === key} - sx={{ - borderRadius: 2, mb: 1, p: 2, - border: '1px solid', - borderColor: searchId === key ? '#C01227' : '#eaeaea', - bgcolor: searchId === key ? '#C0122708' : '#fff', - '&:hover': { bgcolor: '#f8f9fa' } - }} - > - - - ); - })} + {waiting.length === 0 && ( + + No parcels inbounded today. + + )} + {waiting.map((pkg) => ( + { + setSearchId(pkg.trackingno); + handleSearch(pkg.trackingno); + }} + selected={searchId === pkg.trackingno} + sx={{ + borderRadius: 2, mb: 1, p: 2, + border: '1px solid', + borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea', + bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff', + '&:hover': { bgcolor: '#f8f9fa' } + }} + > + + + ))} @@ -245,12 +193,12 @@ export default function Routing() { ) : matchedPkg ? ( {matchedPkg.id}} - subheader={Source: {matchedPkg.source}} + title={{matchedPkg.trackingno}} + subheader={{matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}} action={ } sx={{ px: { xs: 2.5, sm: 4 }, pt: { xs: 3, sm: 4 }, pb: 2 }} @@ -284,42 +232,49 @@ export default function Routing() { justifyContent="space-between" spacing={2} > - - Came From - {matchedPkg.origin} - - Right Now (Here) {HUB_NAME} + + Put On Shelf + {matchedPkg.recommendedshelf || '—'} + + Going To - {matchedPkg.destHub === HUB_NAME ? matchedPkg.destZone : matchedPkg.destHub} + {matchedPkg.destination || '—'} {/* Handling Alerts */} - {matchedPkg.exception && ( + {isException(matchedPkg.condition) && ( Something's Wrong - {matchedPkg.exception} + Condition reported as {matchedPkg.condition}. Set it aside in the Exception Area for a supervisor. + + )} + + {matchedPkg.iscoldchain && ( + + Cold chain parcel + Move this parcel to the Cold Room (Zone C) right away. )} {determineNextAction(matchedPkg).queue === 'Transfer to Another City' && ( Put in the transfer bin - Place this parcel in the bin for {matchedPkg.destHub}. It will go out with the next city transfer. + {matchedPkg.nexthop}. It will go out with the next city transfer. )} {determineNextAction(matchedPkg).queue === 'Local Delivery' && ( Ready for local delivery - Place this parcel in the {matchedPkg.destZone} lane so a miler can take it out. + Place this parcel in the {matchedPkg.destination} lane so a miler can take it out. )} diff --git a/src/pages/operations/TrackingMap.jsx b/src/pages/operations/TrackingMap.jsx index 14e3d35..006d1e2 100644 --- a/src/pages/operations/TrackingMap.jsx +++ b/src/pages/operations/TrackingMap.jsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem, - ListItemAvatar, ListItemText, Badge, Divider + ListItemAvatar, ListItemText, Badge, Divider, Alert } from '@mui/material'; import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining'; import HubIcon from '@mui/icons-material/Hub'; @@ -10,6 +10,9 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react- import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { getMilers, getMilerLocations, getHubs, getTripsheetsInTransit } from '@/api/hub'; +import { getHubContext } from '@/auth/session'; + // Keeps Leaflet's canvas sized correctly when the container resizes (sidebar // toggle, window resize, first paint inside a flex box). Without this the map // renders grey/blank tiles — the #1 reason a real map "doesn't show". @@ -56,54 +59,131 @@ const createLinehaulIcon = () => new L.DivIcon({ iconAnchor: [17, 17], }); -// Mock Coordinates (Real Lat/Lng) -const HUBS = [ - { id: 'HB-DEL', name: 'Delhi Hub (DEL-01)', position: [28.6139, 77.2090], type: 'Main Hub' }, - { id: 'HB-BOM', name: 'Mumbai Hub (BOM-02)', position: [19.0760, 72.8777], type: 'City Hub' }, - { id: 'HB-BLR', name: 'Bengaluru Hub (BLR-03)', position: [12.9716, 77.5946], type: 'City Hub' } -]; +const prettyHubType = (t) => + ({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub'); -const INIT_RIDERS = [ - { id: 'R-102', name: 'Deepak Sharma', area: 'Dwarka, Delhi', status: 'Out for delivery', progress: 0.0, start: [28.5921, 77.0460], end: [28.6139, 77.2090], type: 'Miler' }, - { id: 'R-105', name: 'Karthik S.', area: 'Saket, Delhi', status: 'Picking up', progress: 0.3, start: [28.5245, 77.2066], end: [28.6139, 77.2090], type: 'Miler' } -]; - -const INIT_LINEHAUL = [ - { id: 'LH-DEL-BOM', name: 'Transfer: Delhi → Mumbai', status: 'On the way', progress: 0.1, start: [28.6139, 77.2090], end: [19.0760, 72.8777], type: 'Truck' } -]; +// Colour-code a miler pin/list item by their live availability status. +const statusMeta = (status) => { + switch (status) { + case 'Assigned': + case 'On Pickup': + return { color: '#1A73E8', label: status }; + case 'Available': + case 'Idle': + return { color: '#1E8E3E', label: status }; + case 'On_Break': + case 'On Break': + return { color: '#8E24AA', label: 'On Break' }; + case 'Offline': + return { color: '#80868B', label: 'Offline' }; + default: + return { color: '#0070f3', label: status || 'Active' }; + } +}; export default function TrackingMap() { - const [riders, setRiders] = useState(INIT_RIDERS); - const [linehauls, setLinehauls] = useState(INIT_LINEHAUL); + const hub = getHubContext(); + const [milers, setMilers] = useState([]); + const [error, setError] = useState(''); + const [hubs, setHubs] = useState([]); + const [linehauls, setLinehauls] = useState([]); + const didLoad = useRef(false); - // Smooth operational tracking loop iteration updates + // Hub pins are static per session — load once from /hub/hubs (has lat/lon). useEffect(() => { - const interval = setInterval(() => { - setRiders((prev) => - prev.map(r => ({ - ...r, - progress: r.progress >= 1 ? 0 : parseFloat((r.progress + 0.005).toFixed(3)) - })) - ); - - setLinehauls((prev) => - prev.map(l => ({ - ...l, - progress: l.progress >= 1 ? 0 : parseFloat((l.progress + 0.001).toFixed(3)) - })) - ); - }, 150); - return () => clearInterval(interval); + getHubs() + .then((res) => + setHubs( + (res?.data || []) + .filter((h) => h.lat != null && h.lon != null) + .map((h) => ({ id: h.hubid, name: h.hubname, position: [h.lat, h.lon], type: prettyHubType(h.hubtype) })) + ) + ) + .catch(() => setHubs([])); }, []); - const getInterpolatedPosition = (start, end, progress) => { - return [ - start[0] + (end[0] - start[0]) * progress, - start[1] + (end[1] - start[1]) * progress - ]; - }; + // Poll live miler GPS every 5 seconds. The guide's /admin/milers/locations + // (Redis GEO) returns nothing on this backend, so we read coordinates from + // /hub/milers (currentlatitude/currentlongitude) and fall back to the GEO + // endpoint if it ever starts returning data. + useEffect(() => { + let active = true; + const normalize = (list) => + (list || []) + .map((m) => ({ + userid: m.userid, + displayname: m.displayname, + lat: m.currentlatitude ?? m.lat, + lon: m.currentlongitude ?? m.lon, + status: m.availabilitystatus ?? m.status, + bookingid: m.currentbookingid ?? m.bookingid + })) + .filter((m) => m.lat != null && m.lon != null && !(m.lat === 0 && m.lon === 0)); - const mapCenter = [22.0, 76.0]; // Centered across the Delhi → Mumbai / Bengaluru network + const poll = async () => { + try { + const [geo, roster] = await Promise.all([ + getMilerLocations().catch(() => null), + getMilers().catch(() => null) + ]); + if (!active) return; + const geoList = normalize(geo?.data); + setMilers(geoList.length ? geoList : normalize(roster?.data)); + setError(''); + } catch (err) { + if (active && !didLoad.current) setError(err?.message || 'Could not load live miler locations.'); + } finally { + didLoad.current = true; + } + }; + poll(); + const interval = setInterval(poll, 5000); + return () => { + active = false; + clearInterval(interval); + }; + }, []); + + // Poll real transfer trucks in transit every 5 seconds. + useEffect(() => { + let active = true; + const poll = async () => { + try { + const res = await getTripsheetsInTransit(); + if (!active) return; + setLinehauls( + (res?.data || []) + .filter((t) => t.currentlat != null && t.currentlon != null) + .map((t) => ({ + id: t.tripsheetid, + name: t.label || t.tripsheetno || `Trip ${t.tripsheetid}`, + status: t.status || 'In transit', + progress: (t.progresspct ?? 0) / 100, + start: [t.originlat, t.originlon], + end: [t.destlat, t.destlon], + current: [t.currentlat, t.currentlon] + })) + ); + } catch { + /* leave last-known trucks on transient failure */ + } + }; + poll(); + const interval = setInterval(poll, 5000); + return () => { + active = false; + clearInterval(interval); + }; + }, []); + + // Centre on the milers when we have them, otherwise a sensible national view. + const mapCenter = + milers.length > 0 + ? [ + milers.reduce((s, m) => s + m.lat, 0) / milers.length, + milers.reduce((s, m) => s + m.lon, 0) / milers.length + ] + : [22.0, 76.0]; return ( @@ -112,6 +192,12 @@ export default function TrackingMap() { See where your milers and transfer trucks are right now, on the map. + {error && ( + setError('')} sx={{ mb: 3, borderRadius: 2 }}> + {error} + + )} + {/* Map Canvas Frame */} @@ -124,46 +210,38 @@ export default function TrackingMap() { /> {/* Plot Hubs */} - {HUBS.map(hub => ( - + {hubs.map((h) => ( + - {hub.name} - {hub.type} + {h.name} + {h.type} ))} - {/* Plot Milers */} - {riders.map(rider => { - const pos = getInterpolatedPosition(rider.start, rider.end, rider.progress); - return ( - - - - - {rider.name} - {rider.area} - - - - ); - })} + {/* Plot live Milers */} + {milers.map((m) => ( + + + {m.displayname || `Miler ${m.userid}`} + {statusMeta(m.status).label} + {m.bookingid && On booking #{m.bookingid}} + + + ))} - {/* Plot Linehaul */} - {linehauls.map(lh => { - const pos = getInterpolatedPosition(lh.start, lh.end, lh.progress); - return ( - - - - - {lh.name} - Progress: {Math.round(lh.progress * 100)}% - - - - ); - })} + {/* Plot transfer trucks in transit (real positions) */} + {linehauls.map((lh) => ( + + + + + {lh.name} + Progress: {Math.round(lh.progress * 100)}% + + + + ))} @@ -180,11 +258,16 @@ export default function TrackingMap() { /> - {HUBS.map(hub => ( - - + + + )} + {hubs.map((h) => ( + + @@ -196,31 +279,41 @@ export default function TrackingMap() { {/* Real-time Last Mile Miler Logs */} - Milers Out Now (Delhi)} + Milers Out Now{hub.city ? ` (${hub.city})` : ''}} avatar={} /> - - {riders.map(r => ( - - - - {r.name.charAt(0)} - - - - - {Math.round(r.progress * 100)}% - - - ))} - + {milers.length === 0 ? ( + + No milers reporting a location right now. + + ) : ( + + {milers.map((m) => { + const meta = statusMeta(m.status); + const name = m.displayname || `Miler ${m.userid}`; + return ( + + + + {name.charAt(0)} + + + + + {meta.label} + + + ); + })} + + )} {/* Linehaul Fleet Shipments Tracker */} diff --git a/vite.config.js b/vite.config.js index 23252ab..c1218d3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,7 +17,18 @@ export default defineConfig({ port: 3001, strictPort: true, host: true, - open: true + open: true, + // Proxy API calls through Vite so the browser makes same-origin requests. + // This sidesteps the backend's localhost-only CORS allowlist entirely — + // works whether you open the app on localhost, a LAN IP, or a hostname. + proxy: { + // Change `target` if you point the dev app at a different backend. + '/api': { + target: 'https://api.doormile.com', + changeOrigin: true, + secure: true + } + } } });