added the api for the real data

This commit is contained in:
2026-07-04 17:08:16 +05:30
parent 200831d19c
commit e6ad37b20f
22 changed files with 2854 additions and 1061 deletions

21
.env.example Normal file
View File

@@ -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

View File

@@ -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 <token>`
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*

View File

@@ -4,6 +4,7 @@ import { Box, CircularProgress } from '@mui/material';
import MainLayout from '@/layout/MainLayout'; import MainLayout from '@/layout/MainLayout';
import MinimalLayout from '@/layout/MinimalLayout'; import MinimalLayout from '@/layout/MinimalLayout';
import ProtectedRoute from '@/auth/ProtectedRoute';
const load = (factory) => { const load = (factory) => {
const C = lazy(factory); const C = lazy(factory);
@@ -23,17 +24,26 @@ const load = (factory) => {
export default function App() { export default function App() {
return ( return (
<Routes> <Routes>
{/* Shell pages */} {/* Shell pages — require a valid hub session */}
<Route element={<MainLayout />}> <Route
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} /> <Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
<Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} /> <Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} />
<Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} /> <Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} />
<Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} /> <Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} />
<Route path="/inventory" element={load(() => import('@/pages/operations/Inventory'))} />
<Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} /> <Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} />
<Route path="/assignments" element={load(() => import('@/pages/operations/OrderAssignment'))} /> <Route path="/assignments" element={load(() => import('@/pages/operations/OrderAssignment'))} />
<Route path="/riders" element={load(() => import('@/pages/operations/Riders'))} /> <Route path="/riders" element={load(() => import('@/pages/operations/Riders'))} />
<Route path="/rider-routes" element={load(() => import('@/pages/operations/RiderRoutes'))} /> <Route path="/rider-routes" element={load(() => import('@/pages/operations/RiderRoutes'))} />
<Route
path="/hub-settings"
element={<ProtectedRoute requireDoormile>{load(() => import('@/pages/operations/HubSettings'))}</ProtectedRoute>}
/>
</Route> </Route>
{/* Full-bleed pages */} {/* Full-bleed pages */}

106
src/api/client.js Normal file
View File

@@ -0,0 +1,106 @@
// ─────────────────────────────────────────────────────────────────────────────
// Low-level HTTP client for the Doormile Hub Console.
//
// Every authenticated call automatically attaches `Authorization: Bearer <token>`
// (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' })
};

147
src/api/hub.js Normal file
View File

@@ -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`, {});
}

View File

@@ -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 <Navigate to="/login" replace />;
}
if (requireDoormile && !isDoormileStaff()) {
return <Navigate to="/dashboard" replace />;
}
return children;
}

57
src/auth/session.js Normal file
View File

