added the api for the real data
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal 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
|
||||||
889
docs/Doormile-Hub-Console-API-Reference.md
Normal file
889
docs/Doormile-Hub-Console-API-Reference.md
Normal 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*
|
||||||
16
src/App.jsx
16
src/App.jsx
@@ -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
106
src/api/client.js
Normal 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
147
src/api/hub.js
Normal 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`, {});
|
||||||
|
}
|
||||||
14
src/auth/ProtectedRoute.jsx
Normal file
14
src/auth/ProtectedRoute.jsx
Normal 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
57
src/auth/session.js
Normal 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());
|
||||||
|
}
|
||||||
@@ -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 closeNotif = () => setNotifAnchor(null);
|
const loadNotifications = useCallback(async () => {
|
||||||
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
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([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onNotifClick = (n) => {
|
useEffect(() => {
|
||||||
|
loadNotifications();
|
||||||
|
const t = setInterval(loadNotifications, 60000); // refresh every 60s
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [loadNotifications]);
|
||||||
|
|
||||||
|
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
|
||||||
|
const closeNotif = () => setNotifAnchor(null);
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
await Promise.allSettled(unreadIds.map((id) => markNotificationRead(id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNotifClick = async (n) => {
|
||||||
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -260,6 +289,8 @@ export default function Dashboard() {
|
|||||||
{/* Refresh Button */}
|
{/* Refresh Button */}
|
||||||
<IconButton
|
<IconButton
|
||||||
color="primary"
|
color="primary"
|
||||||
|
onClick={loadDashboard}
|
||||||
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
border: '1px solid #E2E8F0',
|
border: '1px solid #E2E8F0',
|
||||||
width: 36,
|
width: 36,
|
||||||
@@ -270,7 +301,7 @@ export default function Dashboard() {
|
|||||||
'&: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>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton variant="text" width="60%" sx={{ fontSize: { xs: '1.5rem', sm: '1.9rem' }, mb: 0.75 }} />
|
||||||
|
) : (
|
||||||
<Typography sx={{ fontWeight: 800, color: '#212529', fontSize: { xs: '1.5rem', sm: '1.9rem' }, lineHeight: 1.15, mb: 0.75 }}>
|
<Typography sx={{ fontWeight: 800, color: '#212529', fontSize: { xs: '1.5rem', sm: '1.9rem' }, lineHeight: 1.15, mb: 0.75 }}>
|
||||||
{s.value}
|
{s.value}
|
||||||
</Typography>
|
</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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
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');
|
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}>
|
||||||
@@ -134,6 +158,7 @@ export default function Login() {
|
|||||||
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,11 +195,19 @@ export default function Login() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="large"
|
size="large"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSignIn}
|
onClick={handleSignIn}
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: '#C01227',
|
bgcolor: '#C01227',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
@@ -188,7 +222,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign In
|
{loading ? 'Signing in…' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box sx={{ textAlign: 'center', mt: 1 }}>
|
<Box sx={{ textAlign: 'center', mt: 1 }}>
|
||||||
|
|||||||
@@ -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,12 +48,51 @@ 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');
|
||||||
@@ -67,59 +103,92 @@ export default function Dispatch() {
|
|||||||
// 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
|
|
||||||
};
|
|
||||||
|
|
||||||
setManifests([newManifest, ...manifests]);
|
|
||||||
setOpenModal(false);
|
setOpenModal(false);
|
||||||
setNewVehicle('');
|
setNewVehicle('');
|
||||||
setNewDestination('');
|
setNewDestination('');
|
||||||
setToast({ open: true, msg: `Batch ${newManifest.id} created successfully`, severity: 'success' });
|
setToast({ open: true, msg: `Batch ${label} created successfully`, severity: 'success' });
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setToast({ open: true, msg: err?.message || 'Could not create the batch.', severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeal = (id) => {
|
// Move a batch to the next status via PATCH.
|
||||||
|
const advance = async (m, apiStatus, uiStatus, okMsg) => {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(m.tripsheetid);
|
||||||
|
try {
|
||||||
|
await updateBatchStatus(m.tripsheetid, apiStatus);
|
||||||
setManifests((prev) =>
|
setManifests((prev) =>
|
||||||
prev.map((m) => (m.id === id ? { ...m, status: 'Ready', time: 'Checked & ready just now' } : m))
|
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: `Batch ${id} checked and ready to send.`, severity: 'success' });
|
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 & Mark Ready
|
Check & 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>
|
||||||
|
|||||||
314
src/pages/operations/HubSettings.jsx
Normal file
314
src/pages/operations/HubSettings.jsx
Normal 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 can’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 today’s inbound parcels.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// hubName is derived from a stable localStorage read; safe to omit.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInbound();
|
||||||
|
}, [loadInbound]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const 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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,26 +41,64 @@ 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
|
||||||
@@ -65,9 +106,32 @@ export default function OrderAssignment() {
|
|||||||
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
|
|
||||||
setSelectedOrders(prev => prev.filter(id => id !== selectedOrderForAssign.id));
|
|
||||||
setAssignDialogOpen(false);
|
setAssignDialogOpen(false);
|
||||||
};
|
notify(`Pickup #${order.id} assigned to ${name}.`);
|
||||||
|
} catch (err) {
|
||||||
const handleAutoAssignAll = () => {
|
notify(err?.message || 'Could not assign this miler.', 'error');
|
||||||
if (selectedOrders.length === 0) return;
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
setOrders(orders.map(o => {
|
|
||||||
if (selectedOrders.includes(o.id) && o.status === 'Pending Assignment') {
|
|
||||||
// Just pick a random miler for auto-assign simulation
|
|
||||||
const randomMiler = AVAILABLE_MILERS[Math.floor(Math.random() * AVAILABLE_MILERS.length)];
|
|
||||||
return { ...o, status: `Assigned to ${randomMiler.name}`, assignedMiler: randomMiler };
|
|
||||||
}
|
}
|
||||||
return o;
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear selection
|
|
||||||
setSelectedOrders([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingCount = orders.filter(o => o.status === 'Pending Assignment').length;
|
// Let the AI engine assign every selected booking (POST /hub/bookings/:id/auto-assign).
|
||||||
|
const handleAutoAssignAll = async () => {
|
||||||
|
if (selectedOrders.length === 0 || busy) return;
|
||||||
|
const ids = orders.filter((o) => selectedOrders.includes(o.id) && o.status === PENDING).map((o) => o.id);
|
||||||
|
setBusy(true);
|
||||||
|
let ok = 0;
|
||||||
|
let pending = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const res = await autoAssignBooking(id);
|
||||||
|
// 202 → engine still working: success:true but no miler yet.
|
||||||
|
const name = res?.data?.milername;
|
||||||
|
if (name) {
|
||||||
|
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: `Assigned to ${name}` } : o)));
|
||||||
|
ok += 1;
|
||||||
|
} else {
|
||||||
|
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'Assigning…' } : o)));
|
||||||
|
pending += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 422 → no eligible miler in range; other codes → generic failure.
|
||||||
|
failed += 1;
|
||||||
|
if (err?.status === 422) {
|
||||||
|
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'No miler in range' } : o)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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).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,11 +381,16 @@ 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>
|
||||||
|
{availableMilers.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
||||||
|
No available milers right now. Try Auto-Assign, or check the Milers page.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
<List>
|
<List>
|
||||||
{AVAILABLE_MILERS.map((miler) => (
|
{availableMilers.map((miler) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={miler.id}
|
key={miler.id}
|
||||||
onClick={() => setSelectedMiler(miler.id)}
|
onClick={() => setSelectedMiler(miler.id)}
|
||||||
@@ -299,19 +409,41 @@ export default function OrderAssignment() {
|
|||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={miler.name}
|
primary={miler.name}
|
||||||
secondary={`${miler.vehicle} • ${miler.distance}`}
|
secondary={
|
||||||
|
<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 }}
|
primaryTypographyProps={{ fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} />
|
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
))}
|
||||||
</List>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:00–10: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:00–11: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:00–12: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:00–13: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:00–16: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:00–17: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:00–18:00', cod: 0, payment: 'Merchant', legKm: 2.2, instructions: 'Shop was closed at pickup time.' },
|
}
|
||||||
hubStop('16:50'),
|
};
|
||||||
],
|
};
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'RDR-8041', name: 'Sana Sheikh', color: '#8E24AA', vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199', phone: '+91 98765 43214',
|
|
||||||
delivery: {
|
|
||||||
startTime: '08:00', endTime: '14:05', distanceKm: 22.9,
|
|
||||||
stops: [
|
|
||||||
hubStop('08:00'),
|
|
||||||
{ kind: 'order', orderId: 'ORD-100277', customer: 'Imran Qureshi', phone: '+91 90055 66778', address: 'N-Block, Connaught Place, New Delhi', lat: 28.6315, lng: 77.2167, time: '08:30', status: 'Picked', items: 1, weight: '0.4 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 2.1, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100283', customer: 'Neha Gupta', phone: '+91 90066 77889', address: 'C-44, Lajpat Nagar II, New Delhi', lat: 28.5677, lng: 77.2433, time: '09:20', status: 'Picked', items: 2, weight: '1.7 kg', slot: '09:00–11:00', cod: 1450, payment: 'COP', legKm: 7.4, instructions: 'Ring twice.' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100291', customer: 'Vikas Rao', phone: '+91 90077 88990', address: 'J-Block, Saket, New Delhi', lat: 28.5245, lng: 77.2066, time: '10:40', status: 'Picked', items: 1, weight: '0.8 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 6.2, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100305', customer: 'Diya Shah', phone: '+91 90088 99001', address: 'Malviya Nagar Main Market, New Delhi', lat: 28.5355, lng: 77.2110, time: '11:55', status: 'Picked', items: 4, weight: '3.1 kg', slot: '11:00–13:00', cod: 2300, payment: 'COP', legKm: 1.4, instructions: 'Heavy parcel — handle with care.' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pickup: {
|
|
||||||
startTime: '14:30', endTime: '17:20', distanceKm: 14.6,
|
|
||||||
stops: [
|
|
||||||
{ kind: 'order', orderId: 'PCK-50140', customer: 'Bloom & Co Florists', phone: '+91 98804 44555', address: 'Greater Kailash I, New Delhi', lat: 28.5494, lng: 77.2426, time: '15:00', status: 'Picked', items: 4, weight: '2.0 kg', slot: '14:30–16:30', cod: 0, payment: 'Merchant', legKm: 8.1, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'PCK-50147', customer: 'BookNook', phone: '+91 98805 55666', address: 'Hauz Khas Village, New Delhi', lat: 28.5535, lng: 77.1944, time: '16:05', status: 'Picked', items: 9, weight: '7.8 kg', slot: '15:30–17:30', cod: 0, payment: 'Merchant', legKm: 4.5, instructions: 'Multiple boxes.' },
|
|
||||||
hubStop('17:20'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'RDR-8015', name: 'Rajesh Sharma', color: '#1E8E3E', vehicle: 'Cargo Van', vehicleNo: 'DL-01-CV-9876', phone: '+91 98765 43211',
|
|
||||||
delivery: {
|
|
||||||
startTime: '07:50', endTime: '15:10', distanceKm: 31.2,
|
|
||||||
stops: [
|
|
||||||
hubStop('07:50'),
|
|
||||||
{ kind: 'order', orderId: 'ORD-100312', customer: 'Anil Verma', phone: '+91 90099 00112', address: 'Sector 7, Rohini, New Delhi', lat: 28.7042, lng: 77.1025, time: '08:55', status: 'Picked', items: 5, weight: '6.2 kg', slot: '08:00–10:00', cod: 0, payment: 'Prepaid', legKm: 12.4, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100320', customer: 'Meera Iyer', phone: '+91 90100 11223', address: 'NSP, Pitampura, New Delhi', lat: 28.6996, lng: 77.1314, time: '09:50', status: 'Picked', items: 2, weight: '2.0 kg', slot: '09:00–11:00', cod: 990, payment: 'COP', legKm: 3.2, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100334', customer: 'Sahil Khan', phone: '+91 90111 22334', address: 'Model Town III, New Delhi', lat: 28.7158, lng: 77.1910, time: '11:10', status: 'Failed', items: 1, weight: '0.7 kg', slot: '10:00–12:00', cod: 0, payment: 'Prepaid', legKm: 6.1, instructions: 'Wrong address provided.' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100349', customer: 'Tara Bose', phone: '+91 90122 33445', address: 'Civil Lines, New Delhi', lat: 28.6796, lng: 77.2240, time: '12:30', status: 'Picked', items: 3, weight: '4.4 kg', slot: '12:00–14:00', cod: 3100, payment: 'COP', legKm: 5.0, instructions: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pickup: {
|
|
||||||
startTime: '15:40', endTime: '18:30', distanceKm: 19.8,
|
|
||||||
stops: [
|
|
||||||
{ kind: 'order', orderId: 'PCK-50155', customer: 'MegaMart Warehouse', phone: '+91 98806 66777', address: 'Wazirpur Industrial Area, New Delhi', lat: 28.6991, lng: 77.1612, time: '16:20', status: 'Picked', items: 24, weight: '38.0 kg', slot: '16:00–18:00', cod: 0, payment: 'Merchant', legKm: 11.0, instructions: 'Use loading dock 4.' },
|
|
||||||
{ kind: 'order', orderId: 'PCK-50163', customer: 'HomeStyle Furnishings', phone: '+91 98807 77888', address: 'Ashok Vihar, New Delhi', lat: 28.6924, lng: 77.1760, time: '17:25', status: 'Picked', items: 8, weight: '22.5 kg', slot: '17:00–19:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' },
|
|
||||||
hubStop('18:30'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'RDR-8044', name: 'Harpreet Gill', color: '#E8710A', vehicle: 'Cycle', vehicleNo: '—', phone: '+91 98765 43215',
|
|
||||||
delivery: {
|
|
||||||
startTime: '09:20', endTime: '13:15', distanceKm: 9.7,
|
|
||||||
stops: [
|
|
||||||
hubStop('09:20'),
|
|
||||||
{ kind: 'order', orderId: 'ORD-100356', customer: 'Kabir Anand', phone: '+91 90133 44556', address: 'Mayur Vihar Phase I, New Delhi', lat: 28.6090, lng: 77.2920, time: '10:05', status: 'Picked', items: 1, weight: '0.5 kg', slot: '10:00–12:00', cod: 600, payment: 'COP', legKm: 9.0, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100361', customer: 'Ritu Saxena', phone: '+91 90144 55667', address: 'Mayur Vihar Phase III, New Delhi', lat: 28.6135, lng: 77.3215, time: '11:10', status: 'Picked', items: 2, weight: '1.2 kg', slot: '11:00–13:00', cod: 0, payment: 'Prepaid', legKm: 3.0, instructions: '' },
|
|
||||||
{ kind: 'order', orderId: 'ORD-100370', customer: 'Farhan Ali', phone: '+91 90155 66778', address: 'Patparganj, New Delhi', lat: 28.6280, lng: 77.2960, time: '12:20', status: 'Picked', items: 1, weight: '0.8 kg', slot: '12:00–14:00', cod: 450, payment: 'COP', legKm: 2.9, instructions: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pickup: {
|
|
||||||
startTime: '13:40', endTime: '15:30', distanceKm: 6.4,
|
|
||||||
stops: [
|
|
||||||
{ kind: 'order', orderId: 'PCK-50170', customer: 'Cafe Mosaic', phone: '+91 98808 88999', address: 'Mayur Vihar Phase I Market, New Delhi', lat: 28.6055, lng: 77.2985, time: '14:10', status: 'Picked', items: 2, weight: '1.5 kg', slot: '14:00–16:00', cod: 0, payment: 'Merchant', legKm: 4.0, instructions: '' },
|
|
||||||
hubStop('15:30'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── 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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
const toApiPayload = (rider) => ({
|
||||||
|
displayname: rider.name,
|
||||||
|
phone: rider.phone,
|
||||||
|
hubid: rider.hubid || hub.hubid,
|
||||||
|
vehicleid: rider.vehicleid || vehicleIdFor(rider.vehicle),
|
||||||
|
availabilitystatus: UI_TO_API_STATUS[rider.status] || 'Available'
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async (rider) => {
|
||||||
|
try {
|
||||||
if (formDialog.mode === 'add') {
|
if (formDialog.mode === 'add') {
|
||||||
setRiders(p => [rider, ...p]);
|
await createMiler(toApiPayload(rider));
|
||||||
toast(`${rider.name} onboarded successfully`);
|
toast(`${rider.name} onboarded successfully`);
|
||||||
} else {
|
} else {
|
||||||
setRiders(p => p.map(r => r.id === rider.id ? rider : r));
|
await updateMiler(rider.userid ?? rider.id, toApiPayload(rider));
|
||||||
toast(`${rider.name} updated`);
|
toast(`${rider.name} updated`);
|
||||||
}
|
}
|
||||||
setFormDialog({ open: false, mode: 'add', initial: null });
|
setFormDialog({ open: false, mode: 'add', initial: null });
|
||||||
|
loadMilers();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.message || 'Could not save this miler.', 'error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = async () => {
|
||||||
setRiders(p => p.filter(r => r.id !== deleteTarget.id));
|
const target = deleteTarget;
|
||||||
toast(`${deleteTarget.name} removed`, 'info');
|
try {
|
||||||
|
await deleteMiler(target.userid ?? target.id);
|
||||||
|
setRiders((p) => p.filter((r) => r.id !== target.id));
|
||||||
|
toast(`${target.name} removed`, 'info');
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.message || 'Could not remove this miler.', 'error');
|
||||||
|
} finally {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setProfile(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 {
|
||||||
|
await updateMiler(updated.userid ?? updated.id, toApiPayload(updated));
|
||||||
|
setRiders((p) => p.map((r) => (r.id === updated.id ? updated : r)));
|
||||||
setProfile(updated);
|
setProfile(updated);
|
||||||
setProfileEdit(false);
|
setProfileEdit(false);
|
||||||
toast(`${updated.name} updated`);
|
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>
|
||||||
|
|||||||
@@ -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 {
|
return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
|
||||||
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 (
|
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>
|
||||||
|
|
||||||
@@ -178,9 +122,11 @@ export default function Routing() {
|
|||||||
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.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{waiting.map((pkg) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={key}
|
key={pkg.trackingno}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchId(key);
|
setSearchId(pkg.trackingno);
|
||||||
handleSearch(key);
|
handleSearch(pkg.trackingno);
|
||||||
}}
|
}}
|
||||||
selected={searchId === key}
|
selected={searchId === pkg.trackingno}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2, mb: 1, p: 2,
|
borderRadius: 2, mb: 1, p: 2,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: searchId === key ? '#C01227' : '#eaeaea',
|
borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea',
|
||||||
bgcolor: searchId === key ? '#C0122708' : '#fff',
|
bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff',
|
||||||
'&:hover': { bgcolor: '#f8f9fa' }
|
'&:hover': { bgcolor: '#f8f9fa' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={key}
|
primary={pkg.trackingno}
|
||||||
secondary={`${pkg.source} → ${pkg.destHub === HUB_NAME ? pkg.destZone : pkg.destHub}`}
|
secondary={`Going to ${pkg.dest}`}
|
||||||
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === key ? '#C01227' : '#212529' }}
|
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === pkg.trackingno ? '#C01227' : '#212529' }}
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }}
|
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }}
|
||||||
/>
|
/>
|
||||||
</ListItemButton>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
export default function TrackingMap() {
|
case 'On_Break':
|
||||||
const [riders, setRiders] = useState(INIT_RIDERS);
|
case 'On Break':
|
||||||
const [linehauls, setLinehauls] = useState(INIT_LINEHAUL);
|
return { color: '#8E24AA', label: 'On Break' };
|
||||||
|
case 'Offline':
|
||||||
// Smooth operational tracking loop iteration updates
|
return { color: '#80868B', label: 'Offline' };
|
||||||
useEffect(() => {
|
default:
|
||||||
const interval = setInterval(() => {
|
return { color: '#0070f3', label: status || 'Active' };
|
||||||
setRiders((prev) =>
|
}
|
||||||
prev.map(r => ({
|
|
||||||
...r,
|
|
||||||
progress: r.progress >= 1 ? 0 : parseFloat((r.progress + 0.005).toFixed(3))
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
setLinehauls((prev) =>
|
|
||||||
prev.map(l => ({
|
|
||||||
...l,
|
|
||||||
progress: l.progress >= 1 ? 0 : parseFloat((l.progress + 0.001).toFixed(3))
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}, 150);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getInterpolatedPosition = (start, end, progress) => {
|
|
||||||
return [
|
|
||||||
start[0] + (end[0] - start[0]) * progress,
|
|
||||||
start[1] + (end[1] - start[1]) * progress
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapCenter = [22.0, 76.0]; // Centered across the Delhi → Mumbai / Bengaluru network
|
export default function TrackingMap() {
|
||||||
|
const hub = getHubContext();
|
||||||
|
const [milers, setMilers] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [hubs, setHubs] = useState([]);
|
||||||
|
const [linehauls, setLinehauls] = useState([]);
|
||||||
|
const didLoad = useRef(false);
|
||||||
|
|
||||||
|
// Hub pins are static per session — load once from /hub/hubs (has lat/lon).
|
||||||
|
useEffect(() => {
|
||||||
|
getHubs()
|
||||||
|
.then((res) =>
|
||||||
|
setHubs(
|
||||||
|
(res?.data || [])
|
||||||
|
.filter((h) => h.lat != null && h.lon != null)
|
||||||
|
.map((h) => ({ id: h.hubid, name: h.hubname, position: [h.lat, h.lon], type: prettyHubType(h.hubtype) }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(() => setHubs([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll live miler GPS every 5 seconds. The guide's /admin/milers/locations
|
||||||
|
// (Redis GEO) returns nothing on this backend, so we read coordinates from
|
||||||
|
// /hub/milers (currentlatitude/currentlongitude) and fall back to the GEO
|
||||||
|
// endpoint if it ever starts returning data.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const normalize = (list) =>
|
||||||
|
(list || [])
|
||||||
|
.map((m) => ({
|
||||||
|
userid: m.userid,
|
||||||
|
displayname: m.displayname,
|
||||||
|
lat: m.currentlatitude ?? m.lat,
|
||||||
|
lon: m.currentlongitude ?? m.lon,
|
||||||
|
status: m.availabilitystatus ?? m.status,
|
||||||
|
bookingid: m.currentbookingid ?? m.bookingid
|
||||||
|
}))
|
||||||
|
.filter((m) => m.lat != null && m.lon != null && !(m.lat === 0 && m.lon === 0));
|
||||||
|
|
||||||
|
const 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 (
|
|
||||||
<React.Fragment key={rider.id}>
|
|
||||||
<Polyline positions={[rider.start, rider.end]} color="#0070f3" dashArray="5, 8" weight={2} opacity={0.6} />
|
|
||||||
<Marker position={pos} icon={createRiderIcon()}>
|
|
||||||
<Popup>
|
<Popup>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{rider.name}</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{m.displayname || `Miler ${m.userid}`}</Typography>
|
||||||
<Typography variant="caption">{rider.area}</Typography>
|
<Typography variant="caption" display="block">{statusMeta(m.status).label}</Typography>
|
||||||
|
{m.bookingid && <Typography variant="caption" display="block">On booking #{m.bookingid}</Typography>}
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</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);
|
|
||||||
return (
|
|
||||||
<React.Fragment key={lh.id}>
|
<React.Fragment key={lh.id}>
|
||||||
<Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} />
|
<Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} />
|
||||||
<Marker position={pos} icon={createLinehaulIcon()}>
|
<Marker position={lh.current} icon={createLinehaulIcon()}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography>
|
||||||
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography>
|
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
</React.Fragment>
|
</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 primary="No hubs to show" primaryTypographyProps={{ color: 'text.secondary', fontSize: '0.85rem' }} />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
{hubs.map((h) => (
|
||||||
|
<ListItem key={h.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={hub.name}
|
primary={h.name}
|
||||||
secondary={hub.type}
|
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' }}
|
||||||
/>
|
/>
|
||||||
@@ -197,30 +280,40 @@ 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 />
|
||||||
|
{milers.length === 0 ? (
|
||||||
|
<Box sx={{ px: 3, py: 3 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">No milers reporting a location right now.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{riders.map(r => (
|
{milers.map((m) => {
|
||||||
<ListItem key={r.id} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
const meta = statusMeta(m.status);
|
||||||
|
const name = m.displayname || `Miler ${m.userid}`;
|
||||||
|
return (
|
||||||
|
<ListItem key={m.userid} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Badge color="success" variant="dot" overlap="circular" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
<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>
|
<Avatar sx={{ width: 34, height: 34, bgcolor: meta.color, fontWeight: 700, fontSize: 13 }}>{name.charAt(0)}</Avatar>
|
||||||
</Badge>
|
</Badge>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={r.name}
|
primary={name}
|
||||||
secondary={r.area}
|
secondary={m.bookingid ? `On booking #${m.bookingid}` : 'No active booking'}
|
||||||
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
|
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem', noWrap: true }}
|
secondaryTypographyProps={{ fontSize: '0.75rem', noWrap: true }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" sx={{ color: '#0070f3', fontWeight: 700, ml: 1, bgcolor: '#0070f308', px: 1, py: 0.5, borderRadius: 2 }}>
|
<Typography variant="caption" sx={{ color: meta.color, fontWeight: 700, ml: 1, bgcolor: `${meta.color}12`, px: 1, py: 0.5, borderRadius: 2, whiteSpace: 'nowrap' }}>
|
||||||
{Math.round(r.progress * 100)}%
|
{meta.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Linehaul Fleet Shipments Tracker */}
|
{/* Linehaul Fleet Shipments Tracker */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user