added the api for the real data

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

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# Doormile Hub Console — frontend configuration
# Copy to `.env` (or `.env.local`) only if you need to override the defaults.
# ── Development ──────────────────────────────────────────────────────────────
# By default the dev server proxies every /api request to the backend
# (see `server.proxy` in vite.config.js). This makes browser requests
# SAME-ORIGIN, so the backend's localhost-only CORS allowlist never applies —
# it works whether you open the app on localhost, a LAN IP, or a hostname.
#
# Leave VITE_API_BASE_URL UNSET in dev so the proxy is used. To point dev at a
# different backend, change the proxy `target` in vite.config.js.
# ── Production ───────────────────────────────────────────────────────────────
# A production build calls the API host directly (default below). The backend
# must allow your deployed origin via CORS, or serve the app behind a reverse
# proxy that forwards /api. Override the host here if needed:
# VITE_API_BASE_URL=https://api.doormile.com
# Shared key for the internal assignment engine (auto-assign / reassign).
# Sent as the `X-Internal-Key` header instead of a Bearer token.
# VITE_INTERNAL_KEY=doormile-internal-2024

View File

@@ -0,0 +1,889 @@
# Doormile Hub Console — Frontend Integration Guide
**Base URL:** `https://api.doormile.com`
**Auth:** Bearer token in `Authorization` header on every authenticated request
**Content-Type:** `application/json` on all POST / PATCH calls
## How the flow works
1. Staff opens the console → Login page
2. `POST /api/v1/hub/login` → receives JWT + hub context
3. Store token + hub context in localStorage
4. Every page reads hub name/city from stored context (no extra call)
5. Every API call sends `Authorization: Bearer <token>`
6. On 401 → clear localStorage → redirect to `/login`
---
## 1. Authentication
### POST /api/v1/hub/login
No auth header needed.
Request body:
```json
{
"email": "hub.coimbatore@doormile.in",
"password": "password123"
}
```
Response:
```json
{
"success": true,
"token": "eyJhbGci...",
"hub": {
"hubid": 1,
"hubname": "Coimbatore Jupiter Hub",
"hubtype": "sorting_center",
"city": "Coimbatore",
"capacity": 50
},
"staff": {
"displayname": "Coimbatore Hub Staff",
"email": "hub.coimbatore@doormile.in",
"role": 6
},
"is_doormile_staff": true
}
```
What to store in localStorage after login:
```js
localStorage.setItem('hub_token', data.token)
localStorage.setItem('hub_context', JSON.stringify(data.hub))
localStorage.setItem('hub_staff', JSON.stringify(data.staff))
localStorage.setItem('hub_is_doormile', String(data.is_doormile_staff))
```
Test accounts:
| Email | Password | Hub | Access |
|---|---|---|---|
| hub.coimbatore@doormile.in | password123 | Coimbatore Jupiter Hub | Full (Doormile staff) |
| hub.hyderabad@doormile.in | password123 | Hyderabad hub | Full (Doormile staff) |
| hub.bangalore@doormile.in | password123 | Bangalore hub | Full (Doormile staff) |
| hub.chennai@doormile.in | password123 | Chennai hub | Full (Doormile staff) |
| hub@kpmtravels.in | password123 | Hub 1 | Restricted (partner — no hub management) |
---
## 2. Dashboard
### GET /api/v1/hub/dashboard
Returns live KPI stats for the logged-in staff's hub.
Response:
```json
{
"success": true,
"data": {
"parcels_received_today": 142,
"milers_available": 6,
"milers_on_duty": 12,
"pending_pickups": 8,
"batches_sent_today": 3,
"exceptions": 2
}
}
```
Which UI element uses which field:
| Dashboard card | Field |
|---|---|
| Total Parcels Received | `parcels_received_today` |
| Available Milers | `milers_available` |
| Milers on Duty | `milers_on_duty` |
| Pending Pickups | `pending_pickups` |
| Batches Sent Today | `batches_sent_today` |
| Needs Checking | `exceptions` |
**Note:** Hub name shown in the header/title comes from localStorage (`hub_context.hubname`) — not from this endpoint. No extra call needed.
---
## 3. Inbound — Receive Parcels
### GET /api/v1/hub/inbound/today
Returns all parcels inbounded at this hub today. Now returns `sendername`, `originname`, `destinationname`, `temperature` (readable names instead of raw IDs and pincodes).
Response:
```json
{
"success": true,
"data": [
{
"bookingid": 15,
"trackingnumber": "DM-882201",
"sendername": "Acme Corp",
"originname": "Mumbai Hub",
"destinationname": "Dwarka Sec 12, Coimbatore",
"weight": "2.4 kg",
"condition": "Good",
"temperature": "N/A",
"shelf": "Zone A (Shelf 1)",
"inboundedat": "2026-07-04T09:15:00Z"
}
],
"total": 1
}
```
### POST /api/v1/hub/bookings/:id/inbound
Scan a parcel in. `:id` is the booking/consignment ID.
Request body:
```json
{
"tracking_id": "DM-882201",
"condition": "Good",
"temperature": "N/A",
"shelf": "Zone A (Shelf 1)",
"weight": "2.4 kg"
}
```
Condition values: `Good` · `Damaged Box` · `Wet / Crushed` · `Missing Label`
Temperature: Free text — "4.1°C" for cold chain items, "N/A" for dry goods
Response:
```json
{
"success": true,
"data": {
"bookingid": 15,
"trackingnumber": "DM-882201",
"recommended_shelf": "Zone A (Shelf 1)",
"status": "At_Hub"
}
}
```
Shelf recommendation logic (backend handles this automatically):
- Condition contains Damaged / Wet / Missing → Exception Area
- Temperature is not N/A → Zone C (Cold Room)
- Otherwise → Zone A or Zone B based on destination
Error — tracking ID not found:
```json
{
"success": false,
"message": "booking not found"
}
```
HTTP 404.
---
## 4. Order Assignment — Pickup Requests
### GET /api/v1/hub/bookings/unassigned
Returns all pending pickup requests for this hub's city.
Response:
```json
{
"success": true,
"data": [
{
"bookingid": 21,
"customerName": "Ramesh K.",
"pickupaddress": "Gandhipuram, Coimbatore",
"deliveryaddress": "Hitech City, Hyderabad",
"packagedescription": "Small Box, 2kg",
"createdat": "2026-07-04T08:45:00Z",
"status": "Pending_Pickup"
}
],
"total": 3
}
```
For assigning a miler to these bookings, see **Section 14 — Hub Assignment Override**.
---
## 5. Dispatch & Batches
### GET /api/v1/hub/batches
Returns all outgoing batches for this hub.
Response:
```json
{
"success": true,
"data": [
{
"tripsheetid": 1,
"batchlabel": "BATCH-9281",
"batchkind": "transfer",
"destinationlabel": "Mumbai Hub (BOM-02)",
"vehicle": "Truck DL-01-BZ-8055",
"itemcount": 154,
"status": "Draft",
"createdat": "2026-07-04T08:00:00Z",
"dispatchtime": null
}
],
"total": 1
}
```
Status values: `Draft``Ready``Dispatched`
Batchkind values: `local` · `transfer`
### POST /api/v1/hub/batches
Create a new outgoing batch.
Request body:
```json
{
"route": "Transfer to Mumbai Hub",
"destination": "Mumbai Hub (BOM-02)",
"vehicle": "Truck DL-01-BZ-8055",
"parcels_count": 154,
"kind": "transfer"
}
```
Response:
```json
{
"success": true,
"data": {
"tripsheetid": 2,
"batchlabel": "BATCH-9282",
"status": "Draft"
}
}
```
### PATCH /api/v1/hub/batches/:id/status
Move a batch through its status flow.
Request body — mark as ready:
```json
{ "status": "Ready" }
```
Request body — dispatch (send out):
```json
{ "status": "Dispatched" }
```
Response:
```json
{
"success": true,
"data": {
"tripsheetid": 1,
"status": "Ready",
"dispatchtime": null
}
}
```
Invalid status transition → 400:
```json
{
"success": false,
"message": "invalid status"
}
```
---
## 6. Milers
### GET /api/v1/hub/milers
Returns all milers assigned to this hub, now with operational stats per miler.
Response:
```json
{
"success": true,
"data": [
{
"userid": 4,
"displayname": "Rajesh Kumar",
"phone": "+91 98765 43210",
"vehicleid": 1,
"hubid": 1,
"availabilitystatus": "Available",
"rating": 4.8,
"completedorders": 45,
"cancelledorders": 2,
"currentlat": 11.0168,
"currentlon": 76.9558,
"device_token": "fcm_token_here",
"zones": ["641001", "641012"],
"assignedload": 2,
"capacity": 30,
"pickupspending": 1,
"codcollected": 4500.00,
"codpending": 1200.00,
"checkinat": "2026-07-04T08:12:00Z",
"hoursactive": 6.4,
"isverified": true
}
],
"total": 6
}
```
Availability status values: `Available` · `Assigned` · `On_Break` · `Offline`
The `zones`, `assignedload`, `capacity`, `pickupspending`, `codcollected`, `codpending`, `checkinat`, `hoursactive`, `isverified` fields were the empty fields the Milers page was trying to show. Now populated from real data.
### POST /api/v1/admin/milers
Create (onboard) a new miler. Hub JWT is accepted here.
Request body:
```json
{
"displayname": "Muthu Kumar",
"phone": "+91 90011 22334",
"hubid": 1,
"vehicleid": 2,
"availabilitystatus": "Available"
}
```
Response:
```json
{
"success": true,
"data": {
"userid": 10,
"displayname": "Muthu Kumar"
}
}
```
### PATCH /api/v1/admin/milers/:id
Update a miler's details or status.
Request body (any subset of fields):
```json
{
"availabilitystatus": "On_Break",
"hubid": 2
}
```
### DELETE /api/v1/admin/milers/:id
Remove a miler from the roster.
Response:
```json
{
"success": true,
"message": "miler removed"
}
```
---
## 7. Routing — Where Does It Go?
### GET /api/v1/hub/routing/:trackingno
Scan a parcel and get its sort destination. `:trackingno` is the tracking number scanned at the sort station.
Response:
```json
{
"success": true,
"data": {
"trackingno": "DM-TRK-0015",
"destination": "Peelamedu, 641004",
"recommendedshelf": "Zone B",
"nexthop": "Local delivery",
"condition": "Good",
"iscoldchain": false,
"weight": "2.4 kg",
"customername": "Ramesh K.",
"bookingid": 15
}
}
```
`nexthop` values: `Local delivery` · `Transfer to Mumbai Hub` (or whichever destination hub).
Use `recommendedshelf` to show the big shelf indicator UI.
Error — tracking number not found → 404. Show a "Parcel not found" error state.
```json
{
"success": false,
"message": "parcel not found"
}
```
---
## 8. Rider Routes
### GET /api/v1/hub/rider-routes
Returns all milers at this hub with their planned stops today.
### GET /api/v1/hub/milers/:id/route
Returns the planned stops for a single miler. `:id` is the miler user ID.
Response (same `data[]` shape for both; the single-miler endpoint returns one entry):
```json
{
"success": true,
"data": [
{
"mileruserid": 4,
"milername": "Rajesh Kumar",
"mode": "pickup",
"totalstops": 5,
"completedstops": 2,
"totaldistance_km": 12.4,
"stops": [
{ "seq": 1, "address": "Gandhipuram, Coimbatore", "lat": 11.0168, "lon": 76.9558, "items": 1, "bookingid": 20, "status": "completed", "eta_minutes": 0 },
{ "seq": 2, "address": "RS Puram, Coimbatore", "lat": 11.001, "lon": 76.951, "items": 2, "bookingid": 21, "status": "pending", "eta_minutes": 12 }
]
}
]
}
```
`mode` values: `pickup` · `delivery`
Stop status values: `pending` · `in_progress` · `completed`
Pass `stops[].lat/lon` to OSRM to draw the route line — the OSRM call stays client-side.
---
## 9. Live Trucks
### GET /api/v1/hub/tripsheets/in-transit
Returns transfer trucks currently in transit, with interpolated live positions.
Poll this every 5 seconds alongside miler locations.
Response:
```json
{
"success": true,
"data": [
{
"tripsheetid": 3,
"tripsheetno": "DM-TS-0003",
"label": "Chennai Jupiter Hub → Coimbatore Jupiter Hub",
"originlat": 13.0827, "originlon": 80.2707,
"destlat": 11.0168, "destlon": 76.9558,
"currentlat": 12.1, "currentlon": 78.9,
"status": "In_Transit",
"progresspct": 40,
"vehicleno": "TN-04-AX-8822",
"itemcount": 154,
"eta_minutes": 180
}
]
}
```
`currentlat/lon` gives the truck's position on the map. Replace the fake animated truck with these real coordinates.
---
## 10. Arriving Vehicles
### GET /api/v1/hub/inbound/vehicles
Returns trucks arriving at this hub (Dashboard → "Trucks Arriving" table).
Response:
```json
{
"success": true,
"data": [
{
"vehicleno": "TN-04-AX-8822",
"origin": "Chennai Jupiter Hub",
"tripsheetid": 3,
"status": "On the way",
"eta": "2 hrs 30 min",
"unloadedpct": 0,
"itemcount": 154
}
]
}
```
Status values: `On the way` · `Unloading`
(Dispatched maps to `On the way`, Arrived maps to `Unloading`.)
---
## 11. Activity Feed
### GET /api/v1/hub/activity?limit=10
Returns a real event feed for this hub (UNION across 4 sources: inbound, dispatch, exceptions, sorting).
Response:
```json
{
"success": true,
"data": [
{ "time": "2026-07-04T11:24:00Z", "type": "exception", "text": "Exception raised: Damaged on DM-TRK-999999" },
{ "time": "2026-07-04T11:22:00Z", "type": "inbound", "text": "Received parcel DM-TRK-999999 from Coimbatore Jupiter Hub" },
{ "time": "2026-07-04T11:20:00Z", "type": "dispatch", "text": "Dispatched batch DM-TS-0003 to Mumbai Hub" }
],
"total": 3
}
```
Type values: `inbound` · `dispatch` · `exception` · `sorting`
Use `type` to pick the icon color — same color scheme as the existing mock.
---
## 12. Zones
### GET /api/v1/hub/zones
Returns the delivery zone breakdown for this hub (Dashboard → "Delivery Areas").
Response:
```json
{
"success": true,
"data": [
{ "zone": "641001", "zonename": "Gandhipuram", "parcels": 12, "milers": 2, "status": "Active" },
{ "zone": "641004", "zonename": "Peelamedu", "parcels": 8, "milers": 1, "status": "Active" },
{ "zone": "641012", "zonename": "Jupiter Nagar","parcels": 3, "milers": 0, "status": "Need Milers" }
]
}
```
Status values: `Active` · `Need Milers` — use this to color the status chip.
---
## 13. Notifications
### GET /api/v1/hub/notifications
Returns notifications for the header bell icon, generated from real events.
Response:
```json
{
"success": true,
"data": [
{ "id": 1, "title": "Exception: Damaged", "type": "exception", "time": "3 min ago", "read": false },
{ "id": 2, "title": "Truck arriving from Chennai Hub", "type": "inbound", "time": "5 min ago", "read": false },
{ "id": 3, "title": "Pickup waiting 30+ min", "type": "alert", "time": "12 min ago", "read": false }
],
"total": 3
}
```
Type values: `exception` · `inbound` · `warning` · `alert`
### PATCH /api/v1/hub/notifications/:id/read
Mark a notification as read.
Response:
```json
{ "success": true }
```
On bell click → `GET /hub/notifications`. On item click → `PATCH /hub/notifications/:id/read`, then mark it read in local state.
---
## 14. Hub Assignment Override
The old admin assign endpoint (`POST /admin/bookings/:id/assign-miler`) returned **403** for hub staff. Two new hub-scoped endpoints replace it.
### POST /api/v1/hub/bookings/:id/assign-miler
Manual assign — hub staff picks the miler.
Request body:
```json
{ "mileruserid": 4 }
```
Response:
```json
{
"success": true,
"data": {
"bookingid": 21,
"mileruserid": 4,
"milername": "Rajesh Kumar",
"status": "Miler_Assigned"
}
}
```
Use this on the **Pickup Requests** page when staff manually selects a miler from the dialog.
### POST /api/v1/hub/bookings/:id/auto-assign
Auto-assign — AI engine picks the miler.
Request body:
```json
{}
```
Response (success):
```json
{
"success": true,
"data": {
"mileruserid": 4,
"milername": "Rajesh Kumar",
"distance_km": 0.4
}
}
```
Response (no miler) — HTTP 422:
```json
{
"success": false,
"message": "No eligible miler found in range"
}
```
Response (timeout) — HTTP 202:
```json
{
"success": true,
"message": "assignment in progress"
}
```
Use this for the **Auto-Assign** bulk button. Loop over selected booking IDs and call this for each one.
**Backend implementation notes:** shared `AssignMilerToBooking()` service (`booking_assignment_service.go`), `TryAssignOnce()` single-attempt wrapper on `crm_assignment`, `assignedbyuserid` is nullable for hub-initiated assignments, and `AdminAssignMiler` now sends FCM to the miler.
---
## 15. Hub Management (Doormile staff only)
These endpoints return 403 for partner accounts (`is_doormile_staff = false`).
Use the `is_doormile_staff` flag from localStorage to show/hide the UI for these.
### GET /api/v1/hub/hubs
List all hubs in the logged-in staff's city. Now includes `lat`/`lon` for map pins.
Response:
```json
{
"success": true,
"data": [
{
"hubid": 1,
"hubname": "Coimbatore Jupiter Hub",
"hubtype": "sorting_center",
"capacity": 50,
"status": "active",
"has_staff": true,
"lat": 11.0168,
"lon": 76.9558
},
{
"hubid": 2,
"hubname": "Coimbatore Spoke 1",
"hubtype": "spoke",
"capacity": 50,
"status": "active",
"has_staff": false,
"lat": 11.0210,
"lon": 76.9610
}
]
}
```
Use `lat/lon` to place hub pins on the Live Map.
### POST /api/v1/hub/hubs
Create a new hub in the staff's city.
Request body:
```json
{
"hubname": "Coimbatore RS Puram Hub",
"hubtype": "spoke",
"capacity": 30,
"contact": "+91 98765 00000",
"address": "RS Puram, Coimbatore",
"pincode": "641002"
}
```
**Note:** `applocationid` (city) is set automatically from the logged-in staff's hub — cannot be overridden.
### POST /api/v1/hub/staff
Create a hub staff login for any hub in the city.
Request body:
```json
{
"hubid": 2,
"email": "hub.rspuram@doormile.in",
"password": "securepassword",
"displayname": "RS Puram Hub Staff",
"tenantid": null
}
```
Set `tenantid` to the partner's tenant ID for partner accounts, `null` for Doormile staff.
Response:
```json
{
"success": true,
"data": {
"staffid": 6,
"email": "hub.rspuram@doormile.in",
"hubid": 2
}
}
```
---
## Error response shape
All errors follow the same shape:
```json
{
"success": false,
"message": "human readable error description"
}
```
| HTTP code | Meaning |
|---|---|
| 400 | Bad request — invalid body or status transition |
| 401 | Token expired or missing → redirect to /login |
| 202 | Accepted — async assignment in progress |
| 403 | Forbidden — partner account trying Doormile-only endpoint |
| 404 | Resource not found (booking ID, miler ID, tracking no etc.) |
| 422 | Unprocessable — no eligible miler found in range |
| 500 | Server error — show generic "something went wrong" toast |
---
## Page-to-endpoint map (quick reference)
| Page | Endpoints called |
|---|---|
| Login | POST /hub/login |
| Dashboard | GET /hub/dashboard · GET /hub/inbound/vehicles · GET /hub/activity · GET /hub/zones |
| Receive Parcels | GET /hub/inbound/today · POST /hub/bookings/:id/inbound |
| Pickup Requests | GET /hub/bookings/unassigned · GET /hub/milers · POST /hub/bookings/:id/assign-miler · POST /hub/bookings/:id/auto-assign |
| Where Does It Go? (Routing) | GET /hub/routing/:trackingno |
| Dispatch | GET /hub/batches · POST /hub/batches · PATCH /hub/batches/:id/status |
| Milers | GET /hub/milers · POST /admin/milers · PATCH /admin/milers/:id · DELETE /admin/milers/:id |
| Rider Routes | GET /hub/rider-routes · GET /hub/milers/:id/route |
| Live Map | GET /admin/milers/locations (poll 5s) · GET /hub/tripsheets/in-transit (poll 5s) · GET /hub/hubs |
| Header — Notifications | GET /hub/notifications · PATCH /hub/notifications/:id/read |
| Hub Settings | GET /hub/hubs · POST /hub/hubs · POST /hub/staff |
---
## Frontend implementation notes
**Token storage:**
```js
// On login success:
localStorage.setItem('hub_token', response.token)
localStorage.setItem('hub_context', JSON.stringify(response.hub))
localStorage.setItem('hub_staff', JSON.stringify(response.staff))
localStorage.setItem('hub_is_doormile', String(response.is_doormile_staff))
// On every API call:
headers: { 'Authorization': `Bearer ${localStorage.getItem('hub_token')}` }
// On logout or 401:
localStorage.clear()
navigate('/login')
```
**Show hub name dynamically (not hardcoded):**
```js
const hub = JSON.parse(localStorage.getItem('hub_context'))
// hub.hubname → "Coimbatore Jupiter Hub"
// hub.city → "Coimbatore"
// hub.hubid → 1
```
**Hide hub management UI for partners:**
```js
const isDoormile = localStorage.getItem('hub_is_doormile') === 'true'
// Show "Add New Hub" / "Add Staff" buttons only if isDoormile === true
// KPM partner account gets false → those buttons hidden
```
**On 401 — token expired, force re-login:**
```js
if (response.status === 401) {
localStorage.clear()
window.location.href = '/login'
}
```
**Polling intervals:**
- Miler locations → every 5 seconds
- Truck positions → every 5 seconds (same call cycle)
- Dashboard stats → every 30 seconds
- Notifications → every 60 seconds
---
*All endpoints live at `https://api.doormile.com` · Hub Console Backend v1.0 complete*