@@ -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());
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
AppBar, AppBar,
@@ -32,23 +32,36 @@ import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
import ChatIcon from '@mui/icons-material/Chat'; import ChatIcon from '@mui/icons-material/Chat';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; 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 DoneAllIcon from '@mui/icons-material/DoneAll';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SendIcon from '@mui/icons-material/Send'; 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 Logo from '@/components/Logo';
import { getStaff, getHubContext, clearSession } from '@/auth/session';
import { getNotifications, markNotificationRead } from '@/api/hub';
const RED = '#C01227'; const RED = '#C01227';
const INITIAL_NOTIFICATIONS = [ // Map a notification `type` to an icon component (real API sends type, not an icon).
{ id: 1, icon: LocalShippingOutlinedIcon, title: 'Linehaul MH-04-AX-8822 from Mumbai Hub arrived', time: '5 min ago', to: '/inbound', read: false }, const NOTIF_ICON = {
{ id: 2, icon: QrCodeScannerIcon, title: 'Manifest #MNF-2940 sorted & sealed', time: '15 min ago', to: '/dispatch', read: false }, exception: WarningAmberIcon,
{ id: 3, icon: TwoWheelerIcon, title: 'Last-mile Miler Deepak went online', time: '30 min ago', to: '/dispatch', read: false }, inbound: LocalShippingOutlinedIcon,
{ id: 4, icon: AcUnitIcon, title: 'Cold Storage Zone C temperature stabilized at 4.2°C', time: '1 hr ago', to: '/inventory', read: true } 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 = [ const MESSAGES = [
{ id: 1, name: 'Devendra (Gate Supervisor)', text: 'Jaipur vehicle is backing into Bay 4 now.', time: '3 min ago', initials: 'DS' }, { 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' } { 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 = { const INITIAL_CHATS = {
1: { 1: {
name: 'Devendra (Gate Supervisor)', name: 'Devendra (Gate Supervisor)',
@@ -139,6 +101,16 @@ const INITIAL_CHATS = {
export default function Header({ onToggle }) { export default function Header({ onToggle }) {
const navigate = useNavigate(); 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 [account, setAccount] = useState(null);
const [notifAnchor, setNotifAnchor] = useState(null); const [notifAnchor, setNotifAnchor] = useState(null);
const [msgAnchor, setMsgAnchor] = useState(null); const [msgAnchor, setMsgAnchor] = useState(null);
@@ -149,22 +121,53 @@ export default function Header({ onToggle }) {
const [chats, setChats] = useState(INITIAL_CHATS); const [chats, setChats] = useState(INITIAL_CHATS);
const [typedMessage, setTypedMessage] = useState(''); const [typedMessage, setTypedMessage] = useState('');
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS); const [notifications, setNotifications] = useState([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const unread = notifications.filter((n) => !n.read).length; 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 closeNotif = () => setNotifAnchor(null);
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
const markAllRead = async () => {
const onNotifClick = (n) => { 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))); setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
closeNotif(); closeNotif();
if (NOTIFICATION_DETAILS[n.id]) { try {
setSelectedNotif(NOTIFICATION_DETAILS[n.id]); await markNotificationRead(n.id);
} else { } catch {
navigate(n.to); /* best effort */
} }
}; };
@@ -283,13 +286,13 @@ export default function Header({ onToggle }) {
'&:hover': { bgcolor: 'grey.100' } '&:hover': { bgcolor: 'grey.100' }
}} }}
> >
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>RK</Avatar> <Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{toInitials(staffName)}</Avatar>
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}> <Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Rajesh Kumar {staffName}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
Delhi Hub Manager {hubName}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -380,7 +383,7 @@ export default function Header({ onToggle }) {
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, minWidth: 200 } }} PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
> >
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}> <MenuItem onClick={() => { setAccount(null); handleLogout(); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon> <ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout Logout
</MenuItem> </MenuItem>

View File

@@ -19,6 +19,7 @@ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import navItems from '@/menu/navItems'; import navItems from '@/menu/navItems';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { isDoormileStaff } from '@/auth/session';
export const DRAWER_WIDTH = 240; export const DRAWER_WIDTH = 240;
export const MINI_WIDTH = 72; export const MINI_WIDTH = 72;
@@ -97,6 +98,17 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const expanded = open || isMobile; 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)); 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) => (
<Box key={grp.group} sx={{ mt: 2.5 }}> <Box key={grp.group} sx={{ mt: 2.5 }}>
{expanded && ( {expanded && (
<Typography <Typography

View File

@@ -3,9 +3,9 @@ import MapRoundedIcon from '@mui/icons-material/MapRounded';
import HailRoundedIcon from '@mui/icons-material/HailRounded'; import HailRoundedIcon from '@mui/icons-material/HailRounded';
import MoveToInboxRoundedIcon from '@mui/icons-material/MoveToInboxRounded'; import MoveToInboxRoundedIcon from '@mui/icons-material/MoveToInboxRounded';
import AltRouteRoundedIcon from '@mui/icons-material/AltRouteRounded'; import AltRouteRoundedIcon from '@mui/icons-material/AltRouteRounded';
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded';
import LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded'; import LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded';
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded'; import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
// ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== // // ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== //
// Menu follows the parcel's real journey in plain language so any hub staff member // Menu follows the parcel's real journey in plain language so any hub staff member
@@ -34,8 +34,7 @@ const navItems = [
{ {
group: '3. Sort & Store', group: '3. Sort & Store',
items: [ items: [
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon }, { id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon }
{ id: 'inventory', title: 'Storage Shelves', url: '/inventory', icon: WarehouseRoundedIcon }
] ]
}, },
{ {
@@ -50,6 +49,14 @@ const navItems = [
{ id: 'riders', title: 'Milers', url: '/riders', icon: DeliveryDiningRoundedIcon }, { id: 'riders', title: 'Milers', url: '/riders', icon: DeliveryDiningRoundedIcon },
{ id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: AltRouteRoundedIcon } { id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: AltRouteRoundedIcon }
] ]
},
{
group: 'Management',
// Doormile staff only — hidden for partner (restricted) accounts.
doormileOnly: true,
items: [
{ id: 'hub-settings', title: 'Hub Settings', url: '/hub-settings', icon: SettingsRoundedIcon, doormileOnly: true }
]
} }
]; ];

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import { import {
Grid, Grid,
Card, Card,
@@ -20,7 +20,9 @@ import {
Avatar, Avatar,
Button, Button,
Divider, Divider,
Popover Popover,
Alert,
Skeleton
} from '@mui/material'; } from '@mui/material';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { alpha } from '@mui/material/styles'; import { alpha } from '@mui/material/styles';
@@ -33,7 +35,6 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import ElectricBoltIcon from '@mui/icons-material/ElectricBolt'; import ElectricBoltIcon from '@mui/icons-material/ElectricBolt';
import DynamicFeedIcon from '@mui/icons-material/DynamicFeed'; import DynamicFeedIcon from '@mui/icons-material/DynamicFeed';
import SpeedIcon from '@mui/icons-material/Speed'; import SpeedIcon from '@mui/icons-material/Speed';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining'; import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
@@ -41,6 +42,19 @@ import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded'; import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
import { getDashboard, getInboundVehicles, getActivity, getZones } from '@/api/hub';
import { getHubContext } from '@/auth/session';
// Format an ISO time as a short clock label for the activity feed.
const clockTime = (iso) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Map a vehicle status to the MUI chip colour the table expects.
const vehicleColor = (status) => (status === 'Unloading' ? 'success' : status === 'On the way' ? 'info' : 'default');
const BRAND = '#C01227'; const BRAND = '#C01227';
const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
@@ -152,19 +166,14 @@ function RangeCalendar({ from, to, maxDate, onSelect }) {
); );
} }
// Per-day snapshot for a single hub day. `cumulative` metrics add up across the // Live KPI cards — each maps to one field of GET /api/v1/hub/dashboard.
// selected date range; the rest are "right now" figures that stay as a live count. const KPI_DEFS = [
const STAT_DEFS = [ { key: 'parcels_received_today', label: 'Total Parcels Received', icon: DynamicFeedIcon, color: '#1A73E8', sub: 'Received today' },
{ label: 'Total Parcels', base: 3142, cumulative: true, icon: DynamicFeedIcon, color: '#1A73E8', sub: 'Handled in range' }, { key: 'milers_available', label: 'Available Milers', icon: DeliveryDiningIcon, color: '#1E8E3E', sub: 'Free right now' },
{ label: 'Picked Up Locally', base: 1482, cumulative: true, icon: LocalOfferIcon, color: '#1E8E3E', sub: 'Collected by milers' }, { key: 'milers_on_duty', label: 'Milers on Duty', icon: InfoOutlinedIcon, color: '#1A73E8', sub: 'Currently working' },
{ label: 'From Other Cities', base: 1660, cumulative: true, icon: LocalShippingIcon, color: '#1A73E8', sub: 'Arrived by truck' }, { key: 'pending_pickups', label: 'Pending Pickups', icon: AssignmentIcon, color: '#F29900', sub: 'Awaiting a miler' },
{ label: 'Ready for Delivery', base: 840, cumulative: false, icon: AssignmentIcon, color: '#00A854', sub: 'Sorted for local areas' }, { key: 'batches_sent_today', label: 'Batches Sent Today', icon: LocalShippingIcon, color: '#8E24AA', sub: 'Dispatched today' },
{ label: 'Ready to Transfer', base: 1120, cumulative: false, icon: LocalShippingIcon, color: '#8E24AA', sub: 'Going to other cities' }, { key: 'exceptions', label: 'Needs Checking', icon: WarningAmberIcon, color: '#D93025', sub: 'Damaged or unclear' }
{ label: 'Out for Delivery', base: 620, cumulative: false, icon: DeliveryDiningIcon, color: '#F29900', sub: 'With milers right now' },
{ label: 'Needs Checking', base: 48, cumulative: true, icon: WarningAmberIcon, color: '#D93025', sub: 'Damaged or unclear' },
{ label: 'Returns', base: 12, cumulative: true, icon: WarningAmberIcon, color: '#F29900', sub: 'Going back to sender' },
{ label: 'Available Milers', base: 24, cumulative: false, icon: InfoOutlinedIcon, color: '#1A73E8', sub: 'Free or on duty' },
{ label: 'Batches Going Out', base: 8, cumulative: true, icon: LocalShippingIcon, color: '#1E8E3E', sub: 'Sent in range' }
]; ];
const DATE_FMT = 'YYYY-MM-DD'; const DATE_FMT = 'YYYY-MM-DD';
@@ -175,6 +184,62 @@ export default function Dashboard() {
const [range, setRange] = useState({ from: weekAgo, to: today }); const [range, setRange] = useState({ from: weekAgo, to: today });
const [calAnchor, setCalAnchor] = useState(null); const [calAnchor, setCalAnchor] = useState(null);
const hub = getHubContext();
const [kpis, setKpis] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [incomingVehicles, setIncomingVehicles] = useState([]);
const [recentActivity, setRecentActivity] = useState([]);
const [activeRoutes, setActiveRoutes] = useState([]);
const loadDashboard = useCallback(async () => {
setLoading(true);
setError('');
try {
const [dash, vehicles, activity, zones] = await Promise.all([
getDashboard(),
getInboundVehicles().catch(() => null),
getActivity(8).catch(() => null),
getZones().catch(() => null)
]);
setKpis(dash?.data || {});
setIncomingVehicles(
(vehicles?.data || []).map((v) => ({
id: v.vehicleno || `Trip ${v.tripsheetid}`,
origin: v.origin || '—',
estTime: v.eta || '',
status: v.status || 'On the way',
progress: v.unloadedpct || 0,
color: vehicleColor(v.status)
}))
);
setRecentActivity(
(activity?.data || []).map((a) => ({ time: clockTime(a.time), type: a.type, text: a.text }))
);
setActiveRoutes(
(zones?.data || []).map((z) => ({
zone: z.zonename ? `${z.zonename} (${z.zone})` : z.zone,
packages: z.parcels ?? 0,
riders: z.milers ?? 0,
status: z.status || 'Active'
}))
);
} catch (err) {
setError(err?.message || 'Could not load dashboard stats.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
const kpiCards = KPI_DEFS.map((d) => ({
...d,
value: kpis && kpis[d.key] != null ? Number(kpis[d.key]).toLocaleString('en-IN') : '—'
}));
// Inclusive day count for the chosen window (min 1); drives cumulative metrics. // Inclusive day count for the chosen window (min 1); drives cumulative metrics.
const dayCount = useMemo(() => { const dayCount = useMemo(() => {
const from = dayjs(range.from); const from = dayjs(range.from);
@@ -191,39 +256,6 @@ export default function Dashboard() {
const isPreset = (days) => const isPreset = (days) =>
range.to === today && range.from === dayjs().subtract(days - 1, 'day').format(DATE_FMT); range.to === today && range.from === dayjs().subtract(days - 1, 'day').format(DATE_FMT);
const stats = useMemo(
() =>
STAT_DEFS.map((s) => ({
...s,
value: (s.cumulative ? s.base * dayCount : s.base).toLocaleString('en-IN')
})),
[dayCount]
);
const rangeLabel =
dayCount === 1
? dayjs(range.from).format('DD MMM YYYY')
: `${dayjs(range.from).format('DD MMM')} ${dayjs(range.to).format('DD MMM YYYY')} · ${dayCount} days`;
const incomingVehicles = [
{ id: 'Truck MH-04-8822', origin: 'Mumbai Hub', estTime: 'Arrived (Bay 4)', status: 'Unloading', progress: 85, color: 'success' },
{ id: 'Truck RJ-14-1049', origin: 'Jaipur Hub', estTime: '15 min away', status: 'Expected', progress: 0, color: 'info' },
{ id: 'Truck KA-03-0284', origin: 'Bengaluru Hub', estTime: '1.5 hrs away', status: 'On the way', progress: 0, color: 'default' }
];
const recentActivity = [
{ time: '11:24 AM', type: 'inbound', text: 'Received 142 parcels from the Mumbai truck' },
{ time: '11:15 AM', type: 'dispatch', text: 'Batch BATCH-9281 sent out with miler Deepak (West Delhi)' },
{ time: '10:50 AM', type: 'exception', text: 'Parcel DM-1005 put on hold (damaged label)' },
{ time: '10:30 AM', type: 'sorting', text: 'Cold room temperature checked — all good (4.2°C)' }
];
const activeRoutes = [
{ zone: 'West Delhi (Dwarka)', packages: 145, riders: 4, status: 'Active' },
{ zone: 'South Delhi (Saket)', packages: 210, riders: 6, status: 'Active' },
{ zone: 'East Delhi (Mayur Vihar)', packages: 98, riders: 3, status: 'Need Milers' },
{ zone: 'North Delhi (Rohini)', packages: 122, riders: 4, status: 'Active' }
];
return ( return (
<Box> <Box>
@@ -238,13 +270,10 @@ export default function Dashboard() {
{/* Left Title Section */} {/* Left Title Section */}
<Box sx={{ flexShrink: 0 }}> <Box sx={{ flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1E293B', letterSpacing: '-0.02em', mb: 0.5 }}> <Typography variant="h5" sx={{ fontWeight: 700, color: '#1E293B', letterSpacing: '-0.02em', mb: 0.5 }}>
Delhi Hub {hub.hubname || 'Doormile Hub'}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: '#64748B', fontSize: '0.825rem' }}> <Typography variant="body2" sx={{ color: '#64748B', fontSize: '0.825rem' }}>
Showing hub activity for{' '} Live hub snapshot{hub.city ? ` · ${hub.city}` : ''}
<Box component="span" sx={{ fontWeight: 600, color: '#0F172A' }}>
{rangeLabel}
</Box>
</Typography> </Typography>
</Box> </Box>
@@ -258,19 +287,21 @@ export default function Dashboard() {
sx={{ flexGrow: 1, minWidth: 0, justifyContent: { xs: 'flex-start', md: 'flex-end' } }} sx={{ flexGrow: 1, minWidth: 0, justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
> >
{/* Refresh Button */} {/* Refresh Button */}
<IconButton <IconButton
color="primary" color="primary"
sx={{ onClick={loadDashboard}
border: '1px solid #E2E8F0', disabled={loading}
width: 36, sx={{
height: 36, border: '1px solid #E2E8F0',
borderRadius: 2, width: 36,
height: 36,
borderRadius: 2,
bgcolor: '#ffffff', bgcolor: '#ffffff',
color: '#64748B', color: '#64748B',
'&:hover': { bgcolor: '#F8FAFC', color: '#0F172A' } '&:hover': { bgcolor: '#F8FAFC', color: '#0F172A' }
}} }}
> >
<RefreshIcon sx={{ fontSize: 18 }} /> <RefreshIcon sx={{ fontSize: 18, animation: loading ? 'spin 1s linear infinite' : 'none', '@keyframes spin': { to: { transform: 'rotate(360deg)' } } }} />
</IconButton> </IconButton>
{/* Quick Presets */} {/* Quick Presets */}
@@ -362,6 +393,13 @@ export default function Dashboard() {
</Stack> </Stack>
</Popover> </Popover>
{/* Live stats error banner */}
{error && (
<Alert severity="error" onClose={() => setError('')} sx={{ borderRadius: 2, mb: 2 }}>
{error}
</Alert>
)}
{/* Metrics Row — CSS grid with minmax(0,1fr) never overflows on mobile */} {/* Metrics Row — CSS grid with minmax(0,1fr) never overflows on mobile */}
<Box <Box
sx={{ sx={{
@@ -369,14 +407,13 @@ export default function Dashboard() {
gridTemplateColumns: { gridTemplateColumns: {
xs: 'repeat(2, minmax(0, 1fr))', xs: 'repeat(2, minmax(0, 1fr))',
sm: 'repeat(3, minmax(0, 1fr))', sm: 'repeat(3, minmax(0, 1fr))',
md: 'repeat(4, minmax(0, 1fr))', lg: 'repeat(6, minmax(0, 1fr))'
lg: 'repeat(5, minmax(0, 1fr))'
}, },
gap: { xs: 1.5, sm: 2, md: 3 }, gap: { xs: 1.5, sm: 2, md: 3 },
mb: { xs: 3, md: 5 } mb: { xs: 3, md: 5 }
}} }}
> >
{stats.map((s) => { {kpiCards.map((s) => {
const Icon = s.icon; const Icon = s.icon;
return ( return (
<Card key={s.label} sx={{ height: '100%', position: 'relative', overflow: 'hidden', borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.03)', border: '1px solid #ECEEF1' }}> <Card key={s.label} sx={{ height: '100%', position: 'relative', overflow: 'hidden', borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.03)', border: '1px solid #ECEEF1' }}>
@@ -389,9 +426,13 @@ export default function Dashboard() {
{s.label} {s.label}
</Typography> </Typography>
</Stack> </Stack>
<Typography sx={{ fontWeight: 800, color: '#212529', fontSize: { xs: '1.5rem', sm: '1.9rem' }, lineHeight: 1.15, mb: 0.75 }}> {loading ? (
{s.value} <Skeleton variant="text" width="60%" sx={{ fontSize: { xs: '1.5rem', sm: '1.9rem' }, mb: 0.75 }} />
</Typography> ) : (
<Typography sx={{ fontWeight: 800, color: '#212529', fontSize: { xs: '1.5rem', sm: '1.9rem' }, lineHeight: 1.15, mb: 0.75 }}>
{s.value}
</Typography>
)}
<Typography variant="caption" sx={{ display: 'block', color: '#6c757d', fontWeight: 500 }}> <Typography variant="caption" sx={{ display: 'block', color: '#6c757d', fontWeight: 500 }}>
{s.sub} {s.sub}
</Typography> </Typography>

View File

@@ -11,7 +11,9 @@ import {
Button, Button,
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Link Link,
Alert,
CircularProgress
} from '@mui/material'; } from '@mui/material';
import Visibility from '@mui/icons-material/Visibility'; import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff'; 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 VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { login as loginRequest } from '@/api/hub';
import { setSession } from '@/auth/session';
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [show, setShow] = useState(false); 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 [pwd, setPwd] = useState('password123');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSignIn = () => { const handleSignIn = async () => {
navigate('/dashboard'); 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 ( return (
@@ -68,7 +92,7 @@ export default function Login() {
<br /> handled with ease. <br /> handled with ease.
</Typography> </Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}> <Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
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.
</Typography> </Typography>
<Stack spacing={2.5}> <Stack spacing={2.5}>
@@ -129,11 +153,12 @@ export default function Login() {
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}> <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>
Username / Email Username / Email
</Typography> </Typography>
<TextField <TextField
fullWidth fullWidth
placeholder="Enter your email" placeholder="Enter your email"
value={auth} value={auth}
onChange={(e) => setAuth(e.target.value)} onChange={(e) => setAuth(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
/> />
</Box> </Box>
@@ -147,6 +172,7 @@ export default function Login() {
placeholder="Enter your password" placeholder="Enter your password"
value={pwd} value={pwd}
onChange={(e) => setPwd(e.target.value)} onChange={(e) => setPwd(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
@@ -169,26 +195,34 @@ export default function Login() {
</Link> </Link>
</Stack> </Stack>
<Button {error && (
fullWidth <Alert severity="error" sx={{ borderRadius: 2 }}>
size="large" {error}
variant="contained" </Alert>
)}
<Button
fullWidth
size="large"
variant="contained"
onClick={handleSignIn} onClick={handleSignIn}
sx={{ disabled={loading}
bgcolor: '#C01227', startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
sx={{
bgcolor: '#C01227',
color: '#fff', color: '#fff',
py: 1.5, py: 1.5,
fontWeight: 600, fontWeight: 600,
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: 'none',
boxShadow: 'none', boxShadow: 'none',
'&:hover': { '&:hover': {
bgcolor: '#9E0E20', bgcolor: '#9E0E20',
boxShadow: 'none' boxShadow: 'none'
} }
}} }}
> >
Sign In {loading ? 'Signing in…' : 'Sign In'}
</Button> </Button>
<Box sx={{ textAlign: 'center', mt: 1 }}> <Box sx={{ textAlign: 'center', mt: 1 }}>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Typography, Typography,
@@ -25,7 +25,8 @@ import {
Alert, Alert,
Avatar, Avatar,
Divider, Divider,
useMediaQuery useMediaQuery,
CircularProgress
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; 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 SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { getBatches, createBatch, updateBatchStatus } from '@/api/hub';
const INITIAL_MANIFESTS = [ import { getHubContext } from '@/auth/session';
{ 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' }
];
const STATUS_META = { const STATUS_META = {
Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' }, Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' },
@@ -51,75 +48,147 @@ const STATUS_META = {
Sent: { color: '#1E8E3E', bg: '#E6F4EA', label: 'Sent' } 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() { export default function Dispatch() {
const theme = useTheme(); const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md')); 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 [openModal, setOpenModal] = useState(false);
const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState(null);
// Create form state // Create form state
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub'); const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
const [newDestination, setNewDestination] = useState(''); const [newDestination, setNewDestination] = useState('');
const [newVehicle, setNewVehicle] = useState(''); const [newVehicle, setNewVehicle] = useState('');
const [newPkgsCount, setNewPkgsCount] = useState('5'); const [newPkgsCount, setNewPkgsCount] = useState('5');
// Toast state // Toast state
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); 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(); e.preventDefault();
if (!newVehicle) { if (creating) return;
setToast({ open: true, msg: 'Please input Miler or Vehicle info', severity: 'warning' }); if (!newVehicle.trim() || !newDestination.trim()) {
setToast({ open: true, msg: 'Enter the destination and the miler / vehicle.', severity: 'warning' });
return; return;
} }
const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local';
const randomNum = Math.floor(1000 + Math.random() * 9000); setCreating(true);
const newManifest = { try {
id: `BATCH-${randomNum}`, const res = await createBatch({
route: newRoute, route: newRoute,
vehicle: newVehicle, destination: newDestination.trim(),
packagesCount: parseInt(newPkgsCount, 10), vehicle: newVehicle.trim(),
status: 'Preparing', parcels_count: parseInt(newPkgsCount, 10) || 0,
time: 'Created just now', kind
origin: 'Delhi Hub', });
currentLoc: 'Delhi Hub (Dispatch Dock)', const label = res?.data?.batchlabel || 'New batch';
destination: newDestination || newRoute setOpenModal(false);
}; setNewVehicle('');
setNewDestination('');
setManifests([newManifest, ...manifests]); setToast({ open: true, msg: `Batch ${label} created successfully`, severity: 'success' });
setOpenModal(false); load();
setNewVehicle(''); } catch (err) {
setNewDestination(''); setToast({ open: true, msg: err?.message || 'Could not create the batch.', severity: 'error' });
setToast({ open: true, msg: `Batch ${newManifest.id} created successfully`, severity: 'success' }); } finally {
setCreating(false);
}
}; };
const handleSeal = (id) => { // Move a batch to the next status via PATCH.
setManifests((prev) => const advance = async (m, apiStatus, uiStatus, okMsg) => {
prev.map((m) => (m.id === id ? { ...m, status: 'Ready', time: 'Checked & ready just now' } : m)) if (busyId) return;
); setBusyId(m.tripsheetid);
setToast({ open: true, msg: `Batch ${id} checked and ready to send.`, severity: 'success' }); 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) => { const handleSeal = (m) => advance(m, 'Ready', 'Ready', `Batch ${m.id} checked and ready to send.`);
setManifests((prev) => const handleDispatch = (m) => advance(m, 'Dispatched', 'Sent', `Batch ${m.id} sent out! The miler/driver has been notified.`);
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' });
};
// Action button shown for each batch based on its status // Action button shown for each batch based on its status
const BatchAction = ({ m, fullWidth }) => { const BatchAction = ({ m, fullWidth }) => {
const isBusy = busyId === m.tripsheetid;
if (m.status === 'Preparing') { if (m.status === 'Preparing') {
return ( return (
<Button size="small" variant="outlined" color="info" fullWidth={fullWidth} onClick={() => handleSeal(m.id)} sx={{ borderRadius: 2, fontWeight: 700 }}> <Button size="small" variant="outlined" color="info" fullWidth={fullWidth} disabled={isBusy} onClick={() => handleSeal(m)}
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : null} sx={{ borderRadius: 2, fontWeight: 700 }}>
Check &amp; Mark Ready Check &amp; Mark Ready
</Button> </Button>
); );
} }
if (m.status === 'Ready') { if (m.status === 'Ready') {
return ( return (
<Button size="small" variant="contained" color="success" fullWidth={fullWidth} startIcon={<SendIcon sx={{ fontSize: 16 }} />} onClick={() => handleDispatch(m.id)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}> <Button size="small" variant="contained" color="success" fullWidth={fullWidth} disabled={isBusy}
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : <SendIcon sx={{ fontSize: 16 }} />} onClick={() => handleDispatch(m)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}>
Send Out Send Out
</Button> </Button>
); );
@@ -156,7 +225,7 @@ export default function Dispatch() {
<Card> <Card>
<CardHeader <CardHeader
title="Outgoing Batches" title="Outgoing Batches"
subheader="Each batch is a group of parcels leaving Delhi Hub together" subheader={`Each batch is a group of parcels leaving ${hubName} together`}
action={ action={
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}> <Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}>
New Batch New Batch
@@ -165,7 +234,22 @@ export default function Dispatch() {
/> />
<Divider /> <Divider />
{isMdDown ? ( {loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
{loadError}
</Alert>
)}
{loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : manifests.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<LocalShippingIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No outgoing batches yet. Create one to get started.</Typography>
</Box>
) : isMdDown ? (
/* ── MOBILE / TABLET: spacious cards ── */ /* ── MOBILE / TABLET: spacious cards ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{manifests.map((m) => { {manifests.map((m) => {
@@ -304,9 +388,10 @@ export default function Dispatch() {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOpenModal(false)}>Cancel</Button> <Button onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button>
<Button variant="contained" onClick={handleCreateManifest}> <Button variant="contained" onClick={handleCreateManifest} disabled={creating}
Create Batch startIcon={creating ? <CircularProgress size={16} color="inherit" /> : null}>
{creating ? 'Creating…' : 'Create Batch'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -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 ? (
<Chip size="small" icon={<CheckCircleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="Has login"
sx={{ fontWeight: 700, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />
) : (
<Chip size="small" icon={<HighlightOffOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="No login"
sx={{ fontWeight: 700, bgcolor: '#FEF7E0', color: '#B06000', '& .MuiChip-icon': { color: '#B06000' } }} />
);
return (
<Box>
<PageHeader
icon={SettingsRoundedIcon}
title="Hub Settings"
subtitle={`Create and manage hubs and staff logins${hub.city ? ` across ${hub.city}` : ''}. Doormile staff only.`}
/>
<Card>
<CardHeader
title={`Hubs in ${hub.city || 'your city'}`}
subheader="Every hub Doormile operates here"
avatar={<Avatar variant="rounded" sx={{ bgcolor: '#C0122710', color: BRAND, borderRadius: 2 }}><WarehouseOutlinedIcon /></Avatar>}
action={
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button variant="outlined" startIcon={<PersonAddAlt1OutlinedIcon />} onClick={openStaffDialog} disabled={hubs.length === 0}
sx={{ borderRadius: 2, fontWeight: 700 }}>
Add Staff
</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={openHubDialog}
sx={{ borderRadius: 2, fontWeight: 700, bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
Add New Hub
</Button>
</Stack>
}
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
/>
<Divider />
{loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
{loadError}
</Alert>
)}
{loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : hubs.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<WarehouseOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No hubs yet. Add your first hub.</Typography>
</Box>
) : isMdDown ? (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{hubs.map((h) => (
<Card key={h.hubid} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
<Box>
<Typography sx={{ fontWeight: 800, color: '#1A1A2E' }}>{h.hubname}</Typography>
<Typography variant="caption" color="text.secondary">{prettyType(h.hubtype)} · Cap {h.capacity}</Typography>
</Box>
<StaffChip has={(h.has_staff ?? h.has_staff_account)} />
</Stack>
</CardContent>
</Card>
))}
</Box>
) : (
<TableContainer>
<Table sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
{['Hub', 'Type', 'Capacity', 'Status', 'Staff Login'].map((h) => (
<TableCell key={h} sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 1.75 }}>
{h}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{hubs.map((h) => (
<TableRow key={h.hubid} hover>
<TableCell sx={{ fontWeight: 700, color: '#1A1A2E' }}>{h.hubname}</TableCell>
<TableCell>{prettyType(h.hubtype)}</TableCell>
<TableCell>{h.capacity}</TableCell>
<TableCell>
<Chip size="small" label={h.status || 'active'}
sx={{ fontWeight: 700, textTransform: 'capitalize', bgcolor: '#E8F0FE', color: '#1A73E8' }} />
</TableCell>
<TableCell><StaffChip has={(h.has_staff ?? h.has_staff_account)} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
{/* Create Hub dialog */}
<Dialog open={hubDialog} onClose={() => setHubDialog(false)} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 700 }}>Add a New Hub</DialogTitle>
<DialogContent>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
The city is set automatically from your hub it cant be changed here.
</Typography>
<Stack spacing={2.5} sx={{ mt: 0.5 }}>
<TextField fullWidth label="Hub name" value={hubForm.hubname} required
onChange={(e) => setHubForm((f) => ({ ...f, hubname: e.target.value }))} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField select fullWidth label="Hub type" value={hubForm.hubtype}
onChange={(e) => setHubForm((f) => ({ ...f, hubtype: e.target.value }))}>
{HUB_TYPES.map((t) => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</TextField>
<TextField fullWidth type="number" label="Capacity" value={hubForm.capacity} inputProps={{ min: 0 }}
onChange={(e) => setHubForm((f) => ({ ...f, capacity: e.target.value }))} />
</Stack>
<TextField fullWidth label="Contact number" value={hubForm.contact}
onChange={(e) => setHubForm((f) => ({ ...f, contact: e.target.value }))} />
<TextField fullWidth label="Address" value={hubForm.address}
onChange={(e) => setHubForm((f) => ({ ...f, address: e.target.value }))} />
<TextField fullWidth label="Pincode" value={hubForm.pincode}
onChange={(e) => setHubForm((f) => ({ ...f, pincode: e.target.value }))} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setHubDialog(false)} disabled={savingHub}>Cancel</Button>
<Button variant="contained" onClick={submitHub} disabled={savingHub}
startIcon={savingHub ? <CircularProgress size={16} color="inherit" /> : null}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
{savingHub ? 'Creating…' : 'Create Hub'}
</Button>
</DialogActions>
</Dialog>
{/* Create Staff dialog */}
<Dialog open={staffDialog} onClose={() => setStaffDialog(false)} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 700 }}>Add a Hub Staff Login</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ mt: 1 }}>
<TextField select fullWidth label="Hub" value={staffForm.hubid} required
onChange={(e) => setStaffForm((f) => ({ ...f, hubid: e.target.value }))}>
{hubs.map((h) => <MenuItem key={h.hubid} value={h.hubid}>{h.hubname}</MenuItem>)}
</TextField>
<TextField fullWidth label="Display name" value={staffForm.displayname}
onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} />
<TextField fullWidth type="email" label="Email" value={staffForm.email} required
onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} />
<TextField fullWidth type={showPwd ? 'text' : 'password'} label="Password" value={staffForm.password} required
onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPwd((s) => !s)} edge="end" size="small">
{showPwd ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
</InputAdornment>
)
}} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button>
<Button variant="contained" onClick={submitStaff} disabled={savingStaff}
startIcon={savingStaff ? <CircularProgress size={16} color="inherit" /> : null}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
{savingStaff ? 'Creating…' : 'Create Login'}
</Button>
</DialogActions>
</Dialog>
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}>
{toast.msg}
</Alert>
</Snackbar>
</Box>
);
}

View File

@@ -1,9 +1,9 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import { import {
Box, Typography, Card, CardContent, Grid, TextField, Button, Stack, Box, Typography, Card, CardContent, Grid, TextField, Button, Stack,
MenuItem, Table, TableBody, TableCell, TableContainer, TableHead, MenuItem, Table, TableBody, TableCell, TableContainer, TableHead,
TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider, TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider,
useMediaQuery useMediaQuery, CircularProgress
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined'; 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 AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import PageHeader from '@/components/PageHeader'; 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 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 = [ const ORIGINS = [
{ value: 'Mumbai Hub', label: 'Mumbai Hub (BOM-02)' }, { value: 'Mumbai Hub', label: 'Mumbai Hub (BOM-02)' },
{ value: 'Jaipur Hub', label: 'Jaipur Hub (JAI-08)' }, { value: 'Jaipur Hub', label: 'Jaipur Hub (JAI-08)' },
{ value: 'Bengaluru Hub', label: 'Bengaluru Hub (BLR-03)' }, { 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) => { const shelfStyle = (shelf) => {
if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' }; if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' };
if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' }; if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' };
@@ -49,7 +77,10 @@ const isGood = (c) => c === 'Good';
export default function Inbound() { export default function Inbound() {
const theme = useTheme(); const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md')); 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 [trackingId, setTrackingId] = useState('');
const [origin, setOrigin] = useState('Mumbai Hub'); const [origin, setOrigin] = useState('Mumbai Hub');
const [customer, setCustomer] = useState(''); const [customer, setCustomer] = useState('');
@@ -58,10 +89,32 @@ export default function Inbound() {
const [weight, setWeight] = useState(''); const [weight, setWeight] = useState('');
const [condition, setCondition] = useState('Good'); const [condition, setCondition] = useState('Good');
const [temp, setTemp] = useState(''); 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 [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 todays 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 stats = useMemo(() => {
const received = inboundLogs.length; const received = inboundLogs.length;
const exceptions = inboundLogs.filter(l => l.condition !== 'Good' || l.shelf === 'Exception Area').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 handleRandomScan = () => {
const randomNum = Math.floor(100000 + Math.random() * 900000); const randomNum = Math.floor(100000 + Math.random() * 900000);
setTrackingId(`DM-${randomNum}`); setTrackingId(`DM-${randomNum}`);
setBookingId(String(Math.floor(1 + Math.random() * 200)));
const customers = ['Acme Electronics', 'Delhi Medicos', 'Urban Fashion', 'Astro Retail', 'Fresho Foods']; 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 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']; 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)]); 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(); e.preventDefault();
if (!trackingId || !destination) { if (submitting) return;
setToast({ open: true, msg: 'Please scan or fill tracking details', severity: 'warning' }); if (!bookingId.trim() || !trackingId.trim()) {
setToast({ open: true, msg: 'Enter the booking ID and tracking ID to scan a parcel in.', severity: 'warning' });
return; 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 = { setSubmitting(true);
trackingId, sender: customer || 'Unknown Sender', origin, currentLoc: 'Delhi Hub', try {
destination, weight: weight || '1.0 kg', condition, temp: temp || 'N/A', const res = await inboundBooking(bookingId.trim(), {
shelf: recommendedShelf, time: 'Just now' tracking_id: trackingId.trim(),
}; condition,
setInboundLogs([newLog, ...inboundLogs]); temperature: temp || 'N/A',
setToast({ open: true, msg: `${trackingId} received · routed to ${recommendedShelf}`, severity: 'success' }); shelf: recommendShelf(),
setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp(''); 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 } }; const fieldSx = { '& .MuiOutlinedInput-root': { borderRadius: 2 } };
@@ -195,7 +282,7 @@ export default function Inbound() {
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Record a parcel arriving at Delhi Hub Record a parcel arriving at {hubName}
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
@@ -204,6 +291,11 @@ export default function Inbound() {
<CardContent sx={{ pt: 3 }}> <CardContent sx={{ pt: 3 }}>
<Box component="form" onSubmit={handleSubmit}> <Box component="form" onSubmit={handleSubmit}>
<Stack spacing={2.5}> <Stack spacing={2.5}>
<TextField fullWidth label="Booking ID" placeholder="e.g. 15" value={bookingId}
onChange={(e) => setBookingId(e.target.value)} sx={fieldSx} required
helperText="Consignment / booking number for this parcel"
InputProps={{ startAdornment: <InputAdornment position="start"><Inventory2OutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId} <TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId}
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx} onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
InputProps={{ InputProps={{
@@ -248,10 +340,11 @@ export default function Inbound() {
onChange={(e) => setTemp(e.target.value)} sx={fieldSx} onChange={(e) => setTemp(e.target.value)} sx={fieldSx}
InputProps={{ startAdornment: <InputAdornment position="start"><ThermostatOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} /> InputProps={{ startAdornment: <InputAdornment position="start"><ThermostatOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
<Button fullWidth size="large" variant="contained" type="submit" startIcon={<CheckCircleOutlinedIcon />} <Button fullWidth size="large" variant="contained" type="submit" disabled={submitting}
startIcon={submitting ? <CircularProgress size={18} color="inherit" /> : <CheckCircleOutlinedIcon />}
sx={{ mt: 0.5, py: 1.3, borderRadius: 2, bgcolor: '#C01227', fontWeight: 700, sx={{ mt: 0.5, py: 1.3, borderRadius: 2, bgcolor: '#C01227', fontWeight: 700,
boxShadow: '0 4px 14px rgba(192,18,39,0.30)', '&:hover': { bgcolor: '#9E0E20' } }}> boxShadow: '0 4px 14px rgba(192,18,39,0.30)', '&:hover': { bgcolor: '#9E0E20' } }}>
Mark Received at Hub {submitting ? 'Scanning in…' : 'Mark Received at Hub'}
</Button> </Button>
</Stack> </Stack>
</Box> </Box>
@@ -279,8 +372,22 @@ export default function Inbound() {
</Box> </Box>
<Divider /> <Divider />
{/* ── MOBILE: card list ── */} {loadError && (
{isMdDown ? ( <Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
{loadError}
</Alert>
)}
{loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : inboundLogs.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<MoveToInboxOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No parcels received yet today.</Typography>
</Box>
) : isMdDown ? (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{inboundLogs.map((log, i) => { {inboundLogs.map((log, i) => {
const ss = shelfStyle(log.shelf); const ss = shelfStyle(log.shelf);

View File

@@ -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 (
<Box>
<PageHeader
icon={StorageIcon}
title="Storage Shelves"
subtitle="See which shelf every parcel is sitting on, move parcels between shelves, and keep an eye on the cold room."
/>
{/* Environmental Sensors for Cold-Chain Zone C */}
<Grid container spacing={{ xs: 2, md: 3 }} sx={{ mb: 4 }}>
{[
{ 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) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
<Card elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1', height: '100%' }}>
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
<Stack direction="row" alignItems="center" spacing={2.5} sx={{ mb: 2 }}>
<Avatar variant="rounded" sx={{ bgcolor: s.bg, color: s.color, borderRadius: 2.5, width: 48, height: 48, flexShrink: 0 }}>
<s.icon sx={{ fontSize: 24 }} />
</Avatar>
<Typography sx={{ fontSize: '0.8rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', lineHeight: 1.4 }}>
{s.label}
</Typography>
</Stack>
<Typography sx={{ fontSize: '2rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, mb: 1 }}>{s.value}</Typography>
<Chip size="small" label={s.chip} color={s.chipColor} sx={{ fontWeight: 600 }} />
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Main Inventory Board */}
<Card>
<CardHeader
title="What's On Our Shelves"
subheader="Find a parcel and move it to a different shelf if needed"
action={
<TextField
size="small"
placeholder="Search parcel or name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ width: { xs: 160, sm: 220, md: 280 }, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
InputProps={{ startAdornment: <InputAdornment position="start"><SearchOutlinedIcon sx={{ fontSize: 20, color: '#9AA0A6' }} /></InputAdornment> }}
/>
}
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
/>
<Divider />
{isMdDown ? (
/* ── MOBILE / TABLET: cards ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{filteredInventory.map((item) => {
const zc = zoneColor(item.zone);
return (
<Card key={item.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}>
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }} gap={1}>
<Stack direction="row" alignItems="center" gap={1.5} sx={{ minWidth: 0 }}>
<Avatar variant="rounded" sx={{ bgcolor: zc.bg, color: zc.color, borderRadius: 2, width: 40, height: 40 }}>
<WarehouseOutlinedIcon sx={{ fontSize: 21 }} />
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{item.id}</Typography>
<Typography variant="caption" color="text.secondary" noWrap>{item.customer}</Typography>
</Box>
</Stack>
<Chip size="small" label={item.status} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: onShelf(item.status) ? '#E6F4EA' : '#FEF7E0', color: onShelf(item.status) ? '#1E8E3E' : '#B06000' }} />
</Stack>
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}>
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.shelf}
sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, '& .MuiChip-icon': { color: zc.color } }} />
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.weight}
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
</Stack>
<Button fullWidth size="small" variant="outlined" startIcon={<OpenWithRoundedIcon sx={{ fontSize: 16 }} />}
onClick={() => handleOpenRelocate(item)} sx={{ borderRadius: 2, fontWeight: 700 }}>
Move to another shelf
</Button>
</CardContent>
</Card>
);
})}
{filteredInventory.length === 0 && (
<Box sx={{ py: 5, textAlign: 'center', color: 'text.secondary' }}>No parcels found. Try a different search.</Box>
)}
</Box>
) : (
/* ── DESKTOP: spacious table ── */
<TableContainer sx={{ overflowX: 'auto' }}>
<Table sx={{ minWidth: 760 }}>
<TableHead>
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
{['Parcel', 'On Shelf', 'Weight', 'Status', 'Action'].map((h, i) => (
<TableCell key={h} align={i === 4 ? 'right' : 'left'}
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
{h}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{filteredInventory.map((item) => {
const zc = zoneColor(item.zone);
return (
<TableRow key={item.id} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8', py: 2.25 }, '&:last-child td': { border: 0 } }}>
<TableCell>
<Stack direction="row" alignItems="center" gap={3.5}>
<Avatar variant="rounded" sx={{ bgcolor: zc.bg, color: zc.color, borderRadius: 2, width: 40, height: 40 }}>
<WarehouseOutlinedIcon sx={{ fontSize: 21 }} />
</Avatar>
<Box>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{item.id}</Typography>
<Typography variant="caption" color="text.secondary">{item.customer}</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.shelf}
sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, whiteSpace: 'nowrap', '& .MuiChip-icon': { color: zc.color } }} />
</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#495057', whiteSpace: 'nowrap' }}>{item.weight}</TableCell>
<TableCell>
<Chip size="small" label={item.status}
sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: onShelf(item.status) ? '#E6F4EA' : '#FEF7E0', color: onShelf(item.status) ? '#1E8E3E' : '#B06000' }} />
</TableCell>
<TableCell align="right">
<Button size="small" variant="outlined" startIcon={<OpenWithRoundedIcon sx={{ fontSize: 16 }} />}
onClick={() => handleOpenRelocate(item)} sx={{ borderRadius: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
Move
</Button>
</TableCell>
</TableRow>
);
})}
{filteredInventory.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 5, color: 'text.secondary' }}>
No parcels found. Try a different search.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
{/* Relocate Dialog */}
<Dialog open={Boolean(editItem)} onClose={() => setEditItem(null)} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 700 }}>Move Parcel {editItem?.id}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography variant="body2" color="text.secondary">
Choose a new shelf for <strong>{editItem?.id}</strong> (from <strong>{editItem?.customer}</strong>).
</Typography>
<FormControl fullWidth>
<Select value={newShelf} onChange={(e) => setNewShelf(e.target.value)}>
{SHELVES.map((shelf) => (
<MenuItem key={shelf} value={shelf}>{shelf}</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditItem(null)}>Cancel</Button>
<Button variant="contained" onClick={handleConfirmRelocate}>
Move Parcel
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Box, Box,
Typography, Typography,
@@ -27,7 +27,10 @@ import {
Radio, Radio,
Badge, Badge,
Checkbox, Checkbox,
useMediaQuery useMediaQuery,
CircularProgress,
Alert,
Snackbar
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; 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 FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded'; import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { getUnassignedBookings, getMilers, assignMiler, autoAssignBooking } from '@/api/hub';
import { getHubContext } from '@/auth/session';
const UNASSIGNED_ORDERS = [ const PENDING = 'Pending Assignment';
{ 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 AVAILABLE_MILERS = [ const timeAgo = (iso) => {
{ id: 'M-101', name: 'Deepak Sharma', vehicle: 'Two-Wheeler', area: 'Dwarka', distance: '1.2 km away' }, if (!iso) return 'Recently';
{ id: 'M-102', name: 'Karthik S.', vehicle: 'EV-Rickshaw', area: 'Saket', distance: '2.5 km away' }, const then = new Date(iso).getTime();
{ id: 'M-103', name: 'Sanjay R.', vehicle: 'Mini Truck', area: 'Rohini', distance: '0.8 km away' } 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() { export default function OrderAssignment() {
const theme = useTheme(); const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md')); 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([]); const [selectedOrders, setSelectedOrders] = useState([]);
// Single Assign Dialog State // Single Assign Dialog State
const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null); const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null);
const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [selectedMiler, setSelectedMiler] = useState(''); 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) => { const handleSelectAll = (event) => {
if (event.target.checked) { 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); setSelectedOrders(pendingIds);
} else { } else {
setSelectedOrders([]); setSelectedOrders([]);
@@ -88,39 +152,65 @@ export default function OrderAssignment() {
setAssignDialogOpen(true); setAssignDialogOpen(true);
}; };
const handleAssign = () => { // Assign a specific miler to a single booking.
if (!selectedMiler) return; const handleAssign = async () => {
const miler = AVAILABLE_MILERS.find(m => m.id === selectedMiler); if (!selectedMiler || busy) return;
const miler = availableMilers.find((m) => m.id === selectedMiler);
setOrders(orders.map(o => { const order = selectedOrderForAssign;
if (o.id === selectedOrderForAssign.id) { setBusy(true);
return { ...o, status: `Assigned to ${miler.name}`, assignedMiler: miler }; try {
} const res = await assignMiler(order.id, selectedMiler);
return o; 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));
// Remove from selection if it was selected setAssignDialogOpen(false);
setSelectedOrders(prev => prev.filter(id => id !== selectedOrderForAssign.id)); notify(`Pickup #${order.id} assigned to ${name}.`);
setAssignDialogOpen(false); } catch (err) {
notify(err?.message || 'Could not assign this miler.', 'error');
} finally {
setBusy(false);
}
}; };
const handleAutoAssignAll = () => { // Let the AI engine assign every selected booking (POST /hub/bookings/:id/auto-assign).
if (selectedOrders.length === 0) return; const handleAutoAssignAll = async () => {
if (selectedOrders.length === 0 || busy) return;
setOrders(orders.map(o => { const ids = orders.filter((o) => selectedOrders.includes(o.id) && o.status === PENDING).map((o) => o.id);
if (selectedOrders.includes(o.id) && o.status === 'Pending Assignment') { setBusy(true);
// Just pick a random miler for auto-assign simulation let ok = 0;
const randomMiler = AVAILABLE_MILERS[Math.floor(Math.random() * AVAILABLE_MILERS.length)]; let pending = 0;
return { ...o, status: `Assigned to ${randomMiler.name}`, assignedMiler: randomMiler }; 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([]); 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; const isAllSelected = selectedOrders.length > 0 && selectedOrders.length === pendingCount;
return ( return (
@@ -134,15 +224,15 @@ export default function OrderAssignment() {
<Card> <Card>
<CardHeader <CardHeader
title="Waiting for a Miler" title="Waiting for a Miler"
subheader="New pickup requests around Delhi" subheader={`New pickup requests around ${hub.city || 'your city'}`}
avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>} avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>}
action={ action={
<Button <Button
variant={selectedOrders.length === 0 ? 'outlined' : 'contained'} variant={selectedOrders.length === 0 ? 'outlined' : 'contained'}
color="primary" color="primary"
startIcon={<AutoAwesomeIcon />} startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={handleAutoAssignAll} onClick={handleAutoAssignAll}
disabled={selectedOrders.length === 0} disabled={selectedOrders.length === 0 || busy}
sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }}
> >
{selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`} {selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`}
@@ -152,7 +242,22 @@ export default function OrderAssignment() {
/> />
<Divider /> <Divider />
{isMdDown ? ( {loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
{loadError}
</Alert>
)}
{loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : orders.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<AssignmentIndIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No pickup requests waiting right now.</Typography>
</Box>
) : isMdDown ? (
/* ── MOBILE / TABLET: cards ── */ /* ── MOBILE / TABLET: cards ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{orders.map((row) => { {orders.map((row) => {
@@ -276,42 +381,69 @@ export default function OrderAssignment() {
</Card> </Card>
<Dialog open={assignDialogOpen} onClose={() => setAssignDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog open={assignDialogOpen} onClose={() => setAssignDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Choose a Miler for {selectedOrderForAssign?.id}</DialogTitle> <DialogTitle>Choose a Miler for pickup #{selectedOrderForAssign?.id}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography variant="subtitle2" sx={{ mb: 2 }}>Available milers near {selectedOrderForAssign?.pickup}:</Typography> <Typography variant="subtitle2" sx={{ mb: 2 }}>Milers at {hub.hubname || 'this hub'}:</Typography>
<List> {availableMilers.length === 0 ? (
{AVAILABLE_MILERS.map((miler) => ( <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
<ListItemButton No available milers right now. Try Auto-Assign, or check the Milers page.
key={miler.id} </Typography>
onClick={() => setSelectedMiler(miler.id)} ) : (
sx={{ <List>
border: '1px solid', {availableMilers.map((miler) => (
borderColor: selectedMiler === miler.id ? 'primary.main' : 'divider', <ListItemButton
borderRadius: 2, key={miler.id}
mb: 1, onClick={() => setSelectedMiler(miler.id)}
bgcolor: selectedMiler === miler.id ? 'primary.lighter' : 'transparent' sx={{
}} border: '1px solid',
> borderColor: selectedMiler === miler.id ? 'primary.main' : 'divider',
<ListItemAvatar> borderRadius: 2,
<Badge color="success" variant="dot" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}> mb: 1,
<Avatar sx={{ bgcolor: 'grey.200', color: 'grey.700' }}><TwoWheelerIcon /></Avatar> bgcolor: selectedMiler === miler.id ? 'primary.lighter' : 'transparent'
</Badge> }}
</ListItemAvatar> >
<ListItemText <ListItemAvatar>
primary={miler.name} <Badge color="success" variant="dot" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
secondary={`${miler.vehicle}${miler.distance}`} <Avatar sx={{ bgcolor: 'grey.200', color: 'grey.700' }}><TwoWheelerIcon /></Avatar>
primaryTypographyProps={{ fontWeight: 600 }} </Badge>
/> </ListItemAvatar>
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} /> <ListItemText
</ListItemButton> primary={miler.name}
))} secondary={
</List> <Stack direction="row" alignItems="center" gap={0.5} component="span">
<span>{miler.status}</span>
{miler.rating != null && (
<>
<span></span>
<StarRoundedIcon sx={{ fontSize: 14, color: '#F5A623' }} />
<span>{miler.rating}</span>
</>
)}
</Stack>
}
primaryTypographyProps={{ fontWeight: 600 }}
/>
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} />
</ListItemButton>
))}
</List>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setAssignDialogOpen(false)} color="inherit">Cancel</Button> <Button onClick={() => setAssignDialogOpen(false)} color="inherit">Cancel</Button>
<Button onClick={handleAssign} variant="contained" disabled={!selectedMiler}>Confirm Assignment</Button> <Button onClick={handleAssign} variant="contained" disabled={!selectedMiler || busy}
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}>
Confirm Assignment
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}>
{toast.msg}
</Alert>
</Snackbar>
</Box> </Box>
); );
} }

View File

@@ -9,6 +9,9 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip,
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { getRiderRoutes } from '@/api/hub';
import { getHubContext } from '@/auth/session';
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined'; import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import PauseRoundedIcon from '@mui/icons-material/PauseRounded'; 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. // Delivery: hub → customer drops. Pickup: merchant collections → hub.
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
const HUB = { lat: 28.6139, lng: 77.2090, label: 'Delhi Operations Hub' }; // The hub all pickup legs return to. Label comes from the logged-in hub context;
const hubStop = (time) => ({ kind: 'hub', label: HUB.label, lat: HUB.lat, lng: HUB.lng, time }); // 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 = [ // Palette assigned to milers round-robin so each route line is a distinct colour.
{ const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
id: 'RDR-8012', name: 'Muthu Kumar', color: '#1A73E8', vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234', phone: '+91 98765 43210',
delivery: { // Map an API rider-route (from GET /hub/rider-routes) into the structure this
startTime: '08:12', endTime: '13:40', distanceKm: 18.4, // page renders: a `pickup` trip with a list of order stops. Fields the API does
stops: [ // not provide (customer, weight, COD, slot, instructions) default gracefully.
hubStop('08:12'), const mapRoute = (r, i) => {
{ 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:0010:00', cod: 0, payment: 'Prepaid', legKm: 5.2, instructions: 'Leave at reception if not home.' }, const apiStops = Array.isArray(r.stops) ? r.stops : [];
{ 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:0011:00', cod: 1200, payment: 'COP', legKm: 4.1, instructions: 'Call on arrival.' }, const stops = apiStops.map((s) => ({
{ 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:0012:00', cod: 0, payment: 'Prepaid', legKm: 4.6, instructions: 'Customer unreachable — reattempt tomorrow.' }, kind: 'order',
{ 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:0013:00', cod: 850, payment: 'COP', legKm: 4.5, instructions: '' }, 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: { pickup: {
startTime: '14:10', endTime: '16:50', distanceKm: 11.2, startTime: '',
stops: [ endTime: '',
{ 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:0016:00', cod: 0, payment: 'Merchant', legKm: 6.0, instructions: 'Collect from back gate.' }, distanceKm: r.totaldistance_km || 0,
{ 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:0017:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' }, stops
{ 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:0018: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:0010: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:0011: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:0012: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:0013: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:3016: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:3017: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:0010: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:0011: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:0012: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:0014: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:0018: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:0019: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:0012: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:0013: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:0014: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:0016:00', cod: 0, payment: 'Merchant', legKm: 4.0, instructions: '' },
hubStop('15:30'),
],
},
},
];
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase(); 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 = { const STATUS_META = {
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' }, Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' },
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' }, 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 = { const MODES = {
@@ -395,9 +359,11 @@ function OrderDetailPanel({ data, onBack }) {
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
export default function RiderRoutes() { export default function RiderRoutes() {
const [mode] = useState('pickup'); // pickups only 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 [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 [focusedStop, setFocusedStop] = useState(null); // `${riderId}-${index}`
const [detail, setDetail] = useState(null); // { order, rider, mode, index } const [detail, setDetail] = useState(null); // { order, rider, mode, index }
const [flyTarget, setFlyTarget] = useState(null); const [flyTarget, setFlyTarget] = useState(null);
@@ -413,13 +379,29 @@ export default function RiderRoutes() {
const modeCfg = MODES[mode]; 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 // 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. // against re-fetching keys we've already resolved when the tab is revisited.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
await Promise.all( await Promise.all(
RIDERS.map(async (r) => { riders.map(async (r) => {
const key = `${r.id}__${mode}`; const key = `${r.id}__${mode}`;
if (resolvedRef.current[key]) return; if (resolvedRef.current[key]) return;
resolvedRef.current[key] = true; resolvedRef.current[key] = true;
@@ -432,7 +414,7 @@ export default function RiderRoutes() {
); );
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [mode]); }, [mode, riders]);
const pathFor = useCallback( const pathFor = useCallback(
(r) => routes[`${r.id}__${mode}`] || r[mode].stops.map((s) => ({ lat: s.lat, lng: s.lng })), (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. // Auto-fit points = visible riders' stops for the active mode.
const fitPoints = useMemo(() => { const fitPoints = useMemo(() => {
const pts = []; 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; return pts;
}, [visible, stopsOf]); }, [riders, visible, stopsOf]);
// ── Analysis KPIs for the active mode ───────────────────────────────────────── // ── Analysis KPIs for the active mode ─────────────────────────────────────────
const kpi = useMemo(() => { const kpi = useMemo(() => {
let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0; let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0;
RIDERS.forEach((r) => { riders.forEach((r) => {
const trip = r[mode]; const trip = r[mode];
const orderStops = trip.stops.filter((s) => s.kind === 'order'); const orderStops = trip.stops.filter((s) => s.kind === 'order');
if (orderStops.length) activeRiders += 1; if (orderStops.length) activeRiders += 1;
@@ -462,7 +444,7 @@ export default function RiderRoutes() {
cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0); cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0);
}); });
return { orders, done, fail, km: km.toFixed(1), cod, activeRiders }; return { orders, done, fail, km: km.toFixed(1), cod, activeRiders };
}, [mode, modeCfg]); }, [riders, mode, modeCfg]);
// ── Animation driver ────────────────────────────────────────────────────────── // ── Animation driver ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -506,7 +488,8 @@ export default function RiderRoutes() {
const playState = useMemo(() => { const playState = useMemo(() => {
if (!playing) return null; 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); const path = pathFor(rider);
if (path.length < 2) return null; if (path.length < 2) return null;
const totalSegs = path.length - 1; const totalSegs = path.length - 1;
@@ -516,7 +499,7 @@ export default function RiderRoutes() {
const pos = lerpPoint(path[i], path[i + 1], frac); const pos = lerpPoint(path[i], path[i + 1], frac);
const travelled = [...path.slice(0, i + 1), pos]; const travelled = [...path.slice(0, i + 1), pos];
return { rider, path, travelled, pos }; return { rider, path, travelled, pos };
}, [playing, progress, pathFor]); }, [playing, progress, pathFor, riders]);
return ( return (
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}> <Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}>
@@ -536,10 +519,20 @@ export default function RiderRoutes() {
</Stack> </Stack>
{!loading && riders.length === 0 && (
<Card elevation={0} sx={{ borderRadius: 2, border: '1px dashed #CED4DA', py: 8, textAlign: 'center', mt: 3 }}>
<RouteOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="h6" sx={{ fontWeight: 700, color: '#495057' }}>No rider routes today</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Once milers are assigned pickups, their planned stops will show up here.
</Typography>
</Card>
)}
{/* ── Analysis KPI strip ── */} {/* ── Analysis KPI strip ── */}
<Grid container spacing={2.5} mt={4} sx={{ mb: 2 }}> <Grid container spacing={2.5} mt={4} sx={{ mb: 2 }}>
{[ {[
{ 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: 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: 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' }, { icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
@@ -561,7 +554,7 @@ export default function RiderRoutes() {
</Box> </Box>
<List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}> <List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}>
{RIDERS.map((rider) => { {riders.map((rider) => {
const trip = rider[mode]; const trip = rider[mode];
const orders = trip.stops.filter((s) => s.kind === 'order'); const orders = trip.stops.filter((s) => s.kind === 'order');
const done = orders.filter((o) => o.status === modeCfg.doneStatus).length; const done = orders.filter((o) => o.status === modeCfg.doneStatus).length;
@@ -749,7 +742,7 @@ export default function RiderRoutes() {
<Popup><b>{HUB.label}</b><br />Pickups return here</Popup> <Popup><b>{HUB.label}</b><br />Pickups return here</Popup>
</Marker> </Marker>
{RIDERS.map((rider) => { {riders.map((rider) => {
if (!visible[rider.id]) return null; if (!visible[rider.id]) return null;
const path = pathFor(rider); const path = pathFor(rider);
const stops = stopsOf(rider); const stops = stopsOf(rider);
@@ -759,7 +752,7 @@ export default function RiderRoutes() {
<React.Fragment key={rider.id}> <React.Fragment key={rider.id}>
<Polyline positions={path.map((p) => [p.lat, p.lng])} pathOptions={{ color: rider.color, weight: 5, opacity: dimmed ? 0.15 : 0.85, dashArray: modeCfg.dash }} /> <Polyline positions={path.map((p) => [p.lat, p.lng])} pathOptions={{ color: rider.color, weight: 5, opacity: dimmed ? 0.15 : 0.85, dashArray: modeCfg.dash }} />
{stops.map((s, i) => { {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}`; const key = `${rider.id}-${i}`;
return ( return (
<Marker <Marker
@@ -779,7 +772,7 @@ export default function RiderRoutes() {
</Marker> </Marker>
); );
})} })}
{!dimmed && ( {!dimmed && endStop && (
<Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}> <Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}>
<LTooltip direction="top" offset={[0, -14]}>{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}</LTooltip> <LTooltip direction="top" offset={[0, -14]}>{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}</LTooltip>
</Marker> </Marker>
@@ -801,7 +794,7 @@ export default function RiderRoutes() {
{/* Legend */} {/* Legend */}
<Stack direction="row" flexWrap="wrap" gap={2} sx={{ mt: 2 }}> <Stack direction="row" flexWrap="wrap" gap={2} sx={{ mt: 2 }}>
{RIDERS.map((r) => ( {riders.map((r) => (
<Stack key={r.id} direction="row" alignItems="center" gap={0.75} sx={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}> <Stack key={r.id} direction="row" alignItems="center" gap={0.75} sx={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}>
<Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} /> <Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography> <Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography>

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { import {
Box, Typography, Card, CardContent, Avatar, Chip, Table, TableBody, Box, Typography, Card, CardContent, Avatar, Chip, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, IconButton, Button, TableCell, TableContainer, TableHead, TableRow, IconButton, Button,
@@ -6,7 +6,7 @@ import {
TextField, MenuItem, Divider, InputAdornment, Menu, ListItemIcon, TextField, MenuItem, Divider, InputAdornment, Menu, ListItemIcon,
Snackbar, Alert, useMediaQuery, Grid, Select, FormControl, InputLabel, Snackbar, Alert, useMediaQuery, Grid, Select, FormControl, InputLabel,
Autocomplete, Switch, FormControlLabel, Paper, ToggleButtonGroup, Autocomplete, Switch, FormControlLabel, Paper, ToggleButtonGroup,
ToggleButton, Stepper, Step, StepLabel ToggleButton, Stepper, Step, StepLabel, CircularProgress
} from '@mui/material'; } from '@mui/material';
import { useTheme, alpha } from '@mui/material/styles'; 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 HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; 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 ────────────────────────────────────────────────────────────── // ── Reference data ──────────────────────────────────────────────────────────────
const VEHICLES = { const VEHICLES = {
'Electric Bike': { capacity: 30, icon: ElectricBikeOutlinedIcon }, 'Electric Bike': { capacity: 30, icon: ElectricBikeOutlinedIcon },
@@ -74,74 +88,49 @@ const STATUS_META = {
let idSeq = 8060; let idSeq = 8060;
const genId = () => `RDR-${idSeq++}`; const genId = () => `RDR-${idSeq++}`;
// ── Seed roster ──────────────────────────────────────────────────────────────── // Turn a vehicle type name into the 1-based id the backend uses (best effort).
const SEED = [ const vehicleIdFor = (vehicle) => Math.max(1, VEHICLE_TYPES.indexOf(vehicle) + 1);
{
id: 'RDR-8012', name: 'Muthu Kumar', phone: '+91 98765 43210', hub: 'Delhi Operations Hub', // Map a raw API miler onto the rich shape this page renders. Fields the API does
zones: ['Dwarka', 'Janakpuri'], vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234', // not provide (zones, COD, live load) default to empty/zero so the UI still works.
status: 'On Pickup', checkInTime: '08:12', hoursToday: 6.4, // Format an ISO check-in timestamp as HH:MM (or "—").
assigned: 24, capacity: 30, pickupsPending: 3, deliveriesPending: 21, const checkInLabel = (iso) => {
deliveriesDone: 45, deliveriesFailed: 2, codCollected: 4500, codPending: 1200, if (!iso) return '—';
rating: 4.8, verified: true, const d = new Date(iso);
}, if (Number.isNaN(d.getTime())) return '—';
{ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
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, const mapMiler = (m, hubName) => {
assigned: 88, capacity: 120, pickupsPending: 12, deliveriesPending: 76, // Real backend exposes defaultvehicletype (e.g. "Bike"); fall back to id lookup.
deliveriesDone: 110, deliveriesFailed: 4, codCollected: 12000, codPending: 3400, const vehicle = m.defaultvehicletype || VEHICLE_TYPES[(m.vehicleid || 1) - 1] || 'Motorcycle';
rating: 4.9, verified: true, const capacity = m.capacity || VEHICLES[vehicle]?.capacity || 0;
}, return {
{ id: m.userid,
id: 'RDR-8022', name: 'Vikram Singh', phone: '+91 98765 43212', hub: 'Delhi Operations Hub', userid: m.userid,
zones: ['Rohini'], vehicle: 'Motorcycle', vehicleNo: 'DL-08-MC-4567', vehicleid: m.vehicleid,
status: 'Idle', checkInTime: '09:05', hoursToday: 5.2, hubid: m.hubid,
assigned: 0, capacity: 25, pickupsPending: 0, deliveriesPending: 0, name: m.displayname || `Miler ${m.userid}`,
deliveriesDone: 68, deliveriesFailed: 1, codCollected: 0, codPending: 0, phone: m.phone || '—',
rating: 4.5, verified: false, hub: hubName,
}, zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [],
// ... (rest of seed data remains the same) vehicle,
{ vehicleNo: m.vehicleid ? `VEH-${m.vehicleid}` : '—',
id: 'RDR-8030', name: 'Amit Patel', phone: '+91 98765 43213', hub: 'Delhi Operations Hub', status: API_TO_UI_STATUS[m.availabilitystatus] || 'Idle',
zones: ['Vasant Kunj'], vehicle: 'Electric Bike', vehicleNo: 'DL-03-EB-7654', checkInTime: checkInLabel(m.checkinat),
status: 'Returning to Hub', checkInTime: '08:30', hoursToday: 7.1, hoursToday: m.hoursactive ?? 0,
assigned: 2, capacity: 30, pickupsPending: 0, deliveriesPending: 2, assigned: m.assignedload ?? 0,
deliveriesDone: 55, deliveriesFailed: 6, codCollected: 8500, codPending: 0, capacity,
rating: 4.2, verified: true, pickupsPending: m.pickupspending ?? 0,
}, deliveriesPending: m.assignedload ?? 0,
{ deliveriesDone: m.totalcompletedpickups ?? m.completedorders ?? 0,
id: 'RDR-8041', name: 'Sana Sheikh', phone: '+91 98765 43214', hub: 'Delhi Operations Hub', deliveriesFailed: m.totalcancelledpickups ?? m.cancelledorders ?? 0,
zones: ['Central Delhi', 'Karol Bagh'], vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199', codCollected: m.codcollected ?? 0,
status: 'On Pickup', checkInTime: '08:00', hoursToday: 6.6, codPending: m.codpending ?? 0,
assigned: 19, capacity: 25, pickupsPending: 2, deliveriesPending: 17, rating: m.rating ?? 0,
deliveriesDone: 73, deliveriesFailed: 0, codCollected: 6200, codPending: 900, verified: Boolean(m.isverified ?? m.device_token)
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,
},
];
// ── Helpers ───────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────
const successRate = (r) => { const successRate = (r) => {
@@ -938,12 +927,35 @@ export default function Riders() {
const isMdDown = useMediaQuery(theme.breakpoints.down('md')); const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const isLgDown = useMediaQuery(theme.breakpoints.down('lg')); 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 [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('All'); const [statusFilter, setStatusFilter] = useState('All');
const [vehicleFilter, setVehicleFilter] = useState('All'); const [vehicleFilter, setVehicleFilter] = useState('All');
const [view, setView] = useState('table'); 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 [formDialog, setFormDialog] = useState({ open: false, mode: 'add', initial: null });
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [profileEdit, setProfileEdit] = useState(false); const [profileEdit, setProfileEdit] = useState(false);
@@ -979,22 +991,43 @@ export default function Riders() {
}); });
}, [riders, search, statusFilter, vehicleFilter]); }, [riders, search, statusFilter, vehicleFilter]);
const handleSave = (rider) => { // Build the API payload from the rich form object.
if (formDialog.mode === 'add') { const toApiPayload = (rider) => ({
setRiders(p => [rider, ...p]); displayname: rider.name,
toast(`${rider.name} onboarded successfully`); phone: rider.phone,
} else { hubid: rider.hubid || hub.hubid,
setRiders(p => p.map(r => r.id === rider.id ? rider : r)); vehicleid: rider.vehicleid || vehicleIdFor(rider.vehicle),
toast(`${rider.name} updated`); 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 = () => { const handleDelete = async () => {
setRiders(p => p.filter(r => r.id !== deleteTarget.id)); const target = deleteTarget;
toast(`${deleteTarget.name} removed`, 'info'); try {
setDeleteTarget(null); await deleteMiler(target.userid ?? target.id);
setProfile(null); 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 }); const openAdd = () => setFormDialog({ open: true, mode: 'add', initial: null });
@@ -1009,11 +1042,16 @@ export default function Riders() {
setProfile(r); setProfile(r);
setProfileEdit(false); setProfileEdit(false);
}; };
const saveProfileEdit = (updated) => { const saveProfileEdit = async (updated) => {
setRiders(p => p.map(r => (r.id === updated.id ? updated : r))); try {
setProfile(updated); await updateMiler(updated.userid ?? updated.id, toApiPayload(updated));
setProfileEdit(false); setRiders((p) => p.map((r) => (r.id === updated.id ? updated : r)));
toast(`${updated.name} updated`); setProfile(updated);
setProfileEdit(false);
toast(`${updated.name} updated`);
} catch (err) {
toast(err?.message || 'Could not update this miler.', 'error');
}
}; };
const openMenu = (e, r) => { const openMenu = (e, r) => {
setMenuAnchor(e.currentTarget); setMenuAnchor(e.currentTarget);
@@ -1253,7 +1291,16 @@ export default function Riders() {
</Box> </Box>
{/* Content Area */} {/* Content Area */}
{filtered.length === 0 ? ( {loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
{loadError}
</Alert>
)}
{loading ? (
<Box sx={{ py: 12, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : filtered.length === 0 ? (
<Box sx={{ py: 12, textAlign: 'center' }}> <Box sx={{ py: 12, textAlign: 'center' }}>
<SearchOutlinedIcon sx={{ fontSize: 80, color: '#E0E0E0', mb: 3 }} /> <SearchOutlinedIcon sx={{ fontSize: 80, color: '#E0E0E0', mb: 3 }} />
<Typography variant="h5" fontWeight={600}>No milers match your filters</Typography> <Typography variant="h5" fontWeight={600}>No milers match your filters</Typography>

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Box, Typography, Card, CardContent, CardHeader, Grid, TextField, Button, 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'; } from '@mui/material';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; 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 WarningAmberIcon from '@mui/icons-material/WarningAmber';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import HubIcon from '@mui/icons-material/Hub'; import HubIcon from '@mui/icons-material/Hub';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import LocalOfferIcon from '@mui/icons-material/LocalOffer'; import LocalOfferIcon from '@mui/icons-material/LocalOffer';
// Mock DB with Advanced Logistics Scenarios import { getRouting, getInboundToday } from '@/api/hub';
const HUB_NAME = 'Delhi Hub (DEL-01)'; import { getHubContext } from '@/auth/session';
const LOCAL_ZONES = ['Dwarka', 'Janakpuri', 'Saket', 'Malviya Nagar', 'Rohini', 'Vasant Kunj', 'Central Delhi'];
const PACKAGES_DB = { // A condition string that signals the parcel needs manual checking.
'DM-1001': { const isException = (condition) =>
id: 'DM-1001', /damag|wet|crush|missing|broken/i.test(condition || '');
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.'
}
};
export default function Routing() { export default function Routing() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hub = getHubContext();
const HUB_NAME = hub.hubname || 'This Hub';
const [searchId, setSearchId] = useState(''); const [searchId, setSearchId] = useState('');
const [matchedPkg, setMatchedPkg] = useState(null); const [matchedPkg, setMatchedPkg] = useState(null);
const [searched, setSearched] = useState(false); 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(() => { useEffect(() => {
const query = searchParams.get('q'); const query = searchParams.get('q');
@@ -87,58 +66,23 @@ export default function Routing() {
setSearchId(query); setSearchId(query);
handleSearch(query); handleSearch(query);
} }
}, [searchParams]); }, [searchParams, handleSearch]);
const handleSearch = (idToSearch) => { // Classify the next action from the API's nexthop + condition.
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
const determineNextAction = (pkg) => { const determineNextAction = (pkg) => {
if (pkg.status.includes('Exception') || pkg.status === 'Damaged Packaging') { if (isException(pkg.condition)) {
return { return { queue: 'Needs Checking', action: 'Set aside for a supervisor', color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon /> };
queue: 'Needs Checking',
action: 'Set aside for a supervisor',
color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon />
};
} }
if (pkg.status === 'RTS (Return to Sender)') { if (pkg.iscoldchain) {
return { return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', color: '#00838F', bg: '#E0F7FA', icon: <AcUnitIcon /> };
queue: 'Send Back',
action: 'Return to the sender',
color: '#F29900', bg: '#FEF7E0', icon: <LocalShippingIcon />
};
} }
if (pkg.destHub !== HUB_NAME) { if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) {
return { return { queue: 'Transfer to Another City', action: pkg.nexthop, color: '#8E24AA', bg: '#F3E5F5', icon: <LocalShippingIcon /> };
queue: 'Transfer to Another City',
action: `Send to ${pkg.destHub}`,
color: '#8E24AA', bg: '#F3E5F5', icon: <LocalShippingIcon />
};
} }
// If destHub IS this hub, it's local delivery if ((pkg.nexthop || '').toLowerCase().includes('local')) {
if (LOCAL_ZONES.includes(pkg.destZone)) { return { queue: 'Local Delivery', action: `Send to ${pkg.destination || 'the delivery lane'}`, color: '#1E8E3E', bg: '#E6F4EA', icon: <HubIcon /> };
// 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: <LocalShippingIcon />
};
}
return {
queue: 'Local Delivery',
action: `Give to a ${pkg.destZone} miler`,
color: '#1E8E3E', bg: '#E6F4EA', icon: <HubIcon />
};
} }
return { queue: 'Unknown', action: 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> }; return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
}; };
return ( return (
@@ -146,7 +90,7 @@ export default function Routing() {
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography> <Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
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.
</Typography> </Typography>
</Box> </Box>
@@ -173,14 +117,16 @@ export default function Routing() {
sx: { borderRadius: 2, bgcolor: '#fff' } sx: { borderRadius: 2, bgcolor: '#fff' }
}} }}
/> />
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
onClick={() => handleSearch()} onClick={() => handleSearch()}
fullWidth fullWidth
disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
sx={{ bgcolor: '#C01227', py: 1.5, borderRadius: 2, boxShadow: '0px 6px 16px rgba(192, 18, 39, 0.28)', '&:hover': { bgcolor: '#9E0E20' } }} sx={{ bgcolor: '#C01227', py: 1.5, borderRadius: 2, boxShadow: '0px 6px 16px rgba(192, 18, 39, 0.28)', '&:hover': { bgcolor: '#9E0E20' } }}
> >
Tell Me What To Do {loading ? 'Checking' : 'Tell Me What To Do'}
</Button> </Button>
</Stack> </Stack>
</CardContent> </CardContent>
@@ -195,33 +141,35 @@ export default function Routing() {
<Divider /> <Divider />
<CardContent sx={{ pt: 2, p: 1 }}> <CardContent sx={{ pt: 2, p: 1 }}>
<List disablePadding> <List disablePadding>
{Object.keys(PACKAGES_DB).map((key) => { {waiting.length === 0 && (
const pkg = PACKAGES_DB[key]; <Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
return ( No parcels inbounded today.
<ListItemButton </Typography>
key={key} )}
onClick={() => { {waiting.map((pkg) => (
setSearchId(key); <ListItemButton
handleSearch(key); key={pkg.trackingno}
}} onClick={() => {
selected={searchId === key} setSearchId(pkg.trackingno);
sx={{ handleSearch(pkg.trackingno);
borderRadius: 2, mb: 1, p: 2, }}
border: '1px solid', selected={searchId === pkg.trackingno}
borderColor: searchId === key ? '#C01227' : '#eaeaea', sx={{
bgcolor: searchId === key ? '#C0122708' : '#fff', borderRadius: 2, mb: 1, p: 2,
'&:hover': { bgcolor: '#f8f9fa' } border: '1px solid',
}} borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea',
> bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff',
<ListItemText '&:hover': { bgcolor: '#f8f9fa' }
primary={key} }}
secondary={`${pkg.source} → ${pkg.destHub === HUB_NAME ? pkg.destZone : pkg.destHub}`} >
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === key ? '#C01227' : '#212529' }} <ListItemText
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }} primary={pkg.trackingno}
/> secondary={`Going to ${pkg.dest}`}
</ListItemButton> primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === pkg.trackingno ? '#C01227' : '#212529' }}
); secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }}
})} />
</ListItemButton>
))}
</List> </List>
</CardContent> </CardContent>
</Card> </Card>
@@ -245,12 +193,12 @@ export default function Routing() {
) : matchedPkg ? ( ) : matchedPkg ? (
<Card sx={{ height: '100%', borderRadius: 2, border: '1px solid #eaeaea', boxShadow: '0px 4px 20px rgba(0,0,0,0.06)' }}> <Card sx={{ height: '100%', borderRadius: 2, border: '1px solid #eaeaea', boxShadow: '0px 4px 20px rgba(0,0,0,0.06)' }}>
<CardHeader <CardHeader
title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.id}</Typography>} title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.trackingno}</Typography>}
subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>Source: {matchedPkg.source}</Typography>} subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>{matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}</Typography>}
action={ action={
<Chip <Chip
label={matchedPkg.status} label={matchedPkg.condition || 'Good'}
sx={{ fontWeight: 700, bgcolor: '#F1F3F5', color: '#495057', borderRadius: 2 }} sx={{ fontWeight: 700, bgcolor: isException(matchedPkg.condition) ? '#FCE8E6' : '#F1F3F5', color: isException(matchedPkg.condition) ? '#D93025' : '#495057', borderRadius: 2 }}
/> />
} }
sx={{ px: { xs: 2.5, sm: 4 }, pt: { xs: 3, sm: 4 }, pb: 2 }} 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" justifyContent="space-between"
spacing={2} spacing={2}
> >
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Came From</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.origin}</Typography>
</Box>
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}> <Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Right Now (Here)</Typography> <Typography variant="subtitle2" sx={{ color: '#868E96' }}>Right Now (Here)</Typography>
<Typography variant="body1" sx={{ fontWeight: 800, color: '#C01227', mt: 0.5 }}>{HUB_NAME}</Typography> <Typography variant="body1" sx={{ fontWeight: 800, color: '#C01227', mt: 0.5 }}>{HUB_NAME}</Typography>
</Box> </Box>
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} /> <ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Put On Shelf</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.recommendedshelf || ''}</Typography>
</Box>
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}> <Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Going To</Typography> <Typography variant="subtitle2" sx={{ color: '#868E96' }}>Going To</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.destHub === HUB_NAME ? matchedPkg.destZone : matchedPkg.destHub}</Typography> <Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.destination || ''}</Typography>
</Box> </Box>
</Stack> </Stack>
</Box> </Box>
{/* Handling Alerts */} {/* Handling Alerts */}
{matchedPkg.exception && ( {isException(matchedPkg.condition) && (
<Alert severity="error" variant="filled" sx={{ borderRadius: 2, mb: 2 }}> <Alert severity="error" variant="filled" sx={{ borderRadius: 2, mb: 2 }}>
<AlertTitle sx={{ fontWeight: 700 }}>Something's Wrong</AlertTitle> <AlertTitle sx={{ fontWeight: 700 }}>Something's Wrong</AlertTitle>
{matchedPkg.exception} Condition reported as <strong>{matchedPkg.condition}</strong>. Set it aside in the Exception Area for a supervisor.
</Alert>
)}
{matchedPkg.iscoldchain && (
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #b2ebf2', bgcolor: '#e0f7fa', mb: 2 }}>
<AlertTitle sx={{ fontWeight: 700, color: '#00838F' }}>Cold chain parcel</AlertTitle>
Move this parcel to the <strong>Cold Room (Zone C)</strong> right away.
</Alert> </Alert>
)} )}
{determineNextAction(matchedPkg).queue === 'Transfer to Another City' && ( {determineNextAction(matchedPkg).queue === 'Transfer to Another City' && (
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #bae1ff', bgcolor: '#e6f2ff' }}> <Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #bae1ff', bgcolor: '#e6f2ff' }}>
<AlertTitle sx={{ fontWeight: 700, color: '#0055b3' }}>Put in the transfer bin</AlertTitle> <AlertTitle sx={{ fontWeight: 700, color: '#0055b3' }}>Put in the transfer bin</AlertTitle>
Place this parcel in the bin for <strong>{matchedPkg.destHub}</strong>. It will go out with the next city transfer. <strong>{matchedPkg.nexthop}</strong>. It will go out with the next city transfer.
</Alert> </Alert>
)} )}
{determineNextAction(matchedPkg).queue === 'Local Delivery' && ( {determineNextAction(matchedPkg).queue === 'Local Delivery' && (
<Alert severity="success" sx={{ borderRadius: 2, border: '1px solid #c3e6cb', bgcolor: '#d4edda' }}> <Alert severity="success" sx={{ borderRadius: 2, border: '1px solid #c3e6cb', bgcolor: '#d4edda' }}>
<AlertTitle sx={{ fontWeight: 700, color: '#155724' }}>Ready for local delivery</AlertTitle> <AlertTitle sx={{ fontWeight: 700, color: '#155724' }}>Ready for local delivery</AlertTitle>
Place this parcel in the <strong>{matchedPkg.destZone}</strong> lane so a miler can take it out. Place this parcel in the <strong>{matchedPkg.destination}</strong> lane so a miler can take it out.
</Alert> </Alert>
)} )}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem, Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem,
ListItemAvatar, ListItemText, Badge, Divider ListItemAvatar, ListItemText, Badge, Divider, Alert
} from '@mui/material'; } from '@mui/material';
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining'; import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import HubIcon from '@mui/icons-material/Hub'; 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 L from 'leaflet';
import 'leaflet/dist/leaflet.css'; 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 // Keeps Leaflet's canvas sized correctly when the container resizes (sidebar
// toggle, window resize, first paint inside a flex box). Without this the map // 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". // 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], iconAnchor: [17, 17],
}); });
// Mock Coordinates (Real Lat/Lng) const prettyHubType = (t) =>
const HUBS = [ ({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub');
{ 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 INIT_RIDERS = [ // Colour-code a miler pin/list item by their live availability status.
{ 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' }, const statusMeta = (status) => {
{ 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' } switch (status) {
]; case 'Assigned':
case 'On Pickup':
const INIT_LINEHAUL = [ return { color: '#1A73E8', label: status };
{ 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' } 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() { export default function TrackingMap() {
const [riders, setRiders] = useState(INIT_RIDERS); const hub = getHubContext();
const [linehauls, setLinehauls] = useState(INIT_LINEHAUL); 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(() => { useEffect(() => {
const interval = setInterval(() => { getHubs()
setRiders((prev) => .then((res) =>
prev.map(r => ({ setHubs(
...r, (res?.data || [])
progress: r.progress >= 1 ? 0 : parseFloat((r.progress + 0.005).toFixed(3)) .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) }))
); )
)
setLinehauls((prev) => .catch(() => setHubs([]));
prev.map(l => ({
...l,
progress: l.progress >= 1 ? 0 : parseFloat((l.progress + 0.001).toFixed(3))
}))
);
}, 150);
return () => clearInterval(interval);
}, []); }, []);
const getInterpolatedPosition = (start, end, progress) => { // Poll live miler GPS every 5 seconds. The guide's /admin/milers/locations
return [ // (Redis GEO) returns nothing on this backend, so we read coordinates from
start[0] + (end[0] - start[0]) * progress, // /hub/milers (currentlatitude/currentlongitude) and fall back to the GEO
start[1] + (end[1] - start[1]) * progress // 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 ( return (
<Box> <Box>
@@ -112,6 +192,12 @@ export default function TrackingMap() {
<Typography variant="body1" color="text.secondary">See where your milers and transfer trucks are right now, on the map.</Typography> <Typography variant="body1" color="text.secondary">See where your milers and transfer trucks are right now, on the map.</Typography>
</Box> </Box>
{error && (
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3.5 }}> <Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3.5 }}>
{/* Map Canvas Frame */} {/* Map Canvas Frame */}
<Box sx={{ flex: 2, minWidth: 0 }}> <Box sx={{ flex: 2, minWidth: 0 }}>
@@ -124,46 +210,38 @@ export default function TrackingMap() {
/> />
{/* Plot Hubs */} {/* Plot Hubs */}
{HUBS.map(hub => ( {hubs.map((h) => (
<Marker key={hub.id} position={hub.position} icon={createHubIcon()}> <Marker key={h.id} position={h.position} icon={createHubIcon()}>
<Popup> <Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{hub.name}</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{h.name}</Typography>
<Typography variant="caption">{hub.type}</Typography> <Typography variant="caption">{h.type}</Typography>
</Popup> </Popup>
</Marker> </Marker>
))} ))}
{/* Plot Milers */} {/* Plot live Milers */}
{riders.map(rider => { {milers.map((m) => (
const pos = getInterpolatedPosition(rider.start, rider.end, rider.progress); <Marker key={m.userid} position={[m.lat, m.lon]} icon={createRiderIcon()}>
return ( <Popup>
<React.Fragment key={rider.id}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{m.displayname || `Miler ${m.userid}`}</Typography>
<Polyline positions={[rider.start, rider.end]} color="#0070f3" dashArray="5, 8" weight={2} opacity={0.6} /> <Typography variant="caption" display="block">{statusMeta(m.status).label}</Typography>
<Marker position={pos} icon={createRiderIcon()}> {m.bookingid && <Typography variant="caption" display="block">On booking #{m.bookingid}</Typography>}
<Popup> </Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{rider.name}</Typography> </Marker>
<Typography variant="caption">{rider.area}</Typography> ))}
</Popup>
</Marker>
</React.Fragment>
);
})}
{/* Plot Linehaul */} {/* Plot transfer trucks in transit (real positions) */}
{linehauls.map(lh => { {linehauls.map((lh) => (
const pos = getInterpolatedPosition(lh.start, lh.end, lh.progress); <React.Fragment key={lh.id}>
return ( <Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} />
<React.Fragment key={lh.id}> <Marker position={lh.current} icon={createLinehaulIcon()}>
<Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} /> <Popup>
<Marker position={pos} icon={createLinehaulIcon()}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography>
<Popup> <Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography> </Popup>
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography> </Marker>
</Popup> </React.Fragment>
</Marker> ))}
</React.Fragment>
);
})}
</MapContainer> </MapContainer>
</Card> </Card>
</Box> </Box>
@@ -180,11 +258,16 @@ export default function TrackingMap() {
/> />
<Divider /> <Divider />
<List disablePadding> <List disablePadding>
{HUBS.map(hub => ( {hubs.length === 0 && (
<ListItem key={hub.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}> <ListItem sx={{ px: 3, py: 1.5 }}>
<ListItemText <ListItemText primary="No hubs to show" primaryTypographyProps={{ color: 'text.secondary', fontSize: '0.85rem' }} />
primary={hub.name} </ListItem>
secondary={hub.type} )}
{hubs.map((h) => (
<ListItem key={h.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
<ListItemText
primary={h.name}
secondary={h.type}
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.9rem' }} primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.9rem' }}
secondaryTypographyProps={{ fontSize: '0.75rem' }} secondaryTypographyProps={{ fontSize: '0.75rem' }}
/> />
@@ -196,31 +279,41 @@ export default function TrackingMap() {
{/* Real-time Last Mile Miler Logs */} {/* Real-time Last Mile Miler Logs */}
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}> <Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
<CardHeader <CardHeader
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Milers Out Now (Delhi)</Typography>} title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Milers Out Now{hub.city ? ` (${hub.city})` : ''}</Typography>}
avatar={<Avatar sx={{ bgcolor: '#0070f310', color: '#0070f3', borderRadius: 2 }}><DeliveryDiningIcon fontSize="small" /></Avatar>} avatar={<Avatar sx={{ bgcolor: '#0070f310', color: '#0070f3', borderRadius: 2 }}><DeliveryDiningIcon fontSize="small" /></Avatar>}
/> />
<Divider /> <Divider />
<List disablePadding> {milers.length === 0 ? (
{riders.map(r => ( <Box sx={{ px: 3, py: 3 }}>
<ListItem key={r.id} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}> <Typography variant="body2" color="text.secondary">No milers reporting a location right now.</Typography>
<ListItemAvatar> </Box>
<Badge color="success" variant="dot" overlap="circular" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}> ) : (
<Avatar sx={{ width: 34, height: 34, bgcolor: '#0070f3', fontWeight: 700, fontSize: 13 }}>{r.name.charAt(0)}</Avatar> <List disablePadding>
</Badge> {milers.map((m) => {
</ListItemAvatar> const meta = statusMeta(m.status);
<ListItemText const name = m.displayname || `Miler ${m.userid}`;
primary={r.name} return (
secondary={r.area} <ListItem key={m.userid} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }} <ListItemAvatar>
secondaryTypographyProps={{ fontSize: '0.75rem', noWrap: true }} <Badge color="success" variant="dot" overlap="circular" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
/> <Avatar sx={{ width: 34, height: 34, bgcolor: meta.color, fontWeight: 700, fontSize: 13 }}>{name.charAt(0)}</Avatar>
<Typography variant="caption" sx={{ color: '#0070f3', fontWeight: 700, ml: 1, bgcolor: '#0070f308', px: 1, py: 0.5, borderRadius: 2 }}> </Badge>
{Math.round(r.progress * 100)}% </ListItemAvatar>
</Typography> <ListItemText
</ListItem> primary={name}
))} secondary={m.bookingid ? `On booking #${m.bookingid}` : 'No active booking'}
</List> primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
secondaryTypographyProps={{ fontSize: '0.75rem', noWrap: true }}
/>
<Typography variant="caption" sx={{ color: meta.color, fontWeight: 700, ml: 1, bgcolor: `${meta.color}12`, px: 1, py: 0.5, borderRadius: 2, whiteSpace: 'nowrap' }}>
{meta.label}
</Typography>
</ListItem>
);
})}
</List>
)}
</Card> </Card>
{/* Linehaul Fleet Shipments Tracker */} {/* Linehaul Fleet Shipments Tracker */}

View File

@@ -17,7 +17,18 @@ export default defineConfig({
port: 3001, port: 3001,
strictPort: true, strictPort: true,
host: 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
}
}
} }
}); });