View File

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

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

@@ -0,0 +1,106 @@
// ─────────────────────────────────────────────────────────────────────────────
// Low-level HTTP client for the Doormile Hub Console.
//
// Every authenticated call automatically attaches `Authorization: Bearer <token>`
// (read from localStorage) and `Content-Type: application/json`. A 401 clears the
// stored session and bounces the user to /login. Pages should never call fetch()
// directly — import the typed functions from `./hub` instead.
// ─────────────────────────────────────────────────────────────────────────────
import { getToken, clearSession } from '@/auth/session';
// In dev, default to same-origin ('') so requests hit `/api/...` and Vite proxies
// them to the backend (avoids the backend's localhost-only CORS allowlist).
// In production, default to the real API host. Either can be overridden with
// VITE_API_BASE_URL.
const BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.DEV ? '' : 'https://api.doormile.com');
// The internal assignment engine expects a shared key instead of a Bearer token.
const INTERNAL_KEY = import.meta.env.VITE_INTERNAL_KEY || 'doormile-internal-2024';
export class ApiError extends Error {
constructor(message, status, data) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
function redirectToLogin() {
clearSession();
if (window.location.pathname !== '/login') {
window.location.assign('/login');
}
}
/**
* Perform an API request.
* @param {string} path Path beginning with `/` (e.g. `/api/v1/hub/dashboard`).
* @param {object} options
* @param {string} [options.method='GET']
* @param {object} [options.body] Serialised to JSON when present.
* @param {boolean} [options.auth=true] Attach the Bearer token.
* @param {boolean} [options.internal=false] Send the X-Internal-Key header instead of Bearer.
*/
export async function request(path, { method = 'GET', body, auth = true, internal = false, headers = {} } = {}) {
const opts = { method, headers: { ...headers } };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
if (internal) {
opts.headers['X-Internal-Key'] = INTERNAL_KEY;
} else if (auth) {
const token = getToken();
if (token) opts.headers.Authorization = `Bearer ${token}`;
}
let res;
try {
res = await fetch(`${BASE_URL}${path}`, opts);
} catch {
throw new ApiError('Could not reach the server. Check your connection and try again.', 0);
}
// 401 → session is dead. Clear and send to login before anyone reads the body.
if (res.status === 401 && !internal) {
redirectToLogin();
throw new ApiError('Your session has expired. Please sign in again.', 401);
}
// Some endpoints (e.g. DELETE) may return an empty body.
let data = null;
const text = await res.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
if (!res.ok) {
const message =
(data && data.message) ||
(res.status === 403
? 'You do not have permission to do that.'
: res.status === 404
? 'The requested resource was not found.'
: res.status >= 500
? 'Something went wrong on our side. Please try again.'
: `Request failed (${res.status}).`);
throw new ApiError(message, res.status, data);
}
return data;
}
export const http = {
get: (path, options) => request(path, { ...options, method: 'GET' }),
post: (path, body, options) => request(path, { ...options, method: 'POST', body }),
patch: (path, body, options) => request(path, { ...options, method: 'PATCH', body }),
del: (path, options) => request(path, { ...options, method: 'DELETE' })
};

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

@@ -0,0 +1,147 @@
// ─────────────────────────────────────────────────────────────────────────────
// Doormile Hub Console — API surface.
// One function per backend endpoint. Pages import from here only; they never
// build URLs or call fetch() themselves. All functions return the parsed
// response body and throw an ApiError on failure.
// ─────────────────────────────────────────────────────────────────────────────
import { http } from './client';
const V1 = '/api/v1';
// ── 1. Authentication ────────────────────────────────────────────────────────
export function login(email, password) {
// No auth header on login.
return http.post(`${V1}/hub/login`, { email, password }, { auth: false });
}
// ── 2. Dashboard ─────────────────────────────────────────────────────────────
export function getDashboard() {
return http.get(`${V1}/hub/dashboard`);
}
// ── 3. Inbound — receive parcels ─────────────────────────────────────────────
export function getInboundToday() {
return http.get(`${V1}/hub/inbound/today`);
}
/**
* Scan a parcel in.
* @param {number|string} bookingId Booking / consignment ID (the path :id).
* @param {object} payload { tracking_id, condition, temperature, shelf, weight }
*/
export function inboundBooking(bookingId, payload) {
return http.post(`${V1}/hub/bookings/${bookingId}/inbound`, payload);
}
// ── 4. Order assignment — pickup requests ────────────────────────────────────
export function getUnassignedBookings() {
return http.get(`${V1}/hub/bookings/unassigned`);
}
/** Manual assign — hub staff picks the miler (hub-scoped, accepts the hub JWT). */
export function assignMiler(bookingId, mileruserid) {
return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid });
}
/** Auto-assign — the AI engine picks the miler. 200 ok · 422 no miler · 202 in progress. */
export function autoAssignBooking(bookingId) {
return http.post(`${V1}/hub/bookings/${bookingId}/auto-assign`, {});
}
// ── 5. Dispatch & batches ────────────────────────────────────────────────────
export function getBatches() {
return http.get(`${V1}/hub/batches`);
}
/**
* Create an outgoing batch.
* @param {object} payload { route, destination, vehicle, parcels_count, kind }
*/
export function createBatch(payload) {
return http.post(`${V1}/hub/batches`, payload);
}
/** Move a batch through Draft → Ready → Dispatched. */
export function updateBatchStatus(tripsheetId, status) {
return http.patch(`${V1}/hub/batches/${tripsheetId}/status`, { status });
}
// ── 6. Milers ────────────────────────────────────────────────────────────────
export function getMilers() {
return http.get(`${V1}/hub/milers`);
}
/** Onboard a miler. Hub JWT is accepted on the admin endpoint. */
export function createMiler(payload) {
return http.post(`${V1}/admin/milers`, payload);
}
export function updateMiler(userId, payload) {
return http.patch(`${V1}/admin/milers/${userId}`, payload);
}
export function deleteMiler(userId) {
return http.del(`${V1}/admin/milers/${userId}`);
}
// ── 7. Live map ──────────────────────────────────────────────────────────────
export function getMilerLocations() {
return http.get(`${V1}/admin/milers/locations`);
}
// ── 8. Hub management (Doormile staff only) ──────────────────────────────────
export function getHubs() {
return http.get(`${V1}/hub/hubs`);
}
export function createHub(payload) {
return http.post(`${V1}/hub/hubs`, payload);
}
/** Create a hub staff login. `tenantid` = partner tenant ID, or null for Doormile. */
export function createStaff(payload) {
return http.post(`${V1}/hub/staff`, payload);
}
// ── 9. Routing — Where Does It Go? ───────────────────────────────────────────
/** Scan a parcel and get its sort destination. 404 if the tracking number is unknown. */
export function getRouting(trackingno) {
return http.get(`${V1}/hub/routing/${encodeURIComponent(trackingno)}`);
}
// ── 10. Rider Routes ─────────────────────────────────────────────────────────
export function getRiderRoutes() {
return http.get(`${V1}/hub/rider-routes`);
}
export function getMilerRoute(milerUserId) {
return http.get(`${V1}/hub/milers/${milerUserId}/route`);
}
// ── 11. Live trucks (map) ────────────────────────────────────────────────────
export function getTripsheetsInTransit() {
return http.get(`${V1}/hub/tripsheets/in-transit`);
}
// ── 12. Dashboard panels ─────────────────────────────────────────────────────
export function getInboundVehicles() {
return http.get(`${V1}/hub/inbound/vehicles`);
}
export function getActivity(limit = 10) {
return http.get(`${V1}/hub/activity?limit=${limit}`);
}
export function getZones() {
return http.get(`${V1}/hub/zones`);
}
// ── 13. Notifications ────────────────────────────────────────────────────────
export function getNotifications() {
return http.get(`${V1}/hub/notifications`);
}
export function markNotificationRead(id) {
return http.patch(`${V1}/hub/notifications/${id}/read`, {});
}

View File

@@ -0,0 +1,14 @@
import { Navigate } from 'react-router-dom';
import { isAuthenticated, isDoormileStaff } from './session';
// Guards the authenticated app shell. Without a token → bounce to /login.
// `requireDoormile` additionally blocks partner accounts from Doormile-only pages.
export default function ProtectedRoute({ children, requireDoormile = false }) {
if (!isAuthenticated()) {
return <Navigate to="/login" replace />;
}
if (requireDoormile && !isDoormileStaff()) {
return <Navigate to="/dashboard" replace />;
}
return children;
}

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

@@ -0,0 +1,57 @@
// ─────────────────────────────────────────────────────────────────────────────
// Session storage helpers. The hub JWT + context live in localStorage so every
// page can read the hub name / staff / role without an extra network call.
// Keys are namespaced with `hub_` to match the backend integration guide.
// ─────────────────────────────────────────────────────────────────────────────
const KEYS = {
token: 'hub_token',
context: 'hub_context',
staff: 'hub_staff',
isDoormile: 'hub_is_doormile'
};
function readJson(key) {
const raw = localStorage.getItem(key);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/** Persist everything returned by POST /api/v1/hub/login. */
export function setSession({ token, hub, staff, is_doormile_staff }) {
localStorage.setItem(KEYS.token, token || '');
localStorage.setItem(KEYS.context, JSON.stringify(hub || {}));
localStorage.setItem(KEYS.staff, JSON.stringify(staff || {}));
localStorage.setItem(KEYS.isDoormile, String(Boolean(is_doormile_staff)));
}
export function clearSession() {
Object.values(KEYS).forEach((k) => localStorage.removeItem(k));
}
export function getToken() {
return localStorage.getItem(KEYS.token);
}
/** The hub the logged-in staff belongs to: { hubid, hubname, hubtype, city, capacity }. */
export function getHubContext() {
return readJson(KEYS.context) || {};
}
/** The signed-in staff member: { displayname, email, role }. */
export function getStaff() {
return readJson(KEYS.staff) || {};
}
/** Doormile staff see hub-management UI; partner accounts do not. */
export function isDoormileStaff() {
return localStorage.getItem(KEYS.isDoormile) === 'true';
}
export function isAuthenticated() {
return Boolean(getToken());
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AppBar,
@@ -32,23 +32,36 @@ import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
import ChatIcon from '@mui/icons-material/Chat';
import LogoutIcon from '@mui/icons-material/Logout';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
import DoneAllIcon from '@mui/icons-material/DoneAll';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SendIcon from '@mui/icons-material/Send';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
import Logo from '@/components/Logo';
import { getStaff, getHubContext, clearSession } from '@/auth/session';
import { getNotifications, markNotificationRead } from '@/api/hub';
const RED = '#C01227';
const INITIAL_NOTIFICATIONS = [
{ id: 1, icon: LocalShippingOutlinedIcon, title: 'Linehaul MH-04-AX-8822 from Mumbai Hub arrived', time: '5 min ago', to: '/inbound', read: false },
{ id: 2, icon: QrCodeScannerIcon, title: 'Manifest #MNF-2940 sorted & sealed', time: '15 min ago', to: '/dispatch', read: false },
{ id: 3, icon: TwoWheelerIcon, title: 'Last-mile Miler Deepak went online', time: '30 min ago', to: '/dispatch', read: false },
{ id: 4, icon: AcUnitIcon, title: 'Cold Storage Zone C temperature stabilized at 4.2°C', time: '1 hr ago', to: '/inventory', read: true }
];
// Map a notification `type` to an icon component (real API sends type, not an icon).
const NOTIF_ICON = {
exception: WarningAmberIcon,
inbound: LocalShippingOutlinedIcon,
dispatch: LocalShippingOutlinedIcon,
warning: WarningAmberIcon,
alert: NotificationsActiveIcon
};
// Build initials from a display name — falls back to a sensible default.
const toInitials = (name) =>
(name || '')
.split(' ')
.filter(Boolean)
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase() || 'HB';
const MESSAGES = [
{ id: 1, name: 'Devendra (Gate Supervisor)', text: 'Jaipur vehicle is backing into Bay 4 now.', time: '3 min ago', initials: 'DS' },
@@ -56,57 +69,6 @@ const MESSAGES = [
{ id: 3, name: 'Neha (Last-mile Dispatcher)', text: 'We need 2 more milers for South Delhi route.', time: '45 min ago', initials: 'ND' }
];
const NOTIFICATION_DETAILS = {
1: {
title: 'Linehaul Arrival: Mumbai Hub',
desc: 'Linehaul vehicle MH-04-AX-8822 has checked in at gate terminal Bay 4. Manifest includes 480 mixed corporate shipments.',
stats: [
{ label: 'Carrier Plate', value: 'MH-04-AX-8822' },
{ label: 'Origin Node', value: 'Mumbai Hub (BOM-02)' },
{ label: 'Inbound Sorters', value: 'Allocated (Zone A & C)' },
{ label: 'Cold Chain items', value: '18 items binned' }
],
actionText: 'Go to Inbound Station',
to: '/inbound'
},
2: {
title: 'Sealed Outbound Manifest #MNF-2940',
desc: 'Sorting manifest #MNF-2940 has been closed, sealed, and audited by supervisor Suresh. Packages are loaded in transit cart.',
stats: [
{ label: 'Manifest ID', value: 'MNF-2940' },
{ label: 'Route Zone', value: 'West Delhi (Dwarka)' },
{ label: 'Packages Count', value: '42 items loaded' },
{ label: 'Seal ID Code', value: 'SEAL-DEL-98402' }
],
actionText: 'Go to Outbound Dispatch',
to: '/dispatch'
},
3: {
title: 'Miler Deepak Sharma Online',
desc: 'Last-mile EV miler Deepak Sharma logged into transit terminal app. Courier battery capacity 98% (Fully Charged).',
stats: [
{ label: 'Miler Name', value: 'Deepak Sharma' },
{ label: 'Vehicle Class', value: 'EV Two-wheeler' },
{ label: 'Capacity Limit', value: '35 kg' },
{ label: 'Assigned Route', value: 'South Delhi (Saket)' }
],
actionText: 'Go to Outbound Dispatch',
to: '/dispatch'
},
4: {
title: 'Cold Storage Compliance Logged',
desc: 'Sensor Zone C logged audit snapshot at 12:00 PM. Climate check parameters successfully verified at 4.2°C.',
stats: [
{ label: 'Chamber Area', value: 'Cold Zone Chamber C' },
{ label: 'Sensor Reading', value: '4.2°C (Target: 2-8°C)' },
{ label: 'Log ID', value: 'LOG-C-98248102' },
{ label: 'Status', value: 'Optimal Compliance' }
],
actionText: 'Go to Storage & Bins',
to: '/inventory'
}
};
const INITIAL_CHATS = {
1: {
name: 'Devendra (Gate Supervisor)',
@@ -139,6 +101,16 @@ const INITIAL_CHATS = {
export default function Header({ onToggle }) {
const navigate = useNavigate();
const staff = getStaff();
const hub = getHubContext();
const staffName = staff.displayname || 'Hub Staff';
const hubName = hub.hubname || 'Doormile Hub';
const handleLogout = () => {
clearSession();
navigate('/login');
};
const [account, setAccount] = useState(null);
const [notifAnchor, setNotifAnchor] = useState(null);
const [msgAnchor, setMsgAnchor] = useState(null);
@@ -149,22 +121,53 @@ export default function Header({ onToggle }) {
const [chats, setChats] = useState(INITIAL_CHATS);
const [typedMessage, setTypedMessage] = useState('');
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
const [notifications, setNotifications] = useState([]);
const [search, setSearch] = useState('');
const unread = notifications.filter((n) => !n.read).length;
const openNotif = (e) => setNotifAnchor(e.currentTarget);
const closeNotif = () => setNotifAnchor(null);
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
// Load real notifications from the API (map `type` → an icon component).
const loadNotifications = useCallback(async () => {
try {
const res = await getNotifications();
setNotifications(
(res?.data || []).map((n) => ({
id: n.id,
title: n.title,
time: n.time,
read: Boolean(n.read),
type: n.type,
icon: NOTIF_ICON[n.type] || NotificationsNoneIcon
}))
);
} catch {
// Non-fatal: leave the bell empty if it can't load.
setNotifications([]);
}
}, []);
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)));
closeNotif();
if (NOTIFICATION_DETAILS[n.id]) {
setSelectedNotif(NOTIFICATION_DETAILS[n.id]);
} else {
navigate(n.to);
try {
await markNotificationRead(n.id);
} catch {
/* best effort */
}
};
@@ -283,13 +286,13 @@ export default function Header({ onToggle }) {
'&:hover': { bgcolor: 'grey.100' }
}}
>
<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 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Rajesh Kumar
{staffName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Delhi Hub Manager
{hubName}
</Typography>
</Box>
</Box>
@@ -380,7 +383,7 @@ export default function Header({ onToggle }) {
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
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>
Logout
</MenuItem>

View File

@@ -19,6 +19,7 @@ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import navItems from '@/menu/navItems';
import Logo from '@/components/Logo';
import { isDoormileStaff } from '@/auth/session';
export const DRAWER_WIDTH = 240;
export const MINI_WIDTH = 72;
@@ -97,6 +98,17 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
const location = useLocation();
const navigate = useNavigate();
const expanded = open || isMobile;
const doormile = isDoormileStaff();
// Partner accounts don't see Doormile-only groups/items (e.g. Hub Settings).
const groups = useMemo(
() =>
navItems
.filter((g) => !g.doormileOnly || doormile)
.map((g) => ({ ...g, items: g.items.filter((i) => !i.doormileOnly || doormile) }))
.filter((g) => g.items.length > 0),
[doormile]
);
const isActive = (url) => !!(url && location.pathname.startsWith(url));
@@ -156,7 +168,7 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
}
}}
>
{navItems.map((grp) => (
{groups.map((grp) => (
<Box key={grp.group} sx={{ mt: 2.5 }}>
{expanded && (
<Typography

View File

@@ -3,9 +3,9 @@ import MapRoundedIcon from '@mui/icons-material/MapRounded';
import HailRoundedIcon from '@mui/icons-material/HailRounded';
import MoveToInboxRoundedIcon from '@mui/icons-material/MoveToInboxRounded';
import AltRouteRoundedIcon from '@mui/icons-material/AltRouteRounded';
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded';
import LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded';
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
// ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== //
// 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',
items: [
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon },
{ id: 'inventory', title: 'Storage Shelves', url: '/inventory', icon: WarehouseRoundedIcon }
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon }
]
},
{
@@ -50,6 +49,14 @@ const navItems = [
{ id: 'riders', title: 'Milers', url: '/riders', icon: DeliveryDiningRoundedIcon },
{ id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: AltRouteRoundedIcon }
]
},
{
group: 'Management',
// Doormile staff only — hidden for partner (restricted) accounts.
doormileOnly: true,
items: [
{ id: 'hub-settings', title: 'Hub Settings', url: '/hub-settings', icon: SettingsRoundedIcon, doormileOnly: true }
]
}
];

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
Grid,
Card,
@@ -20,7 +20,9 @@ import {
Avatar,
Button,
Divider,
Popover
Popover,
Alert,
Skeleton
} from '@mui/material';
import dayjs from 'dayjs';
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 DynamicFeedIcon from '@mui/icons-material/DynamicFeed';
import SpeedIcon from '@mui/icons-material/Speed';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
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 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 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
// selected date range; the rest are "right now" figures that stay as a live count.
const STAT_DEFS = [
{ label: 'Total Parcels', base: 3142, cumulative: true, icon: DynamicFeedIcon, color: '#1A73E8', sub: 'Handled in range' },
{ label: 'Picked Up Locally', base: 1482, cumulative: true, icon: LocalOfferIcon, color: '#1E8E3E', sub: 'Collected by milers' },
{ label: 'From Other Cities', base: 1660, cumulative: true, icon: LocalShippingIcon, color: '#1A73E8', sub: 'Arrived by truck' },
{ label: 'Ready for Delivery', base: 840, cumulative: false, icon: AssignmentIcon, color: '#00A854', sub: 'Sorted for local areas' },
{ label: 'Ready to Transfer', base: 1120, cumulative: false, icon: LocalShippingIcon, color: '#8E24AA', sub: 'Going to other cities' },
{ 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' }
// Live KPI cards — each maps to one field of GET /api/v1/hub/dashboard.
const KPI_DEFS = [
{ key: 'parcels_received_today', label: 'Total Parcels Received', icon: DynamicFeedIcon, color: '#1A73E8', sub: 'Received today' },
{ key: 'milers_available', label: 'Available Milers', icon: DeliveryDiningIcon, color: '#1E8E3E', sub: 'Free right now' },
{ key: 'milers_on_duty', label: 'Milers on Duty', icon: InfoOutlinedIcon, color: '#1A73E8', sub: 'Currently working' },
{ key: 'pending_pickups', label: 'Pending Pickups', icon: AssignmentIcon, color: '#F29900', sub: 'Awaiting a miler' },
{ key: 'batches_sent_today', label: 'Batches Sent Today', icon: LocalShippingIcon, color: '#8E24AA', sub: 'Dispatched today' },
{ key: 'exceptions', label: 'Needs Checking', icon: WarningAmberIcon, color: '#D93025', sub: 'Damaged or unclear' }
];
const DATE_FMT = 'YYYY-MM-DD';
@@ -175,6 +184,62 @@ export default function Dashboard() {
const [range, setRange] = useState({ from: weekAgo, to: today });
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.
const dayCount = useMemo(() => {
const from = dayjs(range.from);
@@ -191,39 +256,6 @@ export default function Dashboard() {
const isPreset = (days) =>
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 (
<Box>
@@ -238,13 +270,10 @@ export default function Dashboard() {
{/* Left Title Section */}
<Box sx={{ flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1E293B', letterSpacing: '-0.02em', mb: 0.5 }}>
Delhi Hub
{hub.hubname || 'Doormile Hub'}
</Typography>
<Typography variant="body2" sx={{ color: '#64748B', fontSize: '0.825rem' }}>
Showing hub activity for{' '}
<Box component="span" sx={{ fontWeight: 600, color: '#0F172A' }}>
{rangeLabel}
</Box>
Live hub snapshot{hub.city ? ` · ${hub.city}` : ''}
</Typography>
</Box>
@@ -260,6 +289,8 @@ export default function Dashboard() {
{/* Refresh Button */}
<IconButton
color="primary"
onClick={loadDashboard}
disabled={loading}
sx={{
border: '1px solid #E2E8F0',
width: 36,
@@ -270,7 +301,7 @@ export default function Dashboard() {
'&: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>
{/* Quick Presets */}
@@ -362,6 +393,13 @@ export default function Dashboard() {
</Stack>
</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 */}
<Box
sx={{
@@ -369,14 +407,13 @@ export default function Dashboard() {
gridTemplateColumns: {
xs: 'repeat(2, minmax(0, 1fr))',
sm: 'repeat(3, minmax(0, 1fr))',
md: 'repeat(4, minmax(0, 1fr))',
lg: 'repeat(5, minmax(0, 1fr))'
lg: 'repeat(6, minmax(0, 1fr))'
},
gap: { xs: 1.5, sm: 2, md: 3 },
mb: { xs: 3, md: 5 }
}}
>
{stats.map((s) => {
{kpiCards.map((s) => {
const Icon = s.icon;
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' }}>
@@ -389,9 +426,13 @@ export default function Dashboard() {
{s.label}
</Typography>
</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 }}>
{s.value}
</Typography>
)}
<Typography variant="caption" sx={{ display: 'block', color: '#6c757d', fontWeight: 500 }}>
{s.sub}
</Typography>

View File

@@ -11,7 +11,9 @@ import {
Button,
Checkbox,
FormControlLabel,
Link
Link,
Alert,
CircularProgress
} from '@mui/material';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
@@ -20,15 +22,37 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import Logo from '@/components/Logo';
import { login as loginRequest } from '@/api/hub';
import { setSession } from '@/auth/session';
export default function Login() {
const navigate = useNavigate();
const [show, setShow] = useState(false);
const [auth, setAuth] = useState('hub.delhi@doormile.in');
const [auth, setAuth] = useState('hub.coimbatore@doormile.in');
const [pwd, setPwd] = useState('password123');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSignIn = () => {
const handleSignIn = async () => {
if (loading) return;
setError('');
if (!auth.trim() || !pwd) {
setError('Enter your email and password.');
return;
}
setLoading(true);
try {
const data = await loginRequest(auth.trim(), pwd);
if (!data?.success || !data?.token) {
throw new Error(data?.message || 'Login failed.');
}
setSession(data);
navigate('/dashboard');
} catch (err) {
setError(err?.message || 'Unable to sign in. Please try again.');
} finally {
setLoading(false);
}
};
return (
@@ -68,7 +92,7 @@ export default function Login() {
<br /> handled with ease.
</Typography>
<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>
<Stack spacing={2.5}>
@@ -134,6 +158,7 @@ export default function Login() {
placeholder="Enter your email"
value={auth}
onChange={(e) => setAuth(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
/>
</Box>
@@ -147,6 +172,7 @@ export default function Login() {
placeholder="Enter your password"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -169,11 +195,19 @@ export default function Login() {
</Link>
</Stack>
{error && (
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
)}
<Button
fullWidth
size="large"
variant="contained"
onClick={handleSignIn}
disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
sx={{
bgcolor: '#C01227',
color: '#fff',
@@ -188,7 +222,7 @@ export default function Login() {
}
}}
>
Sign In
{loading ? 'Signing in…' : 'Sign In'}
</Button>
<Box sx={{ textAlign: 'center', mt: 1 }}>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
@@ -25,7 +25,8 @@ import {
Alert,
Avatar,
Divider,
useMediaQuery
useMediaQuery,
CircularProgress
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
@@ -38,12 +39,8 @@ import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
import PageHeader from '@/components/PageHeader';
const INITIAL_MANIFESTS = [
{ id: 'BATCH-9281', route: 'Transfer to Mumbai Hub', vehicle: 'Transfer Truck: DL-01-BZ-8055', packagesCount: 154, status: 'Preparing', time: 'Created 1 hr ago', origin: 'Delhi Hub', currentLoc: 'Delhi Hub (Dispatch Dock)', destination: 'Mumbai Hub', kind: 'transfer' },
{ id: 'BATCH-9282', route: 'Local Delivery: Dwarka', vehicle: 'Miler: Deepak Sharma (Two-wheeler)', packagesCount: 12, status: 'Sent', time: 'Sent out 20 min ago', origin: 'Delhi Hub', currentLoc: 'On the way', destination: 'Dwarka, Delhi', kind: 'local' },
{ id: 'BATCH-9283', route: 'Local Delivery: Saket', vehicle: 'Miler: Karthik S. (EV)', packagesCount: 5, status: 'Ready', time: 'Ready 45 min ago', origin: 'Delhi Hub', currentLoc: 'Delhi Hub (Dispatch Dock)', destination: 'Saket, Delhi', kind: 'local' }
];
import { getBatches, createBatch, updateBatchStatus } from '@/api/hub';
import { getHubContext } from '@/auth/session';
const STATUS_META = {
Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' },
@@ -51,12 +48,51 @@ const STATUS_META = {
Sent: { color: '#1E8E3E', bg: '#E6F4EA', label: 'Sent' }
};
// API batch status (Draft/Ready/Dispatched) → the label set this page renders.
const API_TO_UI_STATUS = { Draft: 'Preparing', Ready: 'Ready', Dispatched: 'Sent' };
const timeAgo = (iso, verb = 'Created') => {
if (!iso) return `${verb} recently`;
const then = new Date(iso).getTime();
if (Number.isNaN(then) || then < 1420070400000) return `${verb} recently`;
const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return `${verb} just now`;
if (mins < 60) return `${verb} ${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${verb} ${hrs} hr ago`;
return `${verb} ${Math.round(hrs / 24)} d ago`;
};
export default function Dispatch() {
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext();
const hubName = hub.hubname || 'this hub';
const [manifests, setManifests] = useState(INITIAL_MANIFESTS);
// Real backend: tripsheetno / route / destination / item_count / kind (no vehicle field).
const mapBatch = useCallback(
(b) => ({
tripsheetid: b.tripsheetid,
id: b.batchlabel || b.tripsheetno || `BATCH-${b.tripsheetid}`,
route: b.route || b.destinationlabel || '',
vehicle: b.vehicle || '—',
packagesCount: b.item_count ?? b.itemcount ?? 0,
status: API_TO_UI_STATUS[b.status] || 'Preparing',
time: b.dispatchtime ? timeAgo(b.dispatchtime, 'Sent out') : timeAgo(b.createdat, 'Created'),
origin: hubName,
currentLoc: b.status === 'Dispatched' ? 'On the way' : `${hubName} (Dispatch Dock)`,
destination: b.destination || b.destinationlabel || b.route || '—',
kind: b.kind || b.batchkind || 'transfer'
}),
[hubName]
);
const [manifests, setManifests] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [openModal, setOpenModal] = useState(false);
const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState(null);
// Create form state
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
@@ -67,59 +103,92 @@ export default function Dispatch() {
// Toast state
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const handleCreateManifest = (e) => {
const load = useCallback(async () => {
setLoading(true);
setLoadError('');
try {
const res = await getBatches();
setManifests((res?.data || []).map(mapBatch));
} catch (err) {
setLoadError(err?.message || 'Could not load batches.');
} finally {
setLoading(false);
}
}, [mapBatch]);
useEffect(() => {
load();
}, [load]);
const handleCreateManifest = async (e) => {
e.preventDefault();
if (!newVehicle) {
setToast({ open: true, msg: 'Please input Miler or Vehicle info', severity: 'warning' });
if (creating) return;
if (!newVehicle.trim() || !newDestination.trim()) {
setToast({ open: true, msg: 'Enter the destination and the miler / vehicle.', severity: 'warning' });
return;
}
const randomNum = Math.floor(1000 + Math.random() * 9000);
const newManifest = {
id: `BATCH-${randomNum}`,
const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local';
setCreating(true);
try {
const res = await createBatch({
route: newRoute,
vehicle: newVehicle,
packagesCount: parseInt(newPkgsCount, 10),
status: 'Preparing',
time: 'Created just now',
origin: 'Delhi Hub',
currentLoc: 'Delhi Hub (Dispatch Dock)',
destination: newDestination || newRoute
};
setManifests([newManifest, ...manifests]);
destination: newDestination.trim(),
vehicle: newVehicle.trim(),
parcels_count: parseInt(newPkgsCount, 10) || 0,
kind
});
const label = res?.data?.batchlabel || 'New batch';
setOpenModal(false);
setNewVehicle('');
setNewDestination('');
setToast({ open: true, msg: `Batch ${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) =>
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) => {
setManifests((prev) =>
prev.map((m) => (m.id === id ? { ...m, status: 'Sent', time: 'Sent out just now', currentLoc: 'On the way' } : m))
);
setToast({ open: true, msg: `Batch ${id} sent out! The miler/driver has been notified.`, severity: 'success' });
};
const handleSeal = (m) => advance(m, 'Ready', 'Ready', `Batch ${m.id} checked and ready to send.`);
const handleDispatch = (m) => advance(m, 'Dispatched', 'Sent', `Batch ${m.id} sent out! The miler/driver has been notified.`);
// Action button shown for each batch based on its status
const BatchAction = ({ m, fullWidth }) => {
const isBusy = busyId === m.tripsheetid;
if (m.status === 'Preparing') {
return (
<Button size="small" variant="outlined" color="info" fullWidth={fullWidth} onClick={() => handleSeal(m.id)} sx={{ borderRadius: 2, fontWeight: 700 }}>
<Button size="small" variant="outlined" color="info" fullWidth={fullWidth} disabled={isBusy} onClick={() => handleSeal(m)}
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : null} sx={{ borderRadius: 2, fontWeight: 700 }}>
Check &amp; Mark Ready
</Button>
);
}
if (m.status === 'Ready') {
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
</Button>
);
@@ -156,7 +225,7 @@ export default function Dispatch() {
<Card>
<CardHeader
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={
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}>
New Batch
@@ -165,7 +234,22 @@ export default function Dispatch() {
/>
<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 ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{manifests.map((m) => {
@@ -304,9 +388,10 @@ export default function Dispatch() {
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenModal(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreateManifest}>
Create Batch
<Button onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button>
<Button variant="contained" onClick={handleCreateManifest} disabled={creating}
startIcon={creating ? <CircularProgress size={16} color="inherit" /> : null}>
{creating ? 'Creating…' : 'Create Batch'}
</Button>
</DialogActions>
</Dialog>

View File

@@ -0,0 +1,314 @@
import { useState, useEffect, useCallback } from 'react';
import {
Box, Card, CardContent, CardHeader, Button, Stack, Typography, Divider, Avatar, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, useMediaQuery,
Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, InputAdornment,
IconButton, Snackbar, Alert, CircularProgress
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
import AddIcon from '@mui/icons-material/Add';
import PersonAddAlt1OutlinedIcon from '@mui/icons-material/PersonAddAlt1Outlined';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined';
import PageHeader from '@/components/PageHeader';
import { getHubs, createHub, createStaff } from '@/api/hub';
import { getHubContext } from '@/auth/session';
const BRAND = '#C01227';
const HUB_TYPES = [
{ value: 'sorting_center', label: 'Sorting Center' },
{ value: 'delivery_hub', label: 'Delivery Hub' },
{ value: 'spoke', label: 'Spoke' },
{ value: 'warehouse', label: 'Warehouse' }
];
const EMPTY_HUB = { hubname: '', hubtype: 'spoke', capacity: '30', contact: '', address: '', pincode: '' };
const EMPTY_STAFF = { hubid: '', email: '', password: '', displayname: '' };
const prettyType = (t) => HUB_TYPES.find((h) => h.value === t)?.label || t || '—';
export default function HubSettings() {
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext();
const [hubs, setHubs] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const [hubDialog, setHubDialog] = useState(false);
const [hubForm, setHubForm] = useState(EMPTY_HUB);
const [savingHub, setSavingHub] = useState(false);
const [staffDialog, setStaffDialog] = useState(false);
const [staffForm, setStaffForm] = useState(EMPTY_STAFF);
const [showPwd, setShowPwd] = useState(false);
const [savingStaff, setSavingStaff] = useState(false);
const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity });
const load = useCallback(async () => {
setLoading(true);
setLoadError('');
try {
const res = await getHubs();
setHubs(res?.data || []);
} catch (err) {
setLoadError(err?.message || 'Could not load hubs.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const openHubDialog = () => {
setHubForm(EMPTY_HUB);
setHubDialog(true);
};
const openStaffDialog = () => {
setStaffForm({ ...EMPTY_STAFF, hubid: hubs[0]?.hubid ?? '' });
setShowPwd(false);
setStaffDialog(true);
};
const submitHub = async () => {
if (savingHub) return;
if (!hubForm.hubname.trim()) {
notify('Enter a hub name.', 'warning');
return;
}
setSavingHub(true);
try {
await createHub({
hubname: hubForm.hubname.trim(),
hubtype: hubForm.hubtype,
capacity: parseInt(hubForm.capacity, 10) || 0,
contact: hubForm.contact.trim(),
address: hubForm.address.trim(),
pincode: hubForm.pincode.trim()
});
setHubDialog(false);
notify(`Hub "${hubForm.hubname.trim()}" created.`);
load();
} catch (err) {
notify(err?.message || 'Could not create the hub.', 'error');
} finally {
setSavingHub(false);
}
};
const submitStaff = async () => {
if (savingStaff) return;
if (!staffForm.hubid || !staffForm.email.trim() || !staffForm.password) {
notify('Hub, email and password are all required.', 'warning');
return;
}
setSavingStaff(true);
try {
await createStaff({
hubid: Number(staffForm.hubid),
email: staffForm.email.trim(),
password: staffForm.password,
displayname: staffForm.displayname.trim() || staffForm.email.trim(),
tenantid: null // null for Doormile staff; set a tenant ID for partner accounts
});
setStaffDialog(false);
notify(`Staff login created for ${staffForm.email.trim()}.`);
load();
} catch (err) {
notify(err?.message || 'Could not create the staff login.', 'error');
} finally {
setSavingStaff(false);
}
};
const StaffChip = ({ has }) =>
has ? (
<Chip size="small" icon={<CheckCircleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="Has login"
sx={{ fontWeight: 700, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />
) : (
<Chip size="small" icon={<HighlightOffOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="No login"
sx={{ fontWeight: 700, bgcolor: '#FEF7E0', color: '#B06000', '& .MuiChip-icon': { color: '#B06000' } }} />
);
return (
<Box>
<PageHeader
icon={SettingsRoundedIcon}
title="Hub Settings"
subtitle={`Create and manage hubs and staff logins${hub.city ? ` across ${hub.city}` : ''}. Doormile staff only.`}
/>
<Card>
<CardHeader
title={`Hubs in ${hub.city || 'your city'}`}
subheader="Every hub Doormile operates here"
avatar={<Avatar variant="rounded" sx={{ bgcolor: '#C0122710', color: BRAND, borderRadius: 2 }}><WarehouseOutlinedIcon /></Avatar>}
action={
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button variant="outlined" startIcon={<PersonAddAlt1OutlinedIcon />} onClick={openStaffDialog} disabled={hubs.length === 0}
sx={{ borderRadius: 2, fontWeight: 700 }}>
Add Staff
</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={openHubDialog}
sx={{ borderRadius: 2, fontWeight: 700, bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
Add New Hub
</Button>
</Stack>
}
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
/>
<Divider />
{loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
{loadError}
</Alert>
)}
{loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : hubs.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}>
<WarehouseOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No hubs yet. Add your first hub.</Typography>
</Box>
) : isMdDown ? (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{hubs.map((h) => (
<Card key={h.hubid} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
<Box>
<Typography sx={{ fontWeight: 800, color: '#1A1A2E' }}>{h.hubname}</Typography>
<Typography variant="caption" color="text.secondary">{prettyType(h.hubtype)} · Cap {h.capacity}</Typography>
</Box>
<StaffChip has={(h.has_staff ?? h.has_staff_account)} />
</Stack>
</CardContent>
</Card>
))}
</Box>
) : (
<TableContainer>
<Table sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
{['Hub', 'Type', 'Capacity', 'Status', 'Staff Login'].map((h) => (
<TableCell key={h} sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 1.75 }}>
{h}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{hubs.map((h) => (
<TableRow key={h.hubid} hover>
<TableCell sx={{ fontWeight: 700, color: '#1A1A2E' }}>{h.hubname}</TableCell>
<TableCell>{prettyType(h.hubtype)}</TableCell>
<TableCell>{h.capacity}</TableCell>
<TableCell>
<Chip size="small" label={h.status || 'active'}
sx={{ fontWeight: 700, textTransform: 'capitalize', bgcolor: '#E8F0FE', color: '#1A73E8' }} />
</TableCell>
<TableCell><StaffChip has={(h.has_staff ?? h.has_staff_account)} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
{/* Create Hub dialog */}
<Dialog open={hubDialog} onClose={() => setHubDialog(false)} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 700 }}>Add a New Hub</DialogTitle>
<DialogContent>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
The city is set automatically from your hub it cant be changed here.
</Typography>
<Stack spacing={2.5} sx={{ mt: 0.5 }}>
<TextField fullWidth label="Hub name" value={hubForm.hubname} required
onChange={(e) => setHubForm((f) => ({ ...f, hubname: e.target.value }))} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField select fullWidth label="Hub type" value={hubForm.hubtype}
onChange={(e) => setHubForm((f) => ({ ...f, hubtype: e.target.value }))}>
{HUB_TYPES.map((t) => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</TextField>
<TextField fullWidth type="number" label="Capacity" value={hubForm.capacity} inputProps={{ min: 0 }}
onChange={(e) => setHubForm((f) => ({ ...f, capacity: e.target.value }))} />
</Stack>
<TextField fullWidth label="Contact number" value={hubForm.contact}
onChange={(e) => setHubForm((f) => ({ ...f, contact: e.target.value }))} />
<TextField fullWidth label="Address" value={hubForm.address}
onChange={(e) => setHubForm((f) => ({ ...f, address: e.target.value }))} />
<TextField fullWidth label="Pincode" value={hubForm.pincode}
onChange={(e) => setHubForm((f) => ({ ...f, pincode: e.target.value }))} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setHubDialog(false)} disabled={savingHub}>Cancel</Button>
<Button variant="contained" onClick={submitHub} disabled={savingHub}
startIcon={savingHub ? <CircularProgress size={16} color="inherit" /> : null}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
{savingHub ? 'Creating…' : 'Create Hub'}
</Button>
</DialogActions>
</Dialog>
{/* Create Staff dialog */}
<Dialog open={staffDialog} onClose={() => setStaffDialog(false)} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 700 }}>Add a Hub Staff Login</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ mt: 1 }}>
<TextField select fullWidth label="Hub" value={staffForm.hubid} required
onChange={(e) => setStaffForm((f) => ({ ...f, hubid: e.target.value }))}>
{hubs.map((h) => <MenuItem key={h.hubid} value={h.hubid}>{h.hubname}</MenuItem>)}
</TextField>
<TextField fullWidth label="Display name" value={staffForm.displayname}
onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} />
<TextField fullWidth type="email" label="Email" value={staffForm.email} required
onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} />
<TextField fullWidth type={showPwd ? 'text' : 'password'} label="Password" value={staffForm.password} required
onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPwd((s) => !s)} edge="end" size="small">
{showPwd ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
</InputAdornment>
)
}} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button>
<Button variant="contained" onClick={submitStaff} disabled={savingStaff}
startIcon={savingStaff ? <CircularProgress size={16} color="inherit" /> : null}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
{savingStaff ? 'Creating…' : 'Create Login'}
</Button>
</DialogActions>
</Dialog>
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}>
{toast.msg}
</Alert>
</Snackbar>
</Box>
);
}

View File

@@ -1,9 +1,9 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
Box, Typography, Card, CardContent, Grid, TextField, Button, Stack,
MenuItem, Table, TableBody, TableCell, TableContainer, TableHead,
TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider,
useMediaQuery
useMediaQuery, CircularProgress
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined';
@@ -23,22 +23,50 @@ import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import PageHeader from '@/components/PageHeader';
import { getInboundToday, inboundBooking } from '@/api/hub';
import { getHubContext } from '@/auth/session';
const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone C (Cold Room)', 'Exception Area'];
const INITIAL_INBOUND = [
{ trackingId: 'DM-882201', sender: 'Acme Corp, Mumbai', origin: 'Mumbai Hub', currentLoc: 'Delhi Hub', destination: 'Dwarka, Sec 12, Delhi', weight: '2.4 kg', condition: 'Good', temp: 'N/A', shelf: 'Zone A (Shelf 1)', time: '10 min ago' },
{ trackingId: 'DM-882202', sender: 'Tech Ltd, Mumbai', origin: 'Mumbai Hub', currentLoc: 'Delhi Hub', destination: 'Saket, Block J, Delhi', weight: '1.2 kg', condition: 'Good', temp: '4.1°C', shelf: 'Zone C (Cold Room)', time: '12 min ago' },
{ trackingId: 'DM-882203', sender: 'Crafts India, Jaipur', origin: 'Jaipur Hub', currentLoc: 'Delhi Hub', destination: 'Mayur Vihar Ph 1, Delhi', weight: '8.5 kg', condition: 'Damaged Box', temp: 'N/A', shelf: 'Exception Area', time: '20 min ago' }
];
const ORIGINS = [
{ value: 'Mumbai Hub', label: 'Mumbai Hub (BOM-02)' },
{ value: 'Jaipur Hub', label: 'Jaipur Hub (JAI-08)' },
{ value: 'Bengaluru Hub', label: 'Bengaluru Hub (BLR-03)' },
{ value: 'Client Pickup', label: 'Direct Client Pickup (Delhi Local)' }
{ value: 'Client Pickup', label: 'Direct Client Pickup (Local)' }
];
// Turn an ISO timestamp into a friendly "10 min ago" label.
const timeAgo = (iso) => {
if (!iso) return 'Recently';
const then = new Date(iso).getTime();
if (Number.isNaN(then) || then < 1420070400000) return 'Recently'; // guard zero/0001 dates
const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
return `${Math.round(hrs / 24)} d ago`;
};
// Map an API inbound record onto the shape this page renders.
// Real backend uses consignmentid / trackingno / chargeableweight / deliverypincode.
const mapInbound = (row, hubName) => {
const w = row.chargeableweight ?? row.deadweight ?? row.weight;
return {
bookingid: row.consignmentid ?? row.bookingid,
trackingId: row.trackingno || row.trackingnumber || '—',
sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'),
origin: row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'),
currentLoc: hubName,
destination: row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—',
condition: row.condition || 'Good',
temp: row.temperature || 'N/A',
shelf: row.shelf || 'Zone A (Shelf 1)',
time: timeAgo(row.inboundedat || row.createdat)
};
};
const shelfStyle = (shelf) => {
if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' };
if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' };
@@ -49,7 +77,10 @@ const isGood = (c) => c === 'Good';
export default function Inbound() {
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext();
const hubName = hub.hubname || 'this hub';
const [bookingId, setBookingId] = useState('');
const [trackingId, setTrackingId] = useState('');
const [origin, setOrigin] = useState('Mumbai Hub');
const [customer, setCustomer] = useState('');
@@ -58,10 +89,32 @@ export default function Inbound() {
const [weight, setWeight] = useState('');
const [condition, setCondition] = useState('Good');
const [temp, setTemp] = useState('');
const [submitting, setSubmitting] = useState(false);
const [inboundLogs, setInboundLogs] = useState(INITIAL_INBOUND);
const [inboundLogs, setInboundLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const loadInbound = useCallback(async () => {
setLoading(true);
setLoadError('');
try {
const res = await getInboundToday();
setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName)));
} catch (err) {
setLoadError(err?.message || 'Could not load todays inbound parcels.');
} finally {
setLoading(false);
}
// hubName is derived from a stable localStorage read; safe to omit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadInbound();
}, [loadInbound]);
const stats = useMemo(() => {
const received = inboundLogs.length;
const exceptions = inboundLogs.filter(l => l.condition !== 'Good' || l.shelf === 'Exception Area').length;
@@ -73,6 +126,7 @@ export default function Inbound() {
const handleRandomScan = () => {
const randomNum = Math.floor(100000 + Math.random() * 900000);
setTrackingId(`DM-${randomNum}`);
setBookingId(String(Math.floor(1 + Math.random() * 200)));
const customers = ['Acme Electronics', 'Delhi Medicos', 'Urban Fashion', 'Astro Retail', 'Fresho Foods'];
const senderAddresses = ['Andheri East, Mumbai', 'Malviya Nagar, Jaipur', 'Koramangala, Bengaluru', 'Lajpat Nagar, Delhi'];
const destinations = ['Dwarka Sec 4, Delhi', 'Saket Marg, Delhi', 'Karol Bagh, Delhi', 'Connaught Place, Delhi', 'Vasant Kunj, Delhi'];
@@ -87,25 +141,58 @@ export default function Inbound() {
setOrigin(origins[Math.floor(Math.random() * origins.length)]);
};
const handleSubmit = (e) => {
// Local preview of the shelf the backend is likely to recommend (it decides for real).
const recommendShelf = () => {
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area';
if (temp !== 'N/A' && temp !== '') return 'Zone C (Cold Room)';
return SHELVES[0];
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!trackingId || !destination) {
setToast({ open: true, msg: 'Please scan or fill tracking details', severity: 'warning' });
if (submitting) return;
if (!bookingId.trim() || !trackingId.trim()) {
setToast({ open: true, msg: 'Enter the booking ID and tracking ID to scan a parcel in.', severity: 'warning' });
return;
}
let recommendedShelf = SHELVES[0];
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) recommendedShelf = 'Exception Area';
else if (temp !== 'N/A' && temp !== '') recommendedShelf = 'Zone C (Cold Room)';
else recommendedShelf = SHELVES[Math.floor(Math.random() * 3)];
const newLog = {
trackingId, sender: customer || 'Unknown Sender', origin, currentLoc: 'Delhi Hub',
destination, weight: weight || '1.0 kg', condition, temp: temp || 'N/A',
shelf: recommendedShelf, time: 'Just now'
};
setInboundLogs([newLog, ...inboundLogs]);
setToast({ open: true, msg: `${trackingId} received · routed to ${recommendedShelf}`, severity: 'success' });
setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
setSubmitting(true);
try {
const res = await inboundBooking(bookingId.trim(), {
tracking_id: trackingId.trim(),
condition,
temperature: temp || 'N/A',
shelf: recommendShelf(),
weight: weight || 'N/A'
});
const data = res?.data || {};
const shelf = data.recommended_shelf || recommendShelf();
// Optimistically prepend, then refresh from the server for the source of truth.
setInboundLogs((prev) => [
{
bookingid: data.bookingid ?? bookingId,
trackingId: data.trackingnumber || trackingId.trim(),
sender: customer || 'Unknown Sender',
origin,
currentLoc: hubName,
destination: destination || '—',
weight: weight || 'N/A',
condition,
temp: temp || 'N/A',
shelf,
time: 'Just now'
},
...prev
]);
setToast({ open: true, msg: `${data.trackingnumber || trackingId.trim()} received · routed to ${shelf}`, severity: 'success' });
setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
loadInbound();
} catch (err) {
setToast({ open: true, msg: err?.message || 'Could not scan this parcel in.', severity: 'error' });
} finally {
setSubmitting(false);
}
};
const fieldSx = { '& .MuiOutlinedInput-root': { borderRadius: 2 } };
@@ -195,7 +282,7 @@ export default function Inbound() {
</Typography>
<Typography variant="caption" color="text.secondary">
Record a parcel arriving at Delhi Hub
Record a parcel arriving at {hubName}
</Typography>
</Box>
</Stack>
@@ -204,6 +291,11 @@ export default function Inbound() {
<CardContent sx={{ pt: 3 }}>
<Box component="form" onSubmit={handleSubmit}>
<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}
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
InputProps={{
@@ -248,10 +340,11 @@ export default function Inbound() {
onChange={(e) => setTemp(e.target.value)} sx={fieldSx}
InputProps={{ startAdornment: <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,
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>
</Stack>
</Box>
@@ -279,8 +372,22 @@ export default function Inbound() {
</Box>
<Divider />
{/* ── MOBILE: card list ── */}
{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>
) : 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 }}>
{inboundLogs.map((log, i) => {
const ss = shelfStyle(log.shelf);

View File

@@ -1,285 +0,0 @@
import { useState } from 'react';
import {
Box,
Typography,
Card,
CardContent,
CardHeader,
Grid,
TextField,
Button,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
MenuItem,
Select,
FormControl,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Avatar,
Divider,
InputAdornment,
useMediaQuery
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import StorageIcon from '@mui/icons-material/Storage';
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
import OpenWithRoundedIcon from '@mui/icons-material/OpenWithRounded';
import PageHeader from '@/components/PageHeader';
const zoneColor = (zone) => {
if (zone === 'C') return { color: '#00838F', bg: '#E0F7FA' };
if (zone === 'D') return { color: '#D93025', bg: '#FCE8E6' };
if (zone === 'B') return { color: '#8E24AA', bg: '#F3E5F5' };
return { color: '#1A73E8', bg: '#E8F0FE' };
};
const onShelf = (s) => s === 'On Shelf';
const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone B (Shelf 2)', 'Zone C (Cold Room)', 'Exception Area'];
const INITIAL_INVENTORY = [
{ id: 'DM-882201', customer: 'Acme Corp', weight: '2.4 kg', shelf: 'Zone A (Shelf 1)', zone: 'A', status: 'On Shelf' },
{ id: 'DM-882202', customer: 'BioPharma India', weight: '1.2 kg', shelf: 'Zone C (Cold Room)', zone: 'C', status: 'On Shelf' },
{ id: 'DM-882203', customer: 'Rajesh Textiles', weight: '8.5 kg', shelf: 'Exception Area', zone: 'D', status: 'Under Review' },
{ id: 'DM-109241', customer: 'Urban Fashion', weight: '3.1 kg', shelf: 'Zone B (Shelf 1)', zone: 'B', status: 'On Shelf' },
{ id: 'DM-402941', customer: 'Astro Retail', weight: '0.5 kg', shelf: 'Zone A (Shelf 2)', zone: 'A', status: 'On Shelf' }
];
export default function Inventory() {
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const [inventory, setInventory] = useState(INITIAL_INVENTORY);
const [search, setSearch] = useState('');
// Relocation modal state
const [editItem, setEditItem] = useState(null);
const [newShelf, setNewShelf] = useState('');
const handleOpenRelocate = (item) => {
setEditItem(item);
setNewShelf(item.shelf);
};
const handleConfirmRelocate = () => {
if (!editItem) return;
// Determine new zone label
let zoneLabel = 'A';
if (newShelf.includes('Zone B')) zoneLabel = 'B';
else if (newShelf.includes('Zone C') || newShelf.includes('Cold')) zoneLabel = 'C';
else if (newShelf.includes('Exception')) zoneLabel = 'D';
setInventory((prev) =>
prev.map((item) =>
item.id === editItem.id ? { ...item, shelf: newShelf, zone: zoneLabel } : item
)
);
setEditItem(null);
};
// Filter inventory list
const filteredInventory = inventory.filter(
(item) =>
item.id.toLowerCase().includes(search.toLowerCase()) ||
item.customer.toLowerCase().includes(search.toLowerCase()) ||
item.shelf.toLowerCase().includes(search.toLowerCase())
);
return (
<Box>
<PageHeader
icon={StorageIcon}
title="Storage Shelves"
subtitle="See which shelf every parcel is sitting on, move parcels between shelves, and keep an eye on the cold room."
/>
{/* Environmental Sensors for Cold-Chain Zone C */}
<Grid container spacing={{ xs: 2, md: 3 }} sx={{ mb: 4 }}>
{[
{ label: 'Cold Room Temperature', value: '4.2°C', icon: AcUnitIcon, color: '#00838F', bg: '#E0F7FA', chip: 'All good · Within safe range', chipColor: 'success' },
{ label: 'Storage Space', value: '120 Shelves', icon: ViewModuleIcon, color: '#1A73E8', bg: '#E8F0FE', chip: '28% full', chipColor: 'primary' },
{ label: 'Needs Attention', value: '1 Parcel', icon: LocalOfferIcon, color: '#D93025', bg: '#FCE8E6', chip: 'Waiting to be checked', chipColor: 'error' },
].map((s, i) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
<Card elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1', height: '100%' }}>
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
<Stack direction="row" alignItems="center" spacing={2.5} sx={{ mb: 2 }}>
<Avatar variant="rounded" sx={{ bgcolor: s.bg, color: s.color, borderRadius: 2.5, width: 48, height: 48, flexShrink: 0 }}>
<s.icon sx={{ fontSize: 24 }} />
</Avatar>
<Typography sx={{ fontSize: '0.8rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', lineHeight: 1.4 }}>
{s.label}
</Typography>
</Stack>
<Typography sx={{ fontSize: '2rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, mb: 1 }}>{s.value}</Typography>
<Chip size="small" label={s.chip} color={s.chipColor} sx={{ fontWeight: 600 }} />
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Main Inventory Board */}
<Card>
<CardHeader
title="What's On Our Shelves"
subheader="Find a parcel and move it to a different shelf if needed"
action={
<TextField
size="small"
placeholder="Search parcel or name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ width: { xs: 160, sm: 220, md: 280 }, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
InputProps={{ startAdornment: <InputAdornment position="start"><SearchOutlinedIcon sx={{ fontSize: 20, color: '#9AA0A6' }} /></InputAdornment> }}
/>
}
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
/>
<Divider />
{isMdDown ? (
/* ── MOBILE / TABLET: cards ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{filteredInventory.map((item) => {
const zc = zoneColor(item.zone);
return (
<Card key={item.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}>
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }} gap={1}>
<Stack direction="row" alignItems="center" gap={1.5} sx={{ minWidth: 0 }}>
<Avatar variant="rounded" sx={{ bgcolor: zc.bg, color: zc.color, borderRadius: 2, width: 40, height: 40 }}>
<WarehouseOutlinedIcon sx={{ fontSize: 21 }} />
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{item.id}</Typography>
<Typography variant="caption" color="text.secondary" noWrap>{item.customer}</Typography>
</Box>
</Stack>
<Chip size="small" label={item.status} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: onShelf(item.status) ? '#E6F4EA' : '#FEF7E0', color: onShelf(item.status) ? '#1E8E3E' : '#B06000' }} />
</Stack>
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}>
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.shelf}
sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, '& .MuiChip-icon': { color: zc.color } }} />
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.weight}
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
</Stack>
<Button fullWidth size="small" variant="outlined" startIcon={<OpenWithRoundedIcon sx={{ fontSize: 16 }} />}
onClick={() => handleOpenRelocate(item)} sx={{ borderRadius: 2, fontWeight: 700 }}>
Move to another shelf
</Button>
</CardContent>
</Card>
);
})}
{filteredInventory.length === 0 && (
<Box sx={{ py: 5, textAlign: 'center', color: 'text.secondary' }}>No parcels found. Try a different search.</Box>
)}
</Box>
) : (
/* ── DESKTOP: spacious table ── */
<TableContainer sx={{ overflowX: 'auto' }}>
<Table sx={{ minWidth: 760 }}>
<TableHead>
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
{['Parcel', 'On Shelf', 'Weight', 'Status', 'Action'].map((h, i) => (
<TableCell key={h} align={i === 4 ? 'right' : 'left'}
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
{h}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{filteredInventory.map((item) => {
const zc = zoneColor(item.zone);
return (
<TableRow key={item.id} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8', py: 2.25 }, '&:last-child td': { border: 0 } }}>
<TableCell>
<Stack direction="row" alignItems="center" gap={3.5}>
<Avatar variant="rounded" sx={{ bgcolor: zc.bg, color: zc.color, borderRadius: 2, width: 40, height: 40 }}>
<WarehouseOutlinedIcon sx={{ fontSize: 21 }} />
</Avatar>
<Box>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{item.id}</Typography>
<Typography variant="caption" color="text.secondary">{item.customer}</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={item.shelf}
sx={{ bgcolor: zc.bg, color: zc.color, fontWeight: 700, whiteSpace: 'nowrap', '& .MuiChip-icon': { color: zc.color } }} />
</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#495057', whiteSpace: 'nowrap' }}>{item.weight}</TableCell>
<TableCell>
<Chip size="small" label={item.status}
sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: onShelf(item.status) ? '#E6F4EA' : '#FEF7E0', color: onShelf(item.status) ? '#1E8E3E' : '#B06000' }} />
</TableCell>
<TableCell align="right">
<Button size="small" variant="outlined" startIcon={<OpenWithRoundedIcon sx={{ fontSize: 16 }} />}
onClick={() => handleOpenRelocate(item)} sx={{ borderRadius: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
Move
</Button>
</TableCell>
</TableRow>
);
})}
{filteredInventory.length === 0 && (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 5, color: 'text.secondary' }}>
No parcels found. Try a different search.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
{/* Relocate Dialog */}
<Dialog open={Boolean(editItem)} onClose={() => setEditItem(null)} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 700 }}>Move Parcel {editItem?.id}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography variant="body2" color="text.secondary">
Choose a new shelf for <strong>{editItem?.id}</strong> (from <strong>{editItem?.customer}</strong>).
</Typography>
<FormControl fullWidth>
<Select value={newShelf} onChange={(e) => setNewShelf(e.target.value)}>
{SHELVES.map((shelf) => (
<MenuItem key={shelf} value={shelf}>{shelf}</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditItem(null)}>Cancel</Button>
<Button variant="contained" onClick={handleConfirmRelocate}>
Move Parcel
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
@@ -27,7 +27,10 @@ import {
Radio,
Badge,
Checkbox,
useMediaQuery
useMediaQuery,
CircularProgress,
Alert,
Snackbar
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
@@ -38,26 +41,64 @@ import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PageHeader from '@/components/PageHeader';
import { getUnassignedBookings, getMilers, assignMiler, autoAssignBooking } from '@/api/hub';
import { getHubContext } from '@/auth/session';
const UNASSIGNED_ORDERS = [
{ id: 'PICK-991', customer: 'Ramesh K.', pickup: 'Dwarka Sec 12, Delhi', drop: 'Mumbai', package: 'Small Box, 2kg', time: '10 mins ago', status: 'Pending Assignment' },
{ id: 'PICK-992', customer: 'Anita P.', pickup: 'Saket, Delhi', drop: 'Hyderabad', package: 'Document, 0.5kg', time: '15 mins ago', status: 'Pending Assignment' },
{ id: 'PICK-993', customer: 'Suresh V.', pickup: 'Rohini, Delhi', drop: 'Bengaluru', package: 'Large Box, 12kg', time: '1 hour ago', status: 'Pending Assignment' }
];
const PENDING = 'Pending Assignment';
const AVAILABLE_MILERS = [
{ id: 'M-101', name: 'Deepak Sharma', vehicle: 'Two-Wheeler', area: 'Dwarka', distance: '1.2 km away' },
{ id: 'M-102', name: 'Karthik S.', vehicle: 'EV-Rickshaw', area: 'Saket', distance: '2.5 km away' },
{ id: 'M-103', name: 'Sanjay R.', vehicle: 'Mini Truck', area: 'Rohini', distance: '0.8 km away' }
];
const timeAgo = (iso) => {
if (!iso) return 'Recently';
const then = new Date(iso).getTime();
if (Number.isNaN(then) || then < 1420070400000) return 'Recently';
const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
return `${Math.round(hrs / 24)} d ago`;
};
// Real backend: snake_case fields + a parcels[] array (no packagedescription).
const mapOrder = (b) => {
const parcels = b.parcels || [];
const totalWeight = parcels.reduce((s, p) => s + (p.weight || 0), 0);
const pkg = parcels.length
? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}`
: b.packagedescription || '—';
return {
id: b.bookingid,
customer: b.customer_name || b.customerName || 'Customer',
pickup: b.pickup_address || b.pickupaddress || '—',
drop: b.delivery_address || b.deliveryaddress || '—',
package: pkg,
time: timeAgo(b.created_at || b.createdat),
status: PENDING
};
};
const mapMiler = (m) => ({
id: m.userid,
name: m.displayname || `Miler ${m.userid}`,
status: m.availabilitystatus || 'Available',
rating: m.rating,
completed: m.totalcompletedpickups ?? m.completedorders
});
export default function OrderAssignment() {
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext();
const [orders, setOrders] = useState([]);
const [milers, setMilers] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const [orders, setOrders] = useState(UNASSIGNED_ORDERS);
const [selectedOrders, setSelectedOrders] = useState([]);
// Single Assign Dialog State
@@ -65,9 +106,32 @@ export default function OrderAssignment() {
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [selectedMiler, setSelectedMiler] = useState('');
const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity });
const load = useCallback(async () => {
setLoading(true);
setLoadError('');
try {
const [bookings, milerRes] = await Promise.all([getUnassignedBookings(), getMilers().catch(() => null)]);
setOrders((bookings?.data || []).map(mapOrder));
if (milerRes?.data) setMilers(milerRes.data.map(mapMiler));
} catch (err) {
setLoadError(err?.message || 'Could not load pickup requests.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
// Milers that can take a new pickup right now.
const availableMilers = milers.filter((m) => ['Available', 'Assigned'].includes(m.status));
const handleSelectAll = (event) => {
if (event.target.checked) {
const pendingIds = orders.filter(o => o.status === 'Pending Assignment').map(o => o.id);
const pendingIds = orders.filter(o => o.status === PENDING).map(o => o.id);
setSelectedOrders(pendingIds);
} else {
setSelectedOrders([]);
@@ -88,39 +152,65 @@ export default function OrderAssignment() {
setAssignDialogOpen(true);
};
const handleAssign = () => {
if (!selectedMiler) return;
const miler = AVAILABLE_MILERS.find(m => m.id === selectedMiler);
setOrders(orders.map(o => {
if (o.id === selectedOrderForAssign.id) {
return { ...o, status: `Assigned to ${miler.name}`, assignedMiler: miler };
}
return o;
}));
// Remove from selection if it was selected
setSelectedOrders(prev => prev.filter(id => id !== selectedOrderForAssign.id));
// Assign a specific miler to a single booking.
const handleAssign = async () => {
if (!selectedMiler || busy) return;
const miler = availableMilers.find((m) => m.id === selectedMiler);
const order = selectedOrderForAssign;
setBusy(true);
try {
const res = await assignMiler(order.id, selectedMiler);
const name = res?.data?.milername || miler?.name || 'miler';
setOrders((prev) => prev.map((o) => (o.id === order.id ? { ...o, status: `Assigned to ${name}` } : o)));
setSelectedOrders((prev) => prev.filter((id) => id !== order.id));
setAssignDialogOpen(false);
};
const handleAutoAssignAll = () => {
if (selectedOrders.length === 0) return;
setOrders(orders.map(o => {
if (selectedOrders.includes(o.id) && o.status === 'Pending Assignment') {
// Just pick a random miler for auto-assign simulation
const randomMiler = AVAILABLE_MILERS[Math.floor(Math.random() * AVAILABLE_MILERS.length)];
return { ...o, status: `Assigned to ${randomMiler.name}`, assignedMiler: randomMiler };
notify(`Pickup #${order.id} assigned to ${name}.`);
} catch (err) {
notify(err?.message || 'Could not assign this miler.', 'error');
} finally {
setBusy(false);
}
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;
return (
@@ -134,15 +224,15 @@ export default function OrderAssignment() {
<Card>
<CardHeader
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>}
action={
<Button
variant={selectedOrders.length === 0 ? 'outlined' : 'contained'}
color="primary"
startIcon={<AutoAwesomeIcon />}
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={handleAutoAssignAll}
disabled={selectedOrders.length === 0}
disabled={selectedOrders.length === 0 || busy}
sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }}
>
{selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`}
@@ -152,7 +242,22 @@ export default function OrderAssignment() {
/>
<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 ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{orders.map((row) => {
@@ -276,11 +381,16 @@ export default function OrderAssignment() {
</Card>
<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>
<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>
{AVAILABLE_MILERS.map((miler) => (
{availableMilers.map((miler) => (
<ListItemButton
key={miler.id}
onClick={() => setSelectedMiler(miler.id)}
@@ -299,19 +409,41 @@ export default function OrderAssignment() {
</ListItemAvatar>
<ListItemText
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 }}
/>
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} />
</ListItemButton>
))}
</List>
)}
</DialogContent>
<DialogActions>
<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>
</Dialog>
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}>
{toast.msg}
</Alert>
</Snackbar>
</Box>
);
}

View File

@@ -9,6 +9,9 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip,
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { getRiderRoutes } from '@/api/hub';
import { getHubContext } from '@/auth/session';
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import PauseRoundedIcon from '@mui/icons-material/PauseRounded';
@@ -130,94 +133,53 @@ const moverIcon = (color) => new L.DivIcon({
//
// Delivery: hub → customer drops. Pickup: merchant collections → hub.
// ════════════════════════════════════════════════════════════════════════════════
const HUB = { lat: 28.6139, lng: 77.2090, label: 'Delhi Operations Hub' };
const hubStop = (time) => ({ kind: 'hub', label: HUB.label, lat: HUB.lat, lng: HUB.lng, time });
// The hub all pickup legs return to. Label comes from the logged-in hub context;
// the coordinate is only the initial map centre (the map auto-fits to the stops).
const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' };
const RIDERS = [
{
id: 'RDR-8012', name: 'Muthu Kumar', color: '#1A73E8', vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234', phone: '+91 98765 43210',
delivery: {
startTime: '08:12', endTime: '13:40', distanceKm: 18.4,
stops: [
hubStop('08:12'),
{ kind: 'order', orderId: 'ORD-100231', customer: 'Aarav Mehta', phone: '+91 90011 22334', address: 'Flat 402, Dwarka Sector 12, New Delhi', lat: 28.5921, lng: 77.0460, time: '08:48', status: 'Picked', items: 2, weight: '1.4 kg', slot: '08:0010:00', cod: 0, payment: 'Prepaid', legKm: 5.2, instructions: 'Leave at reception if not home.' },
{ kind: 'order', orderId: 'ORD-100244', customer: 'Priya Nair', phone: '+91 90022 33445', address: 'House 18, Janakpuri B-Block, New Delhi', lat: 28.6219, lng: 77.0878, time: '09:36', status: 'Picked', items: 1, weight: '0.6 kg', slot: '09:0011:00', cod: 1200, payment: 'COP', legKm: 4.1, instructions: 'Call on arrival.' },
{ kind: 'order', orderId: 'ORD-100258', customer: 'Rohit Sethi', phone: '+91 90033 44556', address: 'Shop 7, Rajouri Garden Market, New Delhi', lat: 28.6492, lng: 77.1207, time: '10:25', status: 'Failed', items: 3, weight: '2.8 kg', slot: '10:0012:00', cod: 0, payment: 'Prepaid', legKm: 4.6, instructions: 'Customer unreachable — reattempt tomorrow.' },
{ kind: 'order', orderId: 'ORD-100269', customer: 'Sana Kapoor', phone: '+91 90044 55667', address: 'A-22, Karol Bagh, New Delhi', lat: 28.6512, lng: 77.1907, time: '11:30', status: 'Picked', items: 1, weight: '0.9 kg', slot: '11:0013:00', cod: 850, payment: 'COP', legKm: 4.5, instructions: '' },
],
},
// Palette assigned to milers round-robin so each route line is a distinct colour.
const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
// Map an API rider-route (from GET /hub/rider-routes) into the structure this
// page renders: a `pickup` trip with a list of order stops. Fields the API does
// not provide (customer, weight, COD, slot, instructions) default gracefully.
const mapRoute = (r, i) => {
const apiStops = Array.isArray(r.stops) ? r.stops : [];
const stops = apiStops.map((s) => ({
kind: 'order',
orderId: s.bookingid != null ? `BK-${s.bookingid}` : `Stop ${s.seq}`,
bookingid: s.bookingid,
customer: s.customer || '',
phone: s.phone || '',
address: s.address || '—',
lat: s.lat,
lng: s.lon,
time: s.eta_minutes != null ? `${s.eta_minutes} min` : '',
status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending',
items: s.items ?? 0,
weight: '',
slot: '',
cod: 0,
payment: '',
legKm: 0,
instructions: ''
}));
return {
id: `RDR-${r.mileruserid}`,
mileruserid: r.mileruserid,
name: r.milername || `Miler ${r.mileruserid}`,
color: ROUTE_COLORS[i % ROUTE_COLORS.length],
vehicle: '—',
vehicleNo: '—',
phone: '',
pickup: {
startTime: '14:10', endTime: '16:50', distanceKm: 11.2,
stops: [
{ kind: 'order', orderId: 'PCK-50118', customer: 'TrendKart Store', phone: '+91 98801 11222', address: 'Tilak Nagar Main Rd, New Delhi', lat: 28.6363, lng: 77.0945, time: '14:35', status: 'Picked', items: 6, weight: '4.2 kg', slot: '14:0016:00', cod: 0, payment: 'Merchant', legKm: 6.0, instructions: 'Collect from back gate.' },
{ kind: 'order', orderId: 'PCK-50126', customer: 'FreshLeaf Organics', phone: '+91 98802 22333', address: 'Subhash Nagar, New Delhi', lat: 28.6404, lng: 77.1199, time: '15:30', status: 'Picked', items: 3, weight: '5.5 kg', slot: '15:0017:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' },
{ kind: 'order', orderId: 'PCK-50131', customer: 'GadgetHub', phone: '+91 98803 33444', address: 'Moti Nagar, New Delhi', lat: 28.6580, lng: 77.1450, time: '16:15', status: 'Missed', items: 2, weight: '1.1 kg', slot: '16:0018:00', cod: 0, payment: 'Merchant', legKm: 2.2, instructions: 'Shop was closed at pickup time.' },
hubStop('16:50'),
],
},
},
{
id: 'RDR-8041', name: 'Sana Sheikh', color: '#8E24AA', vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199', phone: '+91 98765 43214',
delivery: {
startTime: '08:00', endTime: '14:05', distanceKm: 22.9,
stops: [
hubStop('08:00'),
{ kind: 'order', orderId: 'ORD-100277', customer: 'Imran Qureshi', phone: '+91 90055 66778', address: 'N-Block, Connaught Place, New Delhi', lat: 28.6315, lng: 77.2167, time: '08:30', status: 'Picked', items: 1, weight: '0.4 kg', slot: '08:0010:00', cod: 0, payment: 'Prepaid', legKm: 2.1, instructions: '' },
{ kind: 'order', orderId: 'ORD-100283', customer: 'Neha Gupta', phone: '+91 90066 77889', address: 'C-44, Lajpat Nagar II, New Delhi', lat: 28.5677, lng: 77.2433, time: '09:20', status: 'Picked', items: 2, weight: '1.7 kg', slot: '09:0011:00', cod: 1450, payment: 'COP', legKm: 7.4, instructions: 'Ring twice.' },
{ kind: 'order', orderId: 'ORD-100291', customer: 'Vikas Rao', phone: '+91 90077 88990', address: 'J-Block, Saket, New Delhi', lat: 28.5245, lng: 77.2066, time: '10:40', status: 'Picked', items: 1, weight: '0.8 kg', slot: '10:0012:00', cod: 0, payment: 'Prepaid', legKm: 6.2, instructions: '' },
{ kind: 'order', orderId: 'ORD-100305', customer: 'Diya Shah', phone: '+91 90088 99001', address: 'Malviya Nagar Main Market, New Delhi', lat: 28.5355, lng: 77.2110, time: '11:55', status: 'Picked', items: 4, weight: '3.1 kg', slot: '11:0013:00', cod: 2300, payment: 'COP', legKm: 1.4, instructions: 'Heavy parcel — handle with care.' },
],
},
pickup: {
startTime: '14:30', endTime: '17:20', distanceKm: 14.6,
stops: [
{ kind: 'order', orderId: 'PCK-50140', customer: 'Bloom & Co Florists', phone: '+91 98804 44555', address: 'Greater Kailash I, New Delhi', lat: 28.5494, lng: 77.2426, time: '15:00', status: 'Picked', items: 4, weight: '2.0 kg', slot: '14:3016:30', cod: 0, payment: 'Merchant', legKm: 8.1, instructions: '' },
{ kind: 'order', orderId: 'PCK-50147', customer: 'BookNook', phone: '+91 98805 55666', address: 'Hauz Khas Village, New Delhi', lat: 28.5535, lng: 77.1944, time: '16:05', status: 'Picked', items: 9, weight: '7.8 kg', slot: '15:3017:30', cod: 0, payment: 'Merchant', legKm: 4.5, instructions: 'Multiple boxes.' },
hubStop('17:20'),
],
},
},
{
id: 'RDR-8015', name: 'Rajesh Sharma', color: '#1E8E3E', vehicle: 'Cargo Van', vehicleNo: 'DL-01-CV-9876', phone: '+91 98765 43211',
delivery: {
startTime: '07:50', endTime: '15:10', distanceKm: 31.2,
stops: [
hubStop('07:50'),
{ kind: 'order', orderId: 'ORD-100312', customer: 'Anil Verma', phone: '+91 90099 00112', address: 'Sector 7, Rohini, New Delhi', lat: 28.7042, lng: 77.1025, time: '08:55', status: 'Picked', items: 5, weight: '6.2 kg', slot: '08:0010:00', cod: 0, payment: 'Prepaid', legKm: 12.4, instructions: '' },
{ kind: 'order', orderId: 'ORD-100320', customer: 'Meera Iyer', phone: '+91 90100 11223', address: 'NSP, Pitampura, New Delhi', lat: 28.6996, lng: 77.1314, time: '09:50', status: 'Picked', items: 2, weight: '2.0 kg', slot: '09:0011:00', cod: 990, payment: 'COP', legKm: 3.2, instructions: '' },
{ kind: 'order', orderId: 'ORD-100334', customer: 'Sahil Khan', phone: '+91 90111 22334', address: 'Model Town III, New Delhi', lat: 28.7158, lng: 77.1910, time: '11:10', status: 'Failed', items: 1, weight: '0.7 kg', slot: '10:0012:00', cod: 0, payment: 'Prepaid', legKm: 6.1, instructions: 'Wrong address provided.' },
{ kind: 'order', orderId: 'ORD-100349', customer: 'Tara Bose', phone: '+91 90122 33445', address: 'Civil Lines, New Delhi', lat: 28.6796, lng: 77.2240, time: '12:30', status: 'Picked', items: 3, weight: '4.4 kg', slot: '12:0014:00', cod: 3100, payment: 'COP', legKm: 5.0, instructions: '' },
],
},
pickup: {
startTime: '15:40', endTime: '18:30', distanceKm: 19.8,
stops: [
{ kind: 'order', orderId: 'PCK-50155', customer: 'MegaMart Warehouse', phone: '+91 98806 66777', address: 'Wazirpur Industrial Area, New Delhi', lat: 28.6991, lng: 77.1612, time: '16:20', status: 'Picked', items: 24, weight: '38.0 kg', slot: '16:0018:00', cod: 0, payment: 'Merchant', legKm: 11.0, instructions: 'Use loading dock 4.' },
{ kind: 'order', orderId: 'PCK-50163', customer: 'HomeStyle Furnishings', phone: '+91 98807 77888', address: 'Ashok Vihar, New Delhi', lat: 28.6924, lng: 77.1760, time: '17:25', status: 'Picked', items: 8, weight: '22.5 kg', slot: '17:0019:00', cod: 0, payment: 'Merchant', legKm: 3.0, instructions: '' },
hubStop('18:30'),
],
},
},
{
id: 'RDR-8044', name: 'Harpreet Gill', color: '#E8710A', vehicle: 'Cycle', vehicleNo: '—', phone: '+91 98765 43215',
delivery: {
startTime: '09:20', endTime: '13:15', distanceKm: 9.7,
stops: [
hubStop('09:20'),
{ kind: 'order', orderId: 'ORD-100356', customer: 'Kabir Anand', phone: '+91 90133 44556', address: 'Mayur Vihar Phase I, New Delhi', lat: 28.6090, lng: 77.2920, time: '10:05', status: 'Picked', items: 1, weight: '0.5 kg', slot: '10:0012:00', cod: 600, payment: 'COP', legKm: 9.0, instructions: '' },
{ kind: 'order', orderId: 'ORD-100361', customer: 'Ritu Saxena', phone: '+91 90144 55667', address: 'Mayur Vihar Phase III, New Delhi', lat: 28.6135, lng: 77.3215, time: '11:10', status: 'Picked', items: 2, weight: '1.2 kg', slot: '11:0013:00', cod: 0, payment: 'Prepaid', legKm: 3.0, instructions: '' },
{ kind: 'order', orderId: 'ORD-100370', customer: 'Farhan Ali', phone: '+91 90155 66778', address: 'Patparganj, New Delhi', lat: 28.6280, lng: 77.2960, time: '12:20', status: 'Picked', items: 1, weight: '0.8 kg', slot: '12:0014:00', cod: 450, payment: 'COP', legKm: 2.9, instructions: '' },
],
},
pickup: {
startTime: '13:40', endTime: '15:30', distanceKm: 6.4,
stops: [
{ kind: 'order', orderId: 'PCK-50170', customer: 'Cafe Mosaic', phone: '+91 98808 88999', address: 'Mayur Vihar Phase I Market, New Delhi', lat: 28.6055, lng: 77.2985, time: '14:10', status: 'Picked', items: 2, weight: '1.5 kg', slot: '14:0016:00', cod: 0, payment: 'Merchant', legKm: 4.0, instructions: '' },
hubStop('15:30'),
],
},
},
];
startTime: '',
endTime: '',
distanceKm: r.totaldistance_km || 0,
stops
}
};
};
// ── helpers ────────────────────────────────────────────────────────────────────
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
@@ -227,6 +189,8 @@ const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng +
const STATUS_META = {
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' },
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' },
'In progress': { color: '#F29900', icon: DeliveryDiningRoundedIcon, label: 'In progress' },
Pending: { color: '#80868B', icon: ScheduleOutlinedIcon, label: 'Pending' },
};
const MODES = {
@@ -395,9 +359,11 @@ function OrderDetailPanel({ data, onBack }) {
// ════════════════════════════════════════════════════════════════════════════════
export default function RiderRoutes() {
const [mode] = useState('pickup'); // pickups only
const [visible, setVisible] = useState(() => Object.fromEntries(RIDERS.map((r) => [r.id, true])));
const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes
const [loading, setLoading] = useState(true);
const [visible, setVisible] = useState({});
const [routes, setRoutes] = useState({}); // keyed `${id}__${mode}`
const [expanded, setExpanded] = useState(RIDERS[0].id);
const [expanded, setExpanded] = useState(null);
const [focusedStop, setFocusedStop] = useState(null); // `${riderId}-${index}`
const [detail, setDetail] = useState(null); // { order, rider, mode, index }
const [flyTarget, setFlyTarget] = useState(null);
@@ -413,13 +379,29 @@ export default function RiderRoutes() {
const modeCfg = MODES[mode];
// Load today's rider routes for this hub.
useEffect(() => {
let cancelled = false;
getRiderRoutes()
.then((res) => {
if (cancelled) return;
const mapped = (res?.data || []).map(mapRoute);
setRiders(mapped);
setVisible(Object.fromEntries(mapped.map((r) => [r.id, true])));
setExpanded(mapped[0]?.id ?? null);
})
.catch(() => !cancelled && setRiders([]))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, []);
// Resolve road routes for the active mode (cached per id+mode). A ref guards
// against re-fetching keys we've already resolved when the tab is revisited.
useEffect(() => {
let cancelled = false;
(async () => {
await Promise.all(
RIDERS.map(async (r) => {
riders.map(async (r) => {
const key = `${r.id}__${mode}`;
if (resolvedRef.current[key]) return;
resolvedRef.current[key] = true;
@@ -432,7 +414,7 @@ export default function RiderRoutes() {
);
})();
return () => { cancelled = true; };
}, [mode]);
}, [mode, riders]);
const pathFor = useCallback(
(r) => routes[`${r.id}__${mode}`] || r[mode].stops.map((s) => ({ lat: s.lat, lng: s.lng })),
@@ -444,14 +426,14 @@ export default function RiderRoutes() {
// Auto-fit points = visible riders' stops for the active mode.
const fitPoints = useMemo(() => {
const pts = [];
RIDERS.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); });
riders.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); });
return pts;
}, [visible, stopsOf]);
}, [riders, visible, stopsOf]);
// ── Analysis KPIs for the active mode ─────────────────────────────────────────
const kpi = useMemo(() => {
let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0;
RIDERS.forEach((r) => {
riders.forEach((r) => {
const trip = r[mode];
const orderStops = trip.stops.filter((s) => s.kind === 'order');
if (orderStops.length) activeRiders += 1;
@@ -462,7 +444,7 @@ export default function RiderRoutes() {
cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0);
});
return { orders, done, fail, km: km.toFixed(1), cod, activeRiders };
}, [mode, modeCfg]);
}, [riders, mode, modeCfg]);
// ── Animation driver ──────────────────────────────────────────────────────────
useEffect(() => {
@@ -506,7 +488,8 @@ export default function RiderRoutes() {
const playState = useMemo(() => {
if (!playing) return null;
const rider = RIDERS.find((r) => r.id === playing);
const rider = riders.find((r) => r.id === playing);
if (!rider) return null;
const path = pathFor(rider);
if (path.length < 2) return null;
const totalSegs = path.length - 1;
@@ -516,7 +499,7 @@ export default function RiderRoutes() {
const pos = lerpPoint(path[i], path[i + 1], frac);
const travelled = [...path.slice(0, i + 1), pos];
return { rider, path, travelled, pos };
}, [playing, progress, pathFor]);
}, [playing, progress, pathFor, riders]);
return (
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}>
@@ -536,10 +519,20 @@ export default function RiderRoutes() {
</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 ── */}
<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: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' },
{ icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
@@ -561,7 +554,7 @@ export default function RiderRoutes() {
</Box>
<List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}>
{RIDERS.map((rider) => {
{riders.map((rider) => {
const trip = rider[mode];
const orders = trip.stops.filter((s) => s.kind === 'order');
const done = orders.filter((o) => o.status === modeCfg.doneStatus).length;
@@ -749,7 +742,7 @@ export default function RiderRoutes() {
<Popup><b>{HUB.label}</b><br />Pickups return here</Popup>
</Marker>
{RIDERS.map((rider) => {
{riders.map((rider) => {
if (!visible[rider.id]) return null;
const path = pathFor(rider);
const stops = stopsOf(rider);
@@ -759,7 +752,7 @@ export default function RiderRoutes() {
<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 }} />
{stops.map((s, i) => {
if (s.kind === 'hub') return null;
if (s.kind === 'hub' || s.lat == null || s.lng == null) return null;
const key = `${rider.id}-${i}`;
return (
<Marker
@@ -779,7 +772,7 @@ export default function RiderRoutes() {
</Marker>
);
})}
{!dimmed && (
{!dimmed && endStop && (
<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>
</Marker>
@@ -801,7 +794,7 @@ export default function RiderRoutes() {
{/* Legend */}
<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)}>
<Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography>

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import {
Box, Typography, Card, CardContent, Avatar, Chip, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, IconButton, Button,
@@ -6,7 +6,7 @@ import {
TextField, MenuItem, Divider, InputAdornment, Menu, ListItemIcon,
Snackbar, Alert, useMediaQuery, Grid, Select, FormControl, InputLabel,
Autocomplete, Switch, FormControlLabel, Paper, ToggleButtonGroup,
ToggleButton, Stepper, Step, StepLabel
ToggleButton, Stepper, Step, StepLabel, CircularProgress
} from '@mui/material';
import { useTheme, alpha } from '@mui/material/styles';
@@ -48,6 +48,20 @@ import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub';
import { getHubContext } from '@/auth/session';
// The backend uses snake-case availability values; this page uses friendlier labels.
const API_TO_UI_STATUS = { Available: 'Idle', Assigned: 'On Pickup', On_Break: 'On Break', Offline: 'Offline' };
const UI_TO_API_STATUS = {
Idle: 'Available',
'On Pickup': 'Assigned',
'On Break': 'On_Break',
'Returning to Hub': 'Assigned',
Offline: 'Offline',
Suspended: 'Offline'
};
// ── Reference data ──────────────────────────────────────────────────────────────
const VEHICLES = {
'Electric Bike': { capacity: 30, icon: ElectricBikeOutlinedIcon },
@@ -74,74 +88,49 @@ const STATUS_META = {
let idSeq = 8060;
const genId = () => `RDR-${idSeq++}`;
// ── Seed roster ────────────────────────────────────────────────────────────────
const SEED = [
{
id: 'RDR-8012', name: 'Muthu Kumar', phone: '+91 98765 43210', hub: 'Delhi Operations Hub',
zones: ['Dwarka', 'Janakpuri'], vehicle: 'Electric Bike', vehicleNo: 'DL-04-EB-1234',
status: 'On Pickup', checkInTime: '08:12', hoursToday: 6.4,
assigned: 24, capacity: 30, pickupsPending: 3, deliveriesPending: 21,
deliveriesDone: 45, deliveriesFailed: 2, codCollected: 4500, codPending: 1200,
rating: 4.8, verified: true,
},
{
id: 'RDR-8015', name: 'Rajesh Sharma', phone: '+91 98765 43211', hub: 'Delhi Operations Hub',
zones: ['Saket', 'Malviya Nagar'], vehicle: 'Cargo Van', vehicleNo: 'DL-01-CV-9876',
status: 'On Pickup', checkInTime: '07:50', hoursToday: 6.8,
assigned: 88, capacity: 120, pickupsPending: 12, deliveriesPending: 76,
deliveriesDone: 110, deliveriesFailed: 4, codCollected: 12000, codPending: 3400,
rating: 4.9, verified: true,
},
{
id: 'RDR-8022', name: 'Vikram Singh', phone: '+91 98765 43212', hub: 'Delhi Operations Hub',
zones: ['Rohini'], vehicle: 'Motorcycle', vehicleNo: 'DL-08-MC-4567',
status: 'Idle', checkInTime: '09:05', hoursToday: 5.2,
assigned: 0, capacity: 25, pickupsPending: 0, deliveriesPending: 0,
deliveriesDone: 68, deliveriesFailed: 1, codCollected: 0, codPending: 0,
rating: 4.5, verified: false,
},
// ... (rest of seed data remains the same)
{
id: 'RDR-8030', name: 'Amit Patel', phone: '+91 98765 43213', hub: 'Delhi Operations Hub',
zones: ['Vasant Kunj'], vehicle: 'Electric Bike', vehicleNo: 'DL-03-EB-7654',
status: 'Returning to Hub', checkInTime: '08:30', hoursToday: 7.1,
assigned: 2, capacity: 30, pickupsPending: 0, deliveriesPending: 2,
deliveriesDone: 55, deliveriesFailed: 6, codCollected: 8500, codPending: 0,
rating: 4.2, verified: true,
},
{
id: 'RDR-8041', name: 'Sana Sheikh', phone: '+91 98765 43214', hub: 'Delhi Operations Hub',
zones: ['Central Delhi', 'Karol Bagh'], vehicle: 'Motorcycle', vehicleNo: 'DL-02-MC-1199',
status: 'On Pickup', checkInTime: '08:00', hoursToday: 6.6,
assigned: 19, capacity: 25, pickupsPending: 2, deliveriesPending: 17,
deliveriesDone: 73, deliveriesFailed: 0, codCollected: 6200, codPending: 900,
rating: 5.0, verified: true,
},
{
id: 'RDR-8044', name: 'Harpreet Gill', phone: '+91 98765 43215', hub: 'Delhi Operations Hub',
zones: ['Mayur Vihar'], vehicle: 'Cycle', vehicleNo: '—',
status: 'On Break', checkInTime: '09:20', hoursToday: 3.9,
assigned: 8, capacity: 15, pickupsPending: 1, deliveriesPending: 7,
deliveriesDone: 22, deliveriesFailed: 1, codCollected: 1500, codPending: 300,
rating: 4.6, verified: true,
},
{
id: 'RDR-8050', name: 'Deepak Yadav', phone: '+91 98765 43216', hub: 'Delhi Operations Hub',
zones: ['Lajpat Nagar'], vehicle: 'Mini Truck', vehicleNo: 'DL-09-MT-3321',
status: 'Offline', checkInTime: '—', hoursToday: 0,
assigned: 0, capacity: 200, pickupsPending: 0, deliveriesPending: 0,
deliveriesDone: 0, deliveriesFailed: 0, codCollected: 0, codPending: 0,
rating: 4.4, verified: false,
},
{
id: 'RDR-8055', name: 'Iqbal Ahmed', phone: '+91 98765 43217', hub: 'Delhi Operations Hub',
zones: ['Connaught Place'], vehicle: 'Electric Bike', vehicleNo: 'DL-05-EB-8080',
status: 'Suspended', checkInTime: '—', hoursToday: 0,
assigned: 0, capacity: 30, pickupsPending: 0, deliveriesPending: 0,
deliveriesDone: 0, deliveriesFailed: 9, codCollected: 0, codPending: 5600,
rating: 3.1, verified: true,
},
];
// Turn a vehicle type name into the 1-based id the backend uses (best effort).
const vehicleIdFor = (vehicle) => Math.max(1, VEHICLE_TYPES.indexOf(vehicle) + 1);
// Map a raw API miler onto the rich shape this page renders. Fields the API does
// not provide (zones, COD, live load) default to empty/zero so the UI still works.
// Format an ISO check-in timestamp as HH:MM (or "—").
const checkInLabel = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const mapMiler = (m, hubName) => {
// Real backend exposes defaultvehicletype (e.g. "Bike"); fall back to id lookup.
const vehicle = m.defaultvehicletype || VEHICLE_TYPES[(m.vehicleid || 1) - 1] || 'Motorcycle';
const capacity = m.capacity || VEHICLES[vehicle]?.capacity || 0;
return {
id: m.userid,
userid: m.userid,
vehicleid: m.vehicleid,
hubid: m.hubid,
name: m.displayname || `Miler ${m.userid}`,
phone: m.phone || '—',
hub: hubName,
zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [],
vehicle,
vehicleNo: m.vehicleid ? `VEH-${m.vehicleid}` : '—',
status: API_TO_UI_STATUS[m.availabilitystatus] || 'Idle',
checkInTime: checkInLabel(m.checkinat),
hoursToday: m.hoursactive ?? 0,
assigned: m.assignedload ?? 0,
capacity,
pickupsPending: m.pickupspending ?? 0,
deliveriesPending: m.assignedload ?? 0,
deliveriesDone: m.totalcompletedpickups ?? m.completedorders ?? 0,
deliveriesFailed: m.totalcancelledpickups ?? m.cancelledorders ?? 0,
codCollected: m.codcollected ?? 0,
codPending: m.codpending ?? 0,
rating: m.rating ?? 0,
verified: Boolean(m.isverified ?? m.device_token)
};
};
// ── Helpers ─────────────────────────────────────────────────────────────
const successRate = (r) => {
@@ -938,12 +927,35 @@ export default function Riders() {
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const isLgDown = useMediaQuery(theme.breakpoints.down('lg'));
const [riders, setRiders] = useState(SEED);
const hub = getHubContext();
const hubName = hub.hubname || 'This Hub';
const [riders, setRiders] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const [vehicleFilter, setVehicleFilter] = useState('All');
const [view, setView] = useState('table');
const loadMilers = useCallback(async () => {
setLoading(true);
setLoadError('');
try {
const res = await getMilers();
setRiders((res?.data || []).map((m) => mapMiler(m, hubName)));
} catch (err) {
setLoadError(err?.message || 'Could not load milers.');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadMilers();
}, [loadMilers]);
const [formDialog, setFormDialog] = useState({ open: false, mode: 'add', initial: null });
const [profile, setProfile] = useState(null);
const [profileEdit, setProfileEdit] = useState(false);
@@ -979,22 +991,43 @@ export default function Riders() {
});
}, [riders, search, statusFilter, vehicleFilter]);
const handleSave = (rider) => {
// 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') {
setRiders(p => [rider, ...p]);
await createMiler(toApiPayload(rider));
toast(`${rider.name} onboarded successfully`);
} else {
setRiders(p => p.map(r => r.id === rider.id ? rider : r));
await updateMiler(rider.userid ?? rider.id, toApiPayload(rider));
toast(`${rider.name} updated`);
}
setFormDialog({ open: false, mode: 'add', initial: null });
loadMilers();
} catch (err) {
toast(err?.message || 'Could not save this miler.', 'error');
}
};
const handleDelete = () => {
setRiders(p => p.filter(r => r.id !== deleteTarget.id));
toast(`${deleteTarget.name} removed`, 'info');
const handleDelete = async () => {
const target = deleteTarget;
try {
await deleteMiler(target.userid ?? target.id);
setRiders((p) => p.filter((r) => r.id !== target.id));
toast(`${target.name} removed`, 'info');
} catch (err) {
toast(err?.message || 'Could not remove this miler.', 'error');
} finally {
setDeleteTarget(null);
setProfile(null);
}
};
const openAdd = () => setFormDialog({ open: true, mode: 'add', initial: null });
@@ -1009,11 +1042,16 @@ export default function Riders() {
setProfile(r);
setProfileEdit(false);
};
const saveProfileEdit = (updated) => {
setRiders(p => p.map(r => (r.id === updated.id ? updated : r)));
const saveProfileEdit = async (updated) => {
try {
await updateMiler(updated.userid ?? updated.id, toApiPayload(updated));
setRiders((p) => p.map((r) => (r.id === updated.id ? updated : r)));
setProfile(updated);
setProfileEdit(false);
toast(`${updated.name} updated`);
} catch (err) {
toast(err?.message || 'Could not update this miler.', 'error');
}
};
const openMenu = (e, r) => {
setMenuAnchor(e.currentTarget);
@@ -1253,7 +1291,16 @@ export default function Riders() {
</Box>
{/* 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' }}>
<SearchOutlinedIcon sx={{ fontSize: 80, color: '#E0E0E0', mb: 3 }} />
<Typography variant="h5" fontWeight={600}>No milers match your filters</Typography>

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Box, Typography, Card, CardContent, CardHeader, Grid, TextField, Button,
Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip
Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip, CircularProgress
} from '@mui/material';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
@@ -10,76 +10,55 @@ import HelpIcon from '@mui/icons-material/Help';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import HubIcon from '@mui/icons-material/Hub';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
// Mock DB with Advanced Logistics Scenarios
const HUB_NAME = 'Delhi Hub (DEL-01)';
const LOCAL_ZONES = ['Dwarka', 'Janakpuri', 'Saket', 'Malviya Nagar', 'Rohini', 'Vasant Kunj', 'Central Delhi'];
import { getRouting, getInboundToday } from '@/api/hub';
import { getHubContext } from '@/auth/session';
const PACKAGES_DB = {
'DM-1001': {
id: 'DM-1001',
source: 'Local Pickup',
origin: 'Customer - Dwarka',
destHub: 'Delhi Hub (DEL-01)',
destZone: 'Janakpuri',
weight: '1.2 kg',
status: 'Arrived at Hub'
},
'DM-1002': {
id: 'DM-1002',
source: 'Local Pickup',
origin: 'Merchant - Saket',
destHub: 'Mumbai Hub (BOM-02)',
destZone: 'Andheri West',
weight: '3.5 kg',
status: 'Arrived at Hub'
},
'DM-1003': {
id: 'DM-1003',
source: 'Arrived from another city',
origin: 'Bengaluru Hub (BLR-02)',
destHub: 'Delhi Hub (DEL-01)',
destZone: 'Rohini',
weight: '0.5 kg',
status: 'Arrived at Hub'
},
'DM-1004': {
id: 'DM-1004',
source: 'Local Pickup',
origin: 'Customer - Vasant Kunj',
destHub: 'Delhi Hub (DEL-01)',
destZone: 'Central Delhi',
weight: '2.0 kg',
status: 'Arrived at Hub'
},
'DM-1005': {
id: 'DM-1005',
source: 'Arrived from another city',
origin: 'Chennai Hub (MAA-05)',
destHub: 'Delhi Hub (DEL-01)',
destZone: 'Dwarka',
weight: '15.0 kg',
status: 'Damaged Packaging',
exception: 'Box crushed during transit.'
},
'DM-1006': {
id: 'DM-1006',
source: 'Arrived from another city',
origin: 'Pune Hub (PNQ-03)',
destHub: 'Pune Hub (PNQ-03)',
destZone: 'Koregaon Park',
weight: '1.0 kg',
status: 'RTS (Return to Sender)',
exception: 'Customer rejected delivery at destination.'
}
};
// A condition string that signals the parcel needs manual checking.
const isException = (condition) =>
/damag|wet|crush|missing|broken/i.test(condition || '');
export default function Routing() {
const [searchParams] = useSearchParams();
const hub = getHubContext();
const HUB_NAME = hub.hubname || 'This Hub';
const [searchId, setSearchId] = useState('');
const [matchedPkg, setMatchedPkg] = useState(null);
const [searched, setSearched] = useState(false);
const [loading, setLoading] = useState(false);
const [waiting, setWaiting] = useState([]);
// Populate "Parcels Waiting" from today's inbound parcels at this hub.
useEffect(() => {
getInboundToday()
.then((res) =>
setWaiting(
(res?.data || [])
.map((r) => ({ trackingno: r.trackingno || r.trackingnumber, dest: r.destinationname || r.deliverypincode || '—' }))
.filter((r) => r.trackingno)
)
)
.catch(() => setWaiting([]));
}, []);
const handleSearch = useCallback(async (idToSearch) => {
const id = (typeof idToSearch === 'string' ? idToSearch : searchId).trim();
if (!id) return;
setSearched(true);
setLoading(true);
try {
const res = await getRouting(id);
// Endpoint returns the routing object either at the root or under data.
setMatchedPkg(res?.data || res || null);
} catch {
setMatchedPkg(null); // 404 → not found state
} finally {
setLoading(false);
}
}, [searchId]);
useEffect(() => {
const query = searchParams.get('q');
@@ -87,58 +66,23 @@ export default function Routing() {
setSearchId(query);
handleSearch(query);
}
}, [searchParams]);
}, [searchParams, handleSearch]);
const handleSearch = (idToSearch) => {
const id = typeof idToSearch === 'string' ? idToSearch : searchId;
setSearched(true);
if (id && PACKAGES_DB[id]) {
setMatchedPkg(PACKAGES_DB[id]);
} else {
setMatchedPkg(null);
}
};
// Advanced Sorting Logic: Classify by Next Action
// Classify the next action from the API's nexthop + condition.
const determineNextAction = (pkg) => {
if (pkg.status.includes('Exception') || pkg.status === 'Damaged Packaging') {
return {
queue: 'Needs Checking',
action: 'Set aside for a supervisor',
color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon />
};
if (isException(pkg.condition)) {
return { queue: 'Needs Checking', action: 'Set aside for a supervisor', color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon /> };
}
if (pkg.status === 'RTS (Return to Sender)') {
return {
queue: 'Send Back',
action: 'Return to the sender',
color: '#F29900', bg: '#FEF7E0', icon: <LocalShippingIcon />
};
if (pkg.iscoldchain) {
return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', color: '#00838F', bg: '#E0F7FA', icon: <AcUnitIcon /> };
}
if (pkg.destHub !== HUB_NAME) {
return {
queue: 'Transfer to Another City',
action: `Send to ${pkg.destHub}`,
color: '#8E24AA', bg: '#F3E5F5', icon: <LocalShippingIcon />
};
if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) {
return { queue: 'Transfer to Another City', action: pkg.nexthop, color: '#8E24AA', bg: '#F3E5F5', icon: <LocalShippingIcon /> };
}
// If destHub IS this hub, it's local delivery
if (LOCAL_ZONES.includes(pkg.destZone)) {
// If it was picked up locally but is going to a different area, it's a cross-area delivery.
if (pkg.source === 'Local Pickup' && !pkg.origin.includes(pkg.destZone)) {
return {
queue: 'Local Delivery (Other Area)',
action: `Send to ${pkg.destZone}`,
color: '#1A73E8', bg: '#E8F0FE', icon: <LocalShippingIcon />
};
if ((pkg.nexthop || '').toLowerCase().includes('local')) {
return { queue: 'Local Delivery', action: `Send to ${pkg.destination || 'the delivery lane'}`, color: '#1E8E3E', bg: '#E6F4EA', icon: <HubIcon /> };
}
return {
queue: 'Local Delivery',
action: `Give to a ${pkg.destZone} miler`,
color: '#1E8E3E', bg: '#E6F4EA', icon: <HubIcon />
};
}
return { queue: 'Unknown', action: 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
};
return (
@@ -146,7 +90,7 @@ export default function Routing() {
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography>
<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>
</Box>
@@ -178,9 +122,11 @@ export default function Routing() {
size="large"
onClick={() => handleSearch()}
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' } }}
>
Tell Me What To Do
{loading ? 'Checking' : 'Tell Me What To Do'}
</Button>
</Stack>
</CardContent>
@@ -195,33 +141,35 @@ export default function Routing() {
<Divider />
<CardContent sx={{ pt: 2, p: 1 }}>
<List disablePadding>
{Object.keys(PACKAGES_DB).map((key) => {
const pkg = PACKAGES_DB[key];
return (
{waiting.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
No parcels inbounded today.
</Typography>
)}
{waiting.map((pkg) => (
<ListItemButton
key={key}
key={pkg.trackingno}
onClick={() => {
setSearchId(key);
handleSearch(key);
setSearchId(pkg.trackingno);
handleSearch(pkg.trackingno);
}}
selected={searchId === key}
selected={searchId === pkg.trackingno}
sx={{
borderRadius: 2, mb: 1, p: 2,
border: '1px solid',
borderColor: searchId === key ? '#C01227' : '#eaeaea',
bgcolor: searchId === key ? '#C0122708' : '#fff',
borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea',
bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff',
'&:hover': { bgcolor: '#f8f9fa' }
}}
>
<ListItemText
primary={key}
secondary={`${pkg.source} → ${pkg.destHub === HUB_NAME ? pkg.destZone : pkg.destHub}`}
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === key ? '#C01227' : '#212529' }}
primary={pkg.trackingno}
secondary={`Going to ${pkg.dest}`}
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === pkg.trackingno ? '#C01227' : '#212529' }}
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }}
/>
</ListItemButton>
);
})}
))}
</List>
</CardContent>
</Card>
@@ -245,12 +193,12 @@ export default function Routing() {
) : matchedPkg ? (
<Card sx={{ height: '100%', borderRadius: 2, border: '1px solid #eaeaea', boxShadow: '0px 4px 20px rgba(0,0,0,0.06)' }}>
<CardHeader
title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.id}</Typography>}
subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>Source: {matchedPkg.source}</Typography>}
title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.trackingno}</Typography>}
subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>{matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}</Typography>}
action={
<Chip
label={matchedPkg.status}
sx={{ fontWeight: 700, bgcolor: '#F1F3F5', color: '#495057', borderRadius: 2 }}
label={matchedPkg.condition || 'Good'}
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 }}
@@ -284,42 +232,49 @@ export default function Routing() {
justifyContent="space-between"
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 }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Right Now (Here)</Typography>
<Typography variant="body1" sx={{ fontWeight: 800, color: '#C01227', mt: 0.5 }}>{HUB_NAME}</Typography>
</Box>
<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 }}>
<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>
</Stack>
</Box>
{/* Handling Alerts */}
{matchedPkg.exception && (
{isException(matchedPkg.condition) && (
<Alert severity="error" variant="filled" sx={{ borderRadius: 2, mb: 2 }}>
<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>
)}
{determineNextAction(matchedPkg).queue === 'Transfer to Another City' && (
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #bae1ff', bgcolor: '#e6f2ff' }}>
<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>
)}
{determineNextAction(matchedPkg).queue === 'Local Delivery' && (
<Alert severity="success" sx={{ borderRadius: 2, border: '1px solid #c3e6cb', bgcolor: '#d4edda' }}>
<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>
)}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem,
ListItemAvatar, ListItemText, Badge, Divider
ListItemAvatar, ListItemText, Badge, Divider, Alert
} from '@mui/material';
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import HubIcon from '@mui/icons-material/Hub';
@@ -10,6 +10,9 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { getMilers, getMilerLocations, getHubs, getTripsheetsInTransit } from '@/api/hub';
import { getHubContext } from '@/auth/session';
// Keeps Leaflet's canvas sized correctly when the container resizes (sidebar
// toggle, window resize, first paint inside a flex box). Without this the map
// renders grey/blank tiles — the #1 reason a real map "doesn't show".
@@ -56,54 +59,131 @@ const createLinehaulIcon = () => new L.DivIcon({
iconAnchor: [17, 17],
});
// Mock Coordinates (Real Lat/Lng)
const HUBS = [
{ id: 'HB-DEL', name: 'Delhi Hub (DEL-01)', position: [28.6139, 77.2090], type: 'Main Hub' },
{ id: 'HB-BOM', name: 'Mumbai Hub (BOM-02)', position: [19.0760, 72.8777], type: 'City Hub' },
{ id: 'HB-BLR', name: 'Bengaluru Hub (BLR-03)', position: [12.9716, 77.5946], type: 'City Hub' }
];
const prettyHubType = (t) =>
({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub');
const INIT_RIDERS = [
{ id: 'R-102', name: 'Deepak Sharma', area: 'Dwarka, Delhi', status: 'Out for delivery', progress: 0.0, start: [28.5921, 77.0460], end: [28.6139, 77.2090], type: 'Miler' },
{ id: 'R-105', name: 'Karthik S.', area: 'Saket, Delhi', status: 'Picking up', progress: 0.3, start: [28.5245, 77.2066], end: [28.6139, 77.2090], type: 'Miler' }
];
const INIT_LINEHAUL = [
{ id: 'LH-DEL-BOM', name: 'Transfer: Delhi → Mumbai', status: 'On the way', progress: 0.1, start: [28.6139, 77.2090], end: [19.0760, 72.8777], type: 'Truck' }
];
// Colour-code a miler pin/list item by their live availability status.
const statusMeta = (status) => {
switch (status) {
case 'Assigned':
case 'On Pickup':
return { color: '#1A73E8', label: status };
case 'Available':
case 'Idle':
return { color: '#1E8E3E', label: status };
case 'On_Break':
case 'On Break':
return { color: '#8E24AA', label: 'On Break' };
case 'Offline':
return { color: '#80868B', label: 'Offline' };
default:
return { color: '#0070f3', label: status || 'Active' };
}
};
export default function TrackingMap() {
const [riders, setRiders] = useState(INIT_RIDERS);
const [linehauls, setLinehauls] = useState(INIT_LINEHAUL);
const hub = getHubContext();
const [milers, setMilers] = useState([]);
const [error, setError] = useState('');
const [hubs, setHubs] = useState([]);
const [linehauls, setLinehauls] = useState([]);
const didLoad = useRef(false);
// Smooth operational tracking loop iteration updates
// Hub pins are static per session — load once from /hub/hubs (has lat/lon).
useEffect(() => {
const interval = setInterval(() => {
setRiders((prev) =>
prev.map(r => ({
...r,
progress: r.progress >= 1 ? 0 : parseFloat((r.progress + 0.005).toFixed(3))
}))
);
setLinehauls((prev) =>
prev.map(l => ({
...l,
progress: l.progress >= 1 ? 0 : parseFloat((l.progress + 0.001).toFixed(3))
}))
);
}, 150);
return () => clearInterval(interval);
getHubs()
.then((res) =>
setHubs(
(res?.data || [])
.filter((h) => h.lat != null && h.lon != null)
.map((h) => ({ id: h.hubid, name: h.hubname, position: [h.lat, h.lon], type: prettyHubType(h.hubtype) }))
)
)
.catch(() => setHubs([]));
}, []);
const getInterpolatedPosition = (start, end, progress) => {
return [
start[0] + (end[0] - start[0]) * progress,
start[1] + (end[1] - start[1]) * progress
];
};
// Poll live miler GPS every 5 seconds. The guide's /admin/milers/locations
// (Redis GEO) returns nothing on this backend, so we read coordinates from
// /hub/milers (currentlatitude/currentlongitude) and fall back to the GEO
// endpoint if it ever starts returning data.
useEffect(() => {
let active = true;
const normalize = (list) =>
(list || [])
.map((m) => ({
userid: m.userid,
displayname: m.displayname,
lat: m.currentlatitude ?? m.lat,
lon: m.currentlongitude ?? m.lon,
status: m.availabilitystatus ?? m.status,
bookingid: m.currentbookingid ?? m.bookingid
}))
.filter((m) => m.lat != null && m.lon != null && !(m.lat === 0 && m.lon === 0));
const mapCenter = [22.0, 76.0]; // Centered across the Delhi → Mumbai / Bengaluru network
const poll = async () => {
try {
const [geo, roster] = await Promise.all([
getMilerLocations().catch(() => null),
getMilers().catch(() => null)
]);
if (!active) return;
const geoList = normalize(geo?.data);
setMilers(geoList.length ? geoList : normalize(roster?.data));
setError('');
} catch (err) {
if (active && !didLoad.current) setError(err?.message || 'Could not load live miler locations.');
} finally {
didLoad.current = true;
}
};
poll();
const interval = setInterval(poll, 5000);
return () => {
active = false;
clearInterval(interval);
};
}, []);
// Poll real transfer trucks in transit every 5 seconds.
useEffect(() => {
let active = true;
const poll = async () => {
try {
const res = await getTripsheetsInTransit();
if (!active) return;
setLinehauls(
(res?.data || [])
.filter((t) => t.currentlat != null && t.currentlon != null)
.map((t) => ({
id: t.tripsheetid,
name: t.label || t.tripsheetno || `Trip ${t.tripsheetid}`,
status: t.status || 'In transit',
progress: (t.progresspct ?? 0) / 100,
start: [t.originlat, t.originlon],
end: [t.destlat, t.destlon],
current: [t.currentlat, t.currentlon]
}))
);
} catch {
/* leave last-known trucks on transient failure */
}
};
poll();
const interval = setInterval(poll, 5000);
return () => {
active = false;
clearInterval(interval);
};
}, []);
// Centre on the milers when we have them, otherwise a sensible national view.
const mapCenter =
milers.length > 0
? [
milers.reduce((s, m) => s + m.lat, 0) / milers.length,
milers.reduce((s, m) => s + m.lon, 0) / milers.length
]
: [22.0, 76.0];
return (
<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>
</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 }}>
{/* Map Canvas Frame */}
<Box sx={{ flex: 2, minWidth: 0 }}>
@@ -124,46 +210,38 @@ export default function TrackingMap() {
/>
{/* Plot Hubs */}
{HUBS.map(hub => (
<Marker key={hub.id} position={hub.position} icon={createHubIcon()}>
{hubs.map((h) => (
<Marker key={h.id} position={h.position} icon={createHubIcon()}>
<Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{hub.name}</Typography>
<Typography variant="caption">{hub.type}</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{h.name}</Typography>
<Typography variant="caption">{h.type}</Typography>
</Popup>
</Marker>
))}
{/* Plot Milers */}
{riders.map(rider => {
const pos = getInterpolatedPosition(rider.start, rider.end, rider.progress);
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()}>
{/* Plot live Milers */}
{milers.map((m) => (
<Marker key={m.userid} position={[m.lat, m.lon]} icon={createRiderIcon()}>
<Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{rider.name}</Typography>
<Typography variant="caption">{rider.area}</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{m.displayname || `Miler ${m.userid}`}</Typography>
<Typography variant="caption" display="block">{statusMeta(m.status).label}</Typography>
{m.bookingid && <Typography variant="caption" display="block">On booking #{m.bookingid}</Typography>}
</Popup>
</Marker>
</React.Fragment>
);
})}
))}
{/* Plot Linehaul */}
{linehauls.map(lh => {
const pos = getInterpolatedPosition(lh.start, lh.end, lh.progress);
return (
{/* Plot transfer trucks in transit (real positions) */}
{linehauls.map((lh) => (
<React.Fragment key={lh.id}>
<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>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography>
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography>
</Popup>
</Marker>
</React.Fragment>
);
})}
))}
</MapContainer>
</Card>
</Box>
@@ -180,11 +258,16 @@ export default function TrackingMap() {
/>
<Divider />
<List disablePadding>
{HUBS.map(hub => (
<ListItem key={hub.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
{hubs.length === 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
primary={hub.name}
secondary={hub.type}
primary={h.name}
secondary={h.type}
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.9rem' }}
secondaryTypographyProps={{ fontSize: '0.75rem' }}
/>
@@ -197,30 +280,40 @@ export default function TrackingMap() {
{/* Real-time Last Mile Miler Logs */}
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
<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>}
/>
<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>
{riders.map(r => (
<ListItem key={r.id} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
{milers.map((m) => {
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>
<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>
</ListItemAvatar>
<ListItemText
primary={r.name}
secondary={r.area}
primary={name}
secondary={m.bookingid ? `On booking #${m.bookingid}` : 'No active booking'}
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
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 }}>
{Math.round(r.progress * 100)}%
<Typography variant="caption" sx={{ color: meta.color, fontWeight: 700, ml: 1, bgcolor: `${meta.color}12`, px: 1, py: 0.5, borderRadius: 2, whiteSpace: 'nowrap' }}>
{meta.label}
</Typography>
</ListItem>
))}
);
})}
</List>
)}
</Card>
{/* Linehaul Fleet Shipments Tracker */}

View File

@@ -17,7 +17,18 @@ export default defineConfig({
port: 3001,
strictPort: true,
host: true,
open: true
open: true,
// Proxy API calls through Vite so the browser makes same-origin requests.
// This sidesteps the backend's localhost-only CORS allowlist entirely —
// works whether you open the app on localhost, a LAN IP, or a hostname.
proxy: {
// Change `target` if you point the dev app at a different backend.
'/api': {
target: 'https://api.doormile.com',
changeOrigin: true,
secure: true
}
}
}
});