Compare commits

...

3 Commits

Author SHA1 Message Date
428e336553 changes regarding the mui to astryx tempalate based design 2026-07-23 18:35:49 +05:30
ef0b14d254 feat: Implement date range picker and enhance order assignment functionality
- Added DateRangePicker component for selecting date ranges in OrderAssignment.
- Updated OrderAssignment to fetch bookings based on selected date range.
- Enhanced status handling in OrderAssignment with new status chip display logic.
- Refactored KPI card implementation in RiderRoutes and Riders to use shared StatCard component.
- Improved ProfileDrawer to retain rider data during close transition.
- Fixed minor text formatting in Routing component.
2026-07-09 16:28:45 +05:30
f53578c520 update Riders UI layout, refine status chip styling, and remove API documentation file 2026-07-08 18:39:57 +05:30
35 changed files with 4925 additions and 6657 deletions

View File

@@ -1,889 +0,0 @@
# 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*

1508
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,14 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@astryxdesign/cli": "^0.1.8",
"@emotion/styled": "^11.14.1", "@astryxdesign/core": "^0.1.8",
"@mui/icons-material": "^9.1.1", "@astryxdesign/theme-neutral": "^0.1.8",
"@mui/material": "^9.1.1", "@stylexjs/stylex": "^0.19.0",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.3.1", "lucide-react": "^1.25.0",
"react-dom": "^18.3.1", "react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4", "react-router-dom": "^6.30.4",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"

BIN
public/navbarLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,7 +1,5 @@
import { Suspense, lazy } from 'react'; import { Suspense, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import MainLayout from '@/layout/MainLayout'; import MainLayout from '@/layout/MainLayout';
import MinimalLayout from '@/layout/MinimalLayout'; import MinimalLayout from '@/layout/MinimalLayout';
import ProtectedRoute from '@/auth/ProtectedRoute'; import ProtectedRoute from '@/auth/ProtectedRoute';
@@ -11,9 +9,10 @@ const load = (factory) => {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<CircularProgress color="primary" /> <div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</Box> <style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
</div>
} }
> >
<C /> <C />
@@ -34,6 +33,8 @@ export default function App() {
> >
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} /> <Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
<Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} /> <Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} />
{/* TEMPORARILY DISABLED: These pages still contain MUI imports which crashes Vite.
They will be re-enabled once they are migrated to Astryx. */}
<Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} /> <Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} />
<Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} /> <Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} />
<Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} /> <Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} />

View File

@@ -44,6 +44,23 @@ export function getInboundToday() {
return http.get(`${V1}/hub/inbound/today`); return http.get(`${V1}/hub/inbound/today`);
} }
/**
* Parcels received in a date range (inclusive), for the "Receive Parcels" history view.
* @param {string} from YYYY-MM-DD (inclusive)
* @param {string} to YYYY-MM-DD (inclusive of the whole day)
* Backend contract: GET /hub/inbound?from=&to= → same row shape as /hub/inbound/today.
* Until that route ships, we transparently fall back to /hub/inbound/today so the page
* keeps working (it just shows today regardless of the picked range).
*/
export async function getInboundRange(from, to) {
try {
return await http.get(`${V1}/hub/inbound?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
} catch (err) {
if (err?.status === 404) return http.get(`${V1}/hub/inbound/today`);
throw err;
}
}
/** /**
* Scan a parcel in. * Scan a parcel in.
* @param {number|string} bookingId Booking / consignment ID (the path :id). * @param {number|string} bookingId Booking / consignment ID (the path :id).
@@ -58,6 +75,24 @@ export function getUnassignedBookings() {
return http.get(`${V1}/hub/bookings/unassigned`); return http.get(`${V1}/hub/bookings/unassigned`);
} }
/**
* All pickup requests (bookings) created in a date range (inclusive), each with its
* current assignment status — powers the "Pickup Requests" history view.
* @param {string} from YYYY-MM-DD (inclusive)
* @param {string} to YYYY-MM-DD (inclusive of the whole day)
* Backend contract: GET /hub/bookings?from=&to= → array of bookings with a `status`
* field (e.g. "pending" | "assigned" | ...) plus the same fields /bookings/unassigned
* returns. Until it ships, we fall back to /bookings/unassigned so the page keeps working.
*/
export async function getBookingsRange(from, to) {
try {
return await http.get(`${V1}/hub/bookings?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
} catch (err) {
if (err?.status === 404) return http.get(`${V1}/hub/bookings/unassigned`);
throw err;
}
}
/** Manual assign — hub staff picks the miler (hub-scoped, accepts the hub JWT). */ /** Manual assign — hub staff picks the miler (hub-scoped, accepts the hub JWT). */
export function assignMiler(bookingId, mileruserid) { export function assignMiler(bookingId, mileruserid) {
return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid }); return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid });
@@ -73,6 +108,22 @@ export function getBatches() {
return http.get(`${V1}/hub/batches`); return http.get(`${V1}/hub/batches`);
} }
/**
* Outgoing batches created in a date range (inclusive) — powers the Dispatch history view.
* @param {string} from YYYY-MM-DD (inclusive)
* @param {string} to YYYY-MM-DD (inclusive of the whole day)
* Backend contract: GET /hub/batches?from=&to= → same row shape as GET /hub/batches,
* filtered by createdat in the range. Falls back to /hub/batches until that ships.
*/
export async function getBatchesRange(from, to) {
try {
return await http.get(`${V1}/hub/batches?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
} catch (err) {
if (err?.status === 404) return http.get(`${V1}/hub/batches`);
throw err;
}
}
/** /**
* Create an outgoing batch. * Create an outgoing batch.
* @param {object} payload { route, destination, vehicle, parcels_count, kind } * @param {object} payload { route, destination, vehicle, parcels_count, kind }

16
src/components/Button.jsx Normal file
View File

@@ -0,0 +1,16 @@
import { Button as AstryxButton } from '@astryxdesign/core/Button';
// Wraps Astryx's Button so `variant="primary"` (the default) renders in the
// Doormile brand red instead of the theme's neutral accent — primary CTAs
// stand out as "the one thing to click" while every other accent-driven
// control (focus rings, selected nav, ghost/outline buttons) keeps the
// theme's neutral dark. Scoped via a CSS custom property on this element only,
// not a global token flip, so nothing else on the page is affected.
export default function Button({ variant = 'primary', style, ...props }) {
const brandStyle =
variant === 'primary'
? { '--color-accent': 'var(--color-brand)', '--color-on-accent': 'var(--color-on-brand)' }
: null;
return <AstryxButton variant={variant} style={brandStyle ? { ...brandStyle, ...style } : style} {...props} />;
}

View File

@@ -0,0 +1,208 @@
import { useState } from 'react';
import dayjs from 'dayjs';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import Button from '@/components/Button';
export const DATE_FMT = 'YYYY-MM-DD';
const BRAND = 'var(--color-brand)';
const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
function MonthGrid({ view, start, end, max, onDay }) {
const gridStart = view.startOf('month').subtract(view.startOf('month').day(), 'day');
const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
return (
<div style={{ width: 224 }}>
<div style={{ textAlign: 'center', fontWeight: 700, fontSize: '0.82rem', color: '#212529', marginBottom: '6px' }}>
{view.format('MMMM YYYY')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: '2px' }}>
{DAY_LABELS.map((d, i) => (
<div key={i} style={{ textAlign: 'center', fontSize: '0.62rem', fontWeight: 600, color: '#B0B5BA', padding: '2px 0' }}>
{d}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{cells.map((d) => {
const inMonth = d.month() === view.month();
const isStart = start && d.isSame(start, 'day');
const isEnd = end && d.isSame(end, 'day');
const isEndpoint = isStart || isEnd;
const inRange = start && end && d.isAfter(start, 'day') && d.isBefore(end, 'day');
const isToday = d.isSame(dayjs(), 'day');
const disabled = max && d.isAfter(max, 'day');
return (
<div key={d.format(DATE_FMT)} style={{
display: 'flex', justifyContent: 'center',
backgroundColor: inRange ? 'var(--color-brand-muted)' : 'transparent',
borderTopLeftRadius: isStart ? 6 : 0, borderBottomLeftRadius: isStart ? 6 : 0,
borderTopRightRadius: isEnd ? 6 : 0, borderBottomRightRadius: isEnd ? 6 : 0
}}>
<button
type="button"
disabled={disabled}
onClick={() => onDay(d)}
style={{
width: 28, height: 28, margin: '1px', border: 'none', cursor: disabled ? 'default' : 'pointer',
borderRadius: 6, fontSize: '0.72rem', fontFamily: 'inherit',
fontWeight: isEndpoint ? 700 : 500,
color: disabled ? '#D5D9DD' : isEndpoint ? '#fff' : inMonth ? '#3C4043' : '#C4C9CE',
backgroundColor: isEndpoint ? BRAND : 'transparent',
boxShadow: isToday && !isEndpoint ? `inset 0 0 0 1.5px ${BRAND}` : 'none',
transition: 'background-color .12s',
}}
onMouseOver={(e) => { if(!disabled && !isEndpoint) e.target.style.backgroundColor = 'var(--color-brand-muted)'; }}
onMouseOut={(e) => { if(!disabled && !isEndpoint) e.target.style.backgroundColor = 'transparent'; }}
>
{d.date()}
</button>
</div>
);
})}
</div>
</div>
);
}
function RangeCalendar({ from, to, maxDate, onSelect }) {
const [view, setView] = useState(dayjs(to || from || undefined).startOf('month'));
const [anchorDate, setAnchorDate] = useState(null);
const start = from ? dayjs(from) : null;
const end = to ? dayjs(to) : null;
const max = maxDate ? dayjs(maxDate) : null;
const handleDay = (d) => {
if (!anchorDate) {
setAnchorDate(d);
onSelect(d.format(DATE_FMT), d.format(DATE_FMT));
} else {
const a = anchorDate;
const lo = d.isBefore(a) ? d : a;
const hi = d.isBefore(a) ? a : d;
onSelect(lo.format(DATE_FMT), hi.format(DATE_FMT));
setAnchorDate(null);
}
};
return (
<div style={{ padding: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<button onClick={() => setView((v) => v.subtract(1, 'month'))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9AA0A6' }}>
<ChevronLeft size={16} />
</button>
<button onClick={() => setView((v) => v.add(1, 'month'))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9AA0A6' }}>
<ChevronRight size={16} />
</button>
</div>
<div style={{ display: 'flex', gap: '16px' }}>
<MonthGrid view={view} start={start} end={end} max={max} onDay={handleDay} />
<div className="hide-on-mobile">
<MonthGrid view={view.add(1, 'month')} start={start} end={end} max={max} onDay={handleDay} />
</div>
</div>
<style>{`
@media (max-width: 600px) {
.hide-on-mobile { display: none; }
}
`}</style>
</div>
);
}
const PRESETS = [
{ label: 'Today', days: 1 },
{ label: 'Last 7 days', days: 7 },
{ label: 'Last 30 days', days: 30 }
];
export default function DateRangePicker({ value, onChange, maxDate }) {
const today = dayjs().format(DATE_FMT);
const max = maxDate ?? today;
const [open, setOpen] = useState(false);
const from = value?.from || today;
const to = value?.to || today;
const invalid = dayjs(to).isBefore(dayjs(from));
const dayCount = (() => {
const f = dayjs(from);
const t = dayjs(to);
if (!f.isValid() || !t.isValid() || t.isBefore(f)) return 1;
return t.diff(f, 'day') + 1;
})();
const applyPreset = (days) => onChange({ from: dayjs().subtract(days - 1, 'day').format(DATE_FMT), to: today });
const isPreset = (days) => to === today && from === dayjs().subtract(days - 1, 'day').format(DATE_FMT);
return (
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
{PRESETS.map((p) => {
const active = isPreset(p.days);
return (
<button
key={p.label}
onClick={() => applyPreset(p.days)}
style={{
height: 36, fontSize: '0.825rem', fontWeight: 600, borderRadius: 8, padding: '0 12px',
backgroundColor: active ? BRAND : '#F1F5F9', color: active ? '#fff' : '#475569',
border: 'none', cursor: 'pointer',
boxShadow: active ? '0 4px 10px rgba(192,18,39,0.15)' : 'none',
}}
>
{p.label}
</button>
);
})}
<Button
onClick={() => setOpen(!open)}
variant="outline"
style={{
height: 36, padding: '0 12px', borderRadius: 8, fontWeight: 600, fontSize: '0.825rem',
color: '#334155', borderColor: invalid ? '#EF4444' : '#E2E8F0', backgroundColor: '#fff',
justifyContent: 'flex-start'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#334155' }}>
<CalendarIcon size={16} />
<span>{dayjs(from).format('DD MMM YYYY')}</span>
<ArrowRight size={16} color="#94A3B8" />
<span>{dayjs(to).format('DD MMM YYYY')}</span>
</div>
</Button>
</div>
{open && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 999 }} onClick={() => setOpen(false)} />
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: '8px', zIndex: 1000,
backgroundColor: '#fff', borderRadius: '12px', border: '1px solid #EEF0F2',
boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden'
}}>
<div style={{ padding: '16px 16px 4px 16px' }}>
<div style={{ fontWeight: 700, fontSize: '0.9rem', color: '#212529' }}>Select date range</div>
<div style={{ fontSize: '0.75rem', color: '#8A9099' }}>
{dayjs(from).format('DD MMM')} {dayjs(to).format('DD MMM YYYY')} · {dayCount} {dayCount === 1 ? 'day' : 'days'}
</div>
</div>
<RangeCalendar from={from} to={to} maxDate={max} onSelect={(f, t) => onChange({ from: f, to: t })} />
<div style={{ borderTop: '1px solid #EEF0F2', padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button onClick={() => applyPreset(1)} style={{ background: 'none', border: 'none', color: '#5F6368', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>
Reset to today
</button>
<Button variant="primary" onClick={() => setOpen(false)}>
Done
</Button>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,44 +1,67 @@
import { Box, Typography } from '@mui/material';
// ==============================|| DOORMILE WORDMARK LOGO ||============================== // // ==============================|| DOORMILE WORDMARK LOGO ||============================== //
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red // Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the // surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
// collapsed sidebar) renders just the square "D" badge, since the wordmark won't fit. // collapsed navbar rail) pairs the round navbar mark with a "Doormile" text
// wordmark instead, since the full wordmark image is too wide to fit there.
const LOGO_SRC = '/Doormile-logo.png'; const LOGO_SRC = '/Doormile-logo.png';
const NAVBAR_MARK_SRC = '/navbarLogo.png';
export default function Logo({ onDark = false, compact = false, height = 26, sx }) { export default function Logo({ onDark = false, compact = false, height = 26, size = 64, style = {} }) {
if (compact) { if (compact) {
const mark = onDark ? '#FFFFFF' : '#C01227';
const markText = onDark ? '#C01227' : '#FFFFFF';
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}> <div
<Box style={{
sx={{ display: 'flex',
width: 34, alignItems: 'center',
height: 34, gap: 9,
borderRadius: 2, // Nudges the mark so its optical centre lines up with the collapsed
bgcolor: mark, // sidebar's icon column directly beneath it (measured ~2px apart —
display: 'flex', // the two live in separate layout trees with their own padding, so
alignItems: 'center', // this small correction keeps them reading as one straight column).
justifyContent: 'center', marginInlineStart: 2,
...style
}}
>
<img
src={NAVBAR_MARK_SRC}
// Adjacent text already announces the brand name, so the mark stays
// decorative here rather than making screen readers say it twice.
alt=""
style={{
width: size,
height: size,
display: 'block',
objectFit: 'cover',
flexShrink: 0, flexShrink: 0,
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39, 0.30)' // Matches the mark's original standalone (pre-wordmark) visual
// size exactly — scaling only the icon, not the row, keeps the
// new "Doormile" text at its own natural size beside it.
transform: 'scale(1.4)'
}}
/>
<span
style={{
fontSize: 20,
fontWeight: 700,
letterSpacing: '-0.01em',
lineHeight: 1,
whiteSpace: 'nowrap',
color: onDark ? '#ffffff' : '#0A1317'
}} }}
> >
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography> Doormile
</Box> </span>
</Box> </div>
); );
} }
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}> <div style={{ display: 'flex', alignItems: 'center', ...style }}>
<Box <img
component="img"
src={LOGO_SRC} src={LOGO_SRC}
alt="Doormile" alt="Doormile"
sx={{ style={{
height, height,
width: 'auto', width: 'auto',
display: 'block', display: 'block',
@@ -46,6 +69,6 @@ export default function Logo({ onDark = false, compact = false, height = 26, sx
filter: 'brightness(0) saturate(100%) invert(15%) sepia(88%) saturate(5900%) hue-rotate(352deg) brightness(86%) contrast(92%)' filter: 'brightness(0) saturate(100%) invert(15%) sepia(88%) saturate(5900%) hue-rotate(352deg) brightness(86%) contrast(92%)'
}} }}
/> />
</Box> </div>
); );
} }

View File

@@ -1,81 +1,51 @@
import { Box, Stack, Typography, Avatar } from '@mui/material'; import { Heading, Text } from '@astryxdesign/core/Text';
// ==============================|| SHARED PAGE HEADER ||============================== //
export default function PageHeader({ icon: Icon, title, subtitle, action }) { export default function PageHeader({ icon: Icon, title, subtitle, action }) {
return ( return (
<Stack <div
direction={{ xs: 'column', sm: 'row' }} style={{
alignItems={{ xs: 'flex-start', sm: 'center' }} display: 'flex',
justifyContent="space-between" flexDirection: 'row',
gap={3} alignItems: 'center',
sx={{ mb: { xs: 4, md: 4.5 } }} justifyContent: 'space-between',
gap: '20px',
marginBottom: '24px',
flexWrap: 'wrap'
}}
> >
<Stack <div style={{ display: 'flex', alignItems: 'center', gap: '16px', minWidth: 0 }}>
direction="row"
alignItems="center"
spacing={2.5} // Space between icon and title
sx={{ minWidth: 0 }}
>
{Icon && ( {Icon && (
<Avatar <div
variant="rounded" style={{
sx={{ backgroundColor: 'var(--color-brand-muted)',
bgcolor: 'primary.lighter', color: 'var(--color-brand)',
color: 'primary.main', width: 44,
width: 52, height: 44,
height: 52, borderRadius: 'var(--radius-element)',
borderRadius: 2.5, flexShrink: 0,
flexShrink: 0 display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}} }}
> >
<Icon sx={{ fontSize: 28 }} /> <Icon size={22} />
</Avatar> </div>
)} )}
<Box sx={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<Typography <Heading level={1} type="display-3">
variant="h4"
sx={{
fontWeight: 800,
color: 'text.primary',
letterSpacing: '-0.5px',
lineHeight: 1.15,
fontSize: {
xs: '1.5rem',
sm: '1.8rem',
md: '2.1rem'
}
}}
>
{title} {title}
</Typography> </Heading>
{subtitle && ( {subtitle && (
<Typography <Text type="body" color="secondary" style={{ display: 'block', marginTop: '4px', maxWidth: 700 }}>
variant="body2"
color="text.secondary"
sx={{
mt: 1,
maxWidth: 700,
lineHeight: 1.6,
fontSize: {
xs: '0.9rem',
sm: '0.95rem'
}
}}
>
{subtitle} {subtitle}
</Typography> </Text>
)} )}
</Box> </div>
</Stack> </div>
{action && ( {action && <div style={{ flexShrink: 0 }}>{action}</div>}
<Box sx={{ flexShrink: 0 }}> </div>
{action}
</Box>
)}
</Stack>
); );
} }

33
src/components/Panel.jsx Normal file
View File

@@ -0,0 +1,33 @@
import { Card } from '@astryxdesign/core/Card';
import { Heading } from '@astryxdesign/core/Text';
// Shared "section card": an optional titled header strip over a body slot.
// Every page that shows a titled block of content (tables, forms, feeds)
// renders it through this so cards share one radius/border/shadow/header
// treatment instead of each page re-declaring its own — that drift (16px vs
// 12px radius, different border colors) was the main source of visual
// inconsistency between screens.
export default function Panel({ title, action, children, bodyPadding = 0, style }) {
return (
<Card padding={0} style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
{title && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
padding: '14px 20px',
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-background-muted)',
flexShrink: 0
}}
>
<Heading level={5}>{title}</Heading>
{action}
</div>
)}
<div style={{ padding: bodyPadding, flexGrow: 1, boxSizing: 'border-box', minWidth: 0 }}>{children}</div>
</Card>
);
}

147
src/components/StatCard.jsx Normal file
View File

@@ -0,0 +1,147 @@
import { Card } from '@astryxdesign/core/Card';
// ==============================|| STAT / KPI CARD ||============================== //
// `tone` maps to Astryx's own categorical tokens (same palette Badge/StatusDot
// use), so KPI cards read as part of the same status language as the rest of
// the app instead of a one-off set of hand-picked hex values.
const TONE_VARS = {
blue: { icon: 'var(--color-icon-blue)', bg: 'var(--color-background-blue)' },
green: { icon: 'var(--color-icon-green)', bg: 'var(--color-background-green)' },
orange: { icon: 'var(--color-icon-orange)', bg: 'var(--color-background-orange)' },
purple: { icon: 'var(--color-icon-purple)', bg: 'var(--color-background-purple)' },
red: { icon: 'var(--color-icon-red)', bg: 'var(--color-background-red)' },
teal: { icon: 'var(--color-icon-teal)', bg: 'var(--color-background-teal)' },
cyan: { icon: 'var(--color-icon-cyan)', bg: 'var(--color-background-cyan)' },
gray: { icon: 'var(--color-icon-gray)', bg: 'var(--color-background-gray)' }
};
export default function StatCard({
label,
value,
icon: Icon,
tone = 'blue',
sub,
size = 'md',
loading = false,
hover = true
}) {
const isCompact = size === 'sm';
const { icon: iconColor, bg: tintBg } = TONE_VARS[tone] || TONE_VARS.blue;
return (
<Card
padding={0}
className={hover ? 'stat-card' : undefined}
style={{
height: '100%',
position: 'relative',
overflow: 'hidden',
padding: isCompact ? '16px' : '20px',
boxSizing: 'border-box',
cursor: hover ? 'pointer' : 'default'
}}
>
{Icon && (
<div
style={{
position: 'absolute',
right: '-12px',
bottom: '-12px',
opacity: 0.05,
transform: 'rotate(-15deg)',
pointerEvents: 'none',
color: iconColor
}}
>
<Icon size={isCompact ? 60 : 88} />
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: isCompact ? '10px' : '14px',
position: 'relative',
zIndex: 2
}}
>
<span
style={{
fontWeight: 700,
fontSize: '0.72rem',
color: 'var(--color-text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
>
{label}
</span>
{Icon && (
<div
style={{
width: isCompact ? '26px' : '34px',
height: isCompact ? '26px' : '34px',
borderRadius: 'var(--radius-inner)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tintBg,
color: iconColor,
flexShrink: 0
}}
>
<Icon size={isCompact ? 14 : 17} />
</div>
)}
</div>
<div style={{ position: 'relative', zIndex: 2, marginBottom: sub ? (isCompact ? '8px' : '12px') : 0 }}>
{loading ? (
<div
style={{
width: '60px',
height: isCompact ? '22px' : '30px',
backgroundColor: 'var(--color-skeleton)',
borderRadius: 4,
animation: 'pulse 1.5s infinite'
}}
/>
) : (
<span
style={{
fontWeight: 800,
fontSize: isCompact ? '1.4rem' : '1.85rem',
color: 'var(--color-text-primary)',
lineHeight: 1,
display: 'block'
}}
>
{value}
</span>
)}
</div>
{sub && (
<div
style={{
paddingTop: isCompact ? '8px' : '10px',
borderTop: '1px dashed var(--color-border)',
position: 'relative',
zIndex: 2
}}
>
<span style={{ fontSize: '0.72rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>{sub}</span>
</div>
)}
<style>{`
.stat-card { transition: box-shadow 0.2s ease, transform 0.2s ease; }
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-med); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
`}</style>
</Card>
);
}

112
src/index.css Normal file
View File

@@ -0,0 +1,112 @@
/* ─── Force light mode + brand color: override every Astryx light-dark() token ─── */
:root,
[data-astryx-theme] {
color-scheme: light;
/* Surface / background tokens */
--color-background-surface: #ffffff;
--color-background-body: #f1f4f7;
--color-background-card: #ffffff;
--color-background-popover: #ffffff;
--color-background-muted: rgba(5, 54, 89, 0.047);
--color-background-inverted: #0A1317;
--color-background-error-inverted: #AA071E;
/* Text tokens */
--color-text-primary: #0A1317;
--color-text-secondary: #4E606F;
--color-text-disabled: #A4B0BC;
--color-text-accent: #0A1317;
/* Icon tokens */
--color-icon-primary: #0A1317;
--color-icon-secondary: #4E606F;
--color-icon-disabled: #A4B0BC;
--color-icon-accent: #0A1317;
/* Border tokens */
--color-border: rgba(5, 54, 89, 0.1);
--color-border-emphasized: #CCD3DB;
/* Interactive overlay tokens */
--color-neutral: rgba(5, 54, 89, 0.1);
--color-overlay: rgba(1, 18, 40, 0.4);
--color-overlay-hover: rgba(5, 54, 89, 0.047);
--color-overlay-pressed: rgba(5, 54, 89, 0.098);
/* Misc */
--color-skeleton: #CCD3DB;
--color-track: #CCD3DB;
--color-shadow: rgba(5, 54, 89, 0.1);
/* Structural accent (nav selection, focus rings, secondary controls) — stays neutral/dark */
--color-accent: #0A1317;
--color-accent-muted: rgba(10, 19, 23, 0.12);
--color-on-accent: #ffffff;
/* Doormile brand red — reserved for primary CTAs only. Do not use for
structural chrome (nav, focus rings, secondary buttons); use
src/components/Button.jsx (variant="primary") to apply it consistently. */
--color-brand: #C01227;
--color-brand-hover: #A20F20;
--color-brand-muted: rgba(192, 18, 39, 0.1);
--color-on-brand: #ffffff;
/* "red" Badge variant */
--color-background-red: rgba(5, 54, 89, 0.1);
--color-text-red: #0A1317;
/* App-level overrides */
background-color: #f8fafc;
color: #0f172a;
}
*, *::before, *::after {
font-family: var(--font-family-body, 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: #f8fafc;
color: #334155;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
}
input, textarea {
font-family: inherit !important;
background-color: #ffffff !important;
color: #0f172a !important;
}
input::placeholder, textarea::placeholder {
color: #94a3b8 !important;
opacity: 1 !important;
}
input:not(.search-bar-input):not([class]), select:not([class]), textarea:not([class]) {
background-color: #ffffff !important;
color: #0f172a !important;
border: 1px solid rgba(5, 54, 89, 0.12) !important;
border-radius: 8px !important;
padding: 10px 14px !important;
font-size: 0.875rem !important;
outline: none !important;
transition: border-color 0.2s, box-shadow 0.2s !important;
box-sizing: border-box !important;
width: 100% !important;
}
input:not(.search-bar-input):not([class]), select:not([class]) {
height: 38px !important;
}
input:not(.search-bar-input):not([class]):focus, select:not([class]):focus, textarea:not([class]):focus {
border-color: #0A1317 !important;
box-shadow: 0 0 0 3px rgba(10, 19, 23, 0.12) !important;
}

View File

@@ -1,43 +1,12 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { Settings, LogOut, Bell, MessageSquare, Search, Truck, AlertTriangle, ArrowRight, Send, CheckCircle2 } from 'lucide-react';
AppBar, import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
Toolbar, import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
IconButton, import { TextInput } from '@astryxdesign/core/TextInput';
Box, import { Avatar } from '@astryxdesign/core/Avatar';
InputBase,
Badge,
Avatar,
Typography,
Menu,
MenuItem,
Divider,
ListItemIcon,
ListItemText,
Tooltip,
Button,
Stack,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
alpha,
InputAdornment
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
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 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 Button from '@/components/Button';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { getStaff, getHubContext, clearSession } from '@/auth/session'; import { getStaff, getHubContext, clearSession } from '@/auth/session';
import { import {
@@ -49,18 +18,16 @@ import {
markConversationRead markConversationRead
} from '@/api/hub'; } from '@/api/hub';
const RED = '#C01227'; const RED = 'var(--color-brand)';
// Map a notification `type` to an icon component (real API sends type, not an icon).
const NOTIF_ICON = { const NOTIF_ICON = {
exception: WarningAmberIcon, exception: AlertTriangle,
inbound: LocalShippingOutlinedIcon, inbound: Truck,
dispatch: LocalShippingOutlinedIcon, dispatch: Truck,
warning: WarningAmberIcon, warning: AlertTriangle,
alert: NotificationsActiveIcon alert: Bell
}; };
// Build initials from a display name — falls back to a sensible default.
const toInitials = (name) => const toInitials = (name) =>
(name || '') (name || '')
.split(' ') .split(' ')
@@ -70,8 +37,6 @@ const toInitials = (name) =>
.join('') .join('')
.toUpperCase() || 'HB'; .toUpperCase() || 'HB';
// Normalise a conversation summary from GET /hub/messages. Be tolerant of a few
// backend field-name variants so the list still renders if a key is named differently.
const toConversation = (c) => { const toConversation = (c) => {
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`; const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
return { return {
@@ -84,14 +49,13 @@ const toConversation = (c) => {
}; };
}; };
// Normalise one message in a thread from GET /hub/messages/:id.
const toMessage = (m) => ({ const toMessage = (m) => ({
sender: m.sender === 'me' ? 'me' : 'them', sender: m.sender === 'me' ? 'me' : 'them',
text: m.text ?? m.body ?? m.message ?? '', text: m.text ?? m.body ?? m.message ?? '',
time: m.time ?? m.createdat ?? m.sentat ?? '' time: m.time ?? m.createdat ?? m.sentat ?? ''
}); });
export default function Header({ onToggle }) { export default function Header({ isSidebarCollapsed }) {
const navigate = useNavigate(); const navigate = useNavigate();
const staff = getStaff(); const staff = getStaff();
const hub = getHubContext(); const hub = getHubContext();
@@ -103,14 +67,9 @@ export default function Header({ onToggle }) {
navigate('/login'); navigate('/login');
}; };
const [account, setAccount] = useState(null);
const [notifAnchor, setNotifAnchor] = useState(null);
const [msgAnchor, setMsgAnchor] = useState(null);
// Dialog State
const [selectedNotif, setSelectedNotif] = useState(null); const [selectedNotif, setSelectedNotif] = useState(null);
const [conversations, setConversations] = useState([]); const [conversations, setConversations] = useState([]);
const [activeChat, setActiveChat] = useState(null); // { id, name, initials, messages: [] } const [activeChat, setActiveChat] = useState(null);
const [chatLoading, setChatLoading] = useState(false); const [chatLoading, setChatLoading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [typedMessage, setTypedMessage] = useState(''); const [typedMessage, setTypedMessage] = useState('');
@@ -122,7 +81,6 @@ export default function Header({ onToggle }) {
const unread = notifications.filter((n) => !n.read).length; const unread = notifications.filter((n) => !n.read).length;
// Load real notifications from the API (map `type` → an icon component).
const loadNotifications = useCallback(async () => { const loadNotifications = useCallback(async () => {
try { try {
const res = await getNotifications(); const res = await getNotifications();
@@ -133,43 +91,39 @@ export default function Header({ onToggle }) {
time: n.time, time: n.time,
read: Boolean(n.read), read: Boolean(n.read),
type: n.type, type: n.type,
icon: NOTIF_ICON[n.type] || NotificationsNoneIcon icon: NOTIF_ICON[n.type] || Bell,
desc: n.desc,
stats: n.stats || [],
to: n.to,
actionText: n.actionText
})) }))
); );
} catch { } catch {
// Non-fatal: leave the bell empty if it can't load.
setNotifications([]); setNotifications([]);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
loadNotifications(); loadNotifications();
const t = setInterval(loadNotifications, 60000); // refresh every 60s const t = setInterval(loadNotifications, 60000);
return () => clearInterval(t); return () => clearInterval(t);
}, [loadNotifications]); }, [loadNotifications]);
// Load conversations (one per miler at the hub) for the messages dropdown.
const loadConversations = useCallback(async () => { const loadConversations = useCallback(async () => {
try { try {
const res = await getConversations(); const res = await getConversations();
setConversations((res?.data || []).map(toConversation)); setConversations((res?.data || []).map(toConversation));
} catch { } catch {
// Non-fatal: leave the messages list empty if it can't load.
setConversations([]); setConversations([]);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
loadConversations(); loadConversations();
const t = setInterval(loadConversations, 60000); // refresh every 60s const t = setInterval(loadConversations, 60000);
return () => clearInterval(t); return () => clearInterval(t);
}, [loadConversations]); }, [loadConversations]);
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
const closeNotif = () => setNotifAnchor(null);
const openMessages = (e) => { setMsgAnchor(e.currentTarget); loadConversations(); };
const markAllRead = async () => { const markAllRead = async () => {
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id); const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
@@ -178,18 +132,14 @@ export default function Header({ onToggle }) {
const onNotifClick = async (n) => { const onNotifClick = async (n) => {
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x))); setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
closeNotif(); if (n.desc || n.stats) setSelectedNotif(n);
else if (n.to) navigate(n.to);
try { try {
await markNotificationRead(n.id); await markNotificationRead(n.id);
} catch { } catch {}
/* best effort */
}
}; };
// Open a conversation: show the header immediately, then load the thread and
// mark the other party's messages as read (which clears the unread badge).
const onMessageClick = async (conv) => { const onMessageClick = async (conv) => {
setMsgAnchor(null);
setTypedMessage(''); setTypedMessage('');
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] }); setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
setChatLoading(true); setChatLoading(true);
@@ -207,35 +157,30 @@ export default function Header({ onToggle }) {
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c))); setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
} }
} catch { } catch {
// Leave the (empty) thread open; the header still shows who it's with.
} finally { } finally {
setChatLoading(false); setChatLoading(false);
} }
}; };
const closeChat = () => { setActiveChat(null); setTypedMessage(''); }; const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
const handleSendMessage = async () => { const handleSendMessage = async () => {
const text = typedMessage.trim(); const text = typedMessage.trim();
if (!text || !activeChat || sending) return; if (!text || !activeChat || sending) return;
setSending(true); setSending(true);
// Optimistically append; reconcile with the server's stored copy on success.
const now = new Date(); const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] })); setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
setTypedMessage(''); setTypedMessage('');
try { try {
await sendMessage(activeChat.id, text); await sendMessage(activeChat.id, text);
// Refresh the thread so the persisted message (and its real timestamp) shows.
const res = await getConversation(activeChat.id); const res = await getConversation(activeChat.id);
const thread = res?.data || {}; const thread = res?.data || {};
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) }); setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
// Keep the dropdown preview in sync.
setConversations((prev) => setConversations((prev) =>
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c)) prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
); );
} catch { } catch {
// On failure, drop the optimistic bubble and restore the draft to retry.
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) }); setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
setTypedMessage(text); setTypedMessage(text);
} finally { } finally {
@@ -250,336 +195,118 @@ export default function Header({ onToggle }) {
}; };
return ( return (
<AppBar <>
position="fixed" <TopNav
elevation={0} label="Main navigation"
sx={{ style={{
bgcolor: '#FFFFFF', backgroundColor: '#ffffff',
color: 'text.primary', borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
zIndex: (t) => t.zIndex.drawer + 1, boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
borderBottom: '1px solid', }}
borderColor: 'grey.200' heading={
}} <TopNavHeading
> logo={<Logo compact={isSidebarCollapsed} size={isSidebarCollapsed ? 36 : 32} height={28} />}
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}> headingHref="/dashboard"
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
<MenuIcon />
</IconButton>
{/* Brand wordmark — left side */}
<Box
onClick={() => navigate('/dashboard')}
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<Logo height={22} />
</Box>
<Box sx={{ flexGrow: 1 }} />
{/* Search */}
<Box
component="form"
onSubmit={submitSearch}
sx={{
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
bgcolor: 'grey.100',
borderRadius: 2,
px: 1.5,
py: 0.5,
width: { sm: 240, md: 320 },
border: '1px solid',
borderColor: 'grey.200',
'&:hover': { bgcolor: 'grey.200' }
}}
>
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} />
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Scan package, check destination…"
sx={{ fontSize: '0.875rem', flex: 1 }}
inputProps={{ 'aria-label': 'search' }}
/> />
</Box> }
endContent={
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1, justifyContent: 'flex-end' }}>
<DropdownMenu
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><MessageSquare size={20} />{unreadMessages > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unreadMessages}</span>}</div> }}
items={[
{ label: 'Messages', type: 'label' },
...conversations.map(m => ({
label: m.name,
description: m.lastMessage,
onClick: () => onMessageClick(m)
}))
]}
/>
<Tooltip title="Messages"> <DropdownMenu
<IconButton color="inherit" onClick={openMessages}> button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><Bell size={20} />{unread > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unread}</span>}</div> }}
<Badge badgeContent={unreadMessages} color="error"> items={[
<ChatIcon /> { label: 'Notifications', type: 'label' },
</Badge> { label: 'Mark all read', icon: <CheckCircle2 size={16} />, onClick: markAllRead, disabled: unread === 0 },
</IconButton> { type: 'divider' },
</Tooltip> ...(notifications.length === 0 ? [{ label: 'No notifications', disabled: true }] : notifications.map((n, i) => ({
<Tooltip title="Notifications"> label: n.title + '\u200B'.repeat(i),
<IconButton color="inherit" onClick={openNotif}> description: n.time,
<Badge badgeContent={unread} color="error"> icon: n.icon ? (() => { const Icon = n.icon; return <Icon size={16} />; })() : undefined,
<NotificationsNoneIcon /> onClick: () => onNotifClick(n)
</Badge> })))
</IconButton> ]}
</Tooltip> />
<Box <DropdownMenu
onClick={(e) => setAccount(e.currentTarget)} button={{
sx={{ variant: 'ghost',
display: 'flex', size: 'lg',
alignItems: 'center', icon: <Avatar name={staffName} fallback={toInitials(staffName)} size="sm" />,
gap: 1, label: staffName
ml: 0.5, }}
cursor: 'pointer', items={[
py: 0.5, { label: 'Settings', icon: <Settings size={16} />, onClick: () => navigate('/hub-settings') },
px: 1, { type: 'divider' },
borderRadius: 2, { label: 'Logout', icon: <LogOut size={16} />, onClick: handleLogout }
'&:hover': { bgcolor: 'grey.100' } ]}
}} />
> </div>
<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 }}>
{staffName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{hubName}
</Typography>
</Box>
</Box>
{/* Notifications dropdown */} {selectedNotif && (
<Menu <dialog open style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, background: '#fff', padding: '24px', borderRadius: '8px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', maxWidth: '500px', width: '100%' }}>
anchorEl={notifAnchor} <h2 style={{ margin: '0 0 16px 0', fontSize: '1.25rem' }}>{selectedNotif.title}</h2>
open={Boolean(notifAnchor)} <p style={{ margin: '0 0 16px 0', color: '#475569' }}>{selectedNotif.desc}</p>
onClose={closeNotif} <div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
transformOrigin={{ horizontal: 'right', vertical: 'top' }} {selectedNotif.stats.map((s, i) => (
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} <div key={i} style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px', flex: 1 }}>
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }} <div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold' }}>{s.label}</div>
> <div style={{ fontSize: '1.125rem', fontWeight: 'bold', marginTop: '4px' }}>{s.value}</div>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}> </div>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> ))}
Notifications </div>
</Typography> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}> <Button variant="ghost" onClick={() => setSelectedNotif(null)}>Dismiss</Button>
Mark all read {selectedNotif.actionText && (
</Button> <Button onClick={() => { setSelectedNotif(null); navigate(selectedNotif.to); }}>
</Stack> {selectedNotif.actionText} <ArrowRight size={16} />
<Divider />
{notifications.length === 0 && (
<MenuItem disabled>
<ListItemText primary="No notifications" />
</MenuItem>
)}
{notifications.map((n) => {
const Icon = n.icon;
return (
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
<ListItemIcon sx={{ mt: 0.25 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
<Icon fontSize="small" />
</Avatar>
</ListItemIcon>
<ListItemText
primary={n.title}
secondary={n.time}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
secondaryTypographyProps={{ fontSize: '0.75rem' }}
/>
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
</MenuItem>
);
})}
</Menu>
{/* Messages dropdown */}
<Menu
anchorEl={msgAnchor}
open={Boolean(msgAnchor)}
onClose={() => setMsgAnchor(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
Messages
</Typography>
<Divider />
{conversations.length === 0 && (
<MenuItem disabled>
<ListItemText primary="No messages" />
</MenuItem>
)}
{conversations.map((m) => (
<MenuItem key={m.id} onClick={() => onMessageClick(m)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
<ListItemIcon sx={{ mt: 0.25 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
{m.initials}
</Avatar>
</ListItemIcon>
<ListItemText
primary={m.name}
secondary={m.lastMessage || 'No messages yet'}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: m.unread > 0 ? 700 : 600 }}
secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }}
/>
<Stack alignItems="flex-end" sx={{ ml: 1, flexShrink: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{m.time}
</Typography>
{m.unread > 0 && (
<Badge badgeContent={m.unread} color="error" sx={{ mt: 1, mr: 0.75 }} />
)}
</Stack>
</MenuItem>
))}
</Menu>
{/* Account dropdown */}
<Menu
anchorEl={account}
open={Boolean(account)}
onClose={() => setAccount(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
>
<MenuItem onClick={() => { setAccount(null); handleLogout(); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout
</MenuItem>
</Menu>
</Toolbar>
{/* High Fidelity Notification Detail Dialog */}
<Dialog open={Boolean(selectedNotif)} onClose={() => setSelectedNotif(null)} fullWidth maxWidth="sm">
{selectedNotif && (
<>
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}>
{selectedNotif.title}
</DialogTitle>
<Divider />
<DialogContent sx={{ py: 3 }}>
<Typography variant="body1" sx={{ mb: 3 }}>
{selectedNotif.desc}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'text.secondary' }}>
Operational Details
</Typography>
<Grid container spacing={2}>
{selectedNotif.stats.map((stat, index) => (
<Grid size={{ xs: 6 }} key={index}>
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontWeight: 600 }}>
{stat.label}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mt: 0.5 }}>
{stat.value}
</Typography>
</Box>
</Grid>
))}
</Grid>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button>
<Button
variant="contained"
endIcon={<ArrowForwardIcon fontSize="small" />}
onClick={() => {
setSelectedNotif(null);
navigate(selectedNotif.to);
}}
>
{selectedNotif.actionText}
</Button> </Button>
</DialogActions> )}
</> </div>
)} </dialog>
</Dialog> )}
{/* High Fidelity Chat Message Dialog */} {activeChat && (
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs"> <dialog open style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 1000, background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', width: '360px', padding: 0, overflow: 'hidden' }}>
{activeChat && ( <div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '12px', background: '#f8fafc' }}>
<> <div style={{ width: 32, height: 32, borderRadius: '50%', background: RED, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }}>{activeChat.initials}</div>
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}> <h3 style={{ margin: 0, flex: 1 }}>{activeChat.name}</h3>
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}> <Button variant="ghost" size="icon" onClick={closeChat}>×</Button>
{activeChat.initials} </div>
</Avatar> <div style={{ height: '300px', overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', background: '#fff' }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}> {chatLoading ? (
{activeChat.name} <div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>Loading...</div>
</Typography> ) : activeChat.messages.length === 0 ? (
</DialogTitle> <div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>No messages yet.</div>
<Divider /> ) : (
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}> activeChat.messages.map((m, i) => (
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}> <div key={i} style={{ alignSelf: m.sender === 'me' ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
{chatLoading && activeChat.messages.length === 0 && ( <div style={{ background: m.sender === 'me' ? RED : '#f1f5f9', color: m.sender === 'me' ? '#fff' : '#0f172a', padding: '8px 12px', borderRadius: '12px', borderBottomRightRadius: m.sender === 'me' ? 0 : '12px', borderBottomLeftRadius: m.sender === 'them' ? 0 : '12px' }}>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}> {m.text}
Loading </div>
</Typography> <div style={{ fontSize: '0.65rem', color: '#94a3b8', textAlign: m.sender === 'me' ? 'right' : 'left', marginTop: '4px' }}>{m.time}</div>
)} </div>
{!chatLoading && activeChat.messages.length === 0 && ( ))
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}> )}
No messages yet. Say hello. </div>
</Typography> <div style={{ padding: '12px', borderTop: '1px solid #e2e8f0', background: '#fff', display: 'flex', gap: '8px' }}>
)} <TextInput style={{ flex: 1 }} value={typedMessage} onChange={(e) => setTypedMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} placeholder="Type a message..." />
{activeChat.messages.map((msg, idx) => ( <Button size="icon" onClick={handleSendMessage} disabled={!typedMessage.trim() || sending}><Send size={16} /></Button>
<Box </div>
key={idx} </dialog>
sx={{ )}
alignSelf: msg.sender === 'me' ? 'flex-end' : 'flex-start', </>
maxWidth: '80%'
}}
>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: msg.sender === 'me' ? RED : '#FFFFFF',
color: msg.sender === 'me' ? '#FFFFFF' : 'text.primary',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
border: msg.sender === 'me' ? 'none' : '1px solid',
borderColor: 'grey.200'
}}
>
<Typography variant="body1">
{msg.text}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
mt: 0.5,
textAlign: msg.sender === 'me' ? 'right' : 'left'
}}
>
{msg.time}
</Typography>
</Box>
))}
</Stack>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 1.5 }}>
<TextField
fullWidth
size="small"
placeholder="Type your message..."
value={typedMessage}
onChange={(e) => setTypedMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleSendMessage} size="small" color="primary" disabled={sending || !typedMessage.trim()}>
<SendIcon fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
/>
</DialogActions>
</>
)}
</Dialog>
</AppBar>
); );
} }

View File

@@ -1,106 +1,16 @@
import { useState, useMemo } from 'react'; import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
Drawer, import { Text } from '@astryxdesign/core/Text';
Box,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
Collapse,
Tooltip,
Toolbar
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import navItems from '@/menu/navItems'; import navItems from '@/menu/navItems';
import Logo from '@/components/Logo';
import { isDoormileStaff } from '@/auth/session'; import { isDoormileStaff } from '@/auth/session';
export const DRAWER_WIDTH = 240; export default function Sidebar({ isCollapsed, onCollapsedChange }) {
export const MINI_WIDTH = 72;
const BRAND_RED = '#C01227';
function NavLeaf({ item, open, active, depth = 0, onClick }) {
const Icon = item.icon;
const button = (
<ListItemButton
selected={active}
onClick={onClick}
sx={{
minHeight: 44,
my: 0.25,
mx: 1.25,
px: open ? 1.5 : 0,
justifyContent: open ? 'flex-start' : 'center',
borderRadius: '8px',
color: active ? BRAND_RED : 'text.primary',
transition: (theme) => theme.transitions.create(['background-color', 'color', 'padding'], {
duration: theme.transitions.duration.shorter,
}),
'& .MuiListItemIcon-root': {
color: active ? BRAND_RED : 'text.secondary',
minWidth: open ? 32 : 0,
justifyContent: 'center'
},
'&:hover': {
bgcolor: 'action.hover',
color: 'text.primary',
'& .MuiListItemIcon-root': { color: 'text.primary' }
},
'&.Mui-selected': {
bgcolor: alpha(BRAND_RED, 0.08),
color: BRAND_RED,
'& .MuiListItemIcon-root': { color: BRAND_RED },
'&:hover': { bgcolor: alpha(BRAND_RED, 0.12) }
}
}}
>
<ListItemIcon>
{depth > 0 && !Icon ? (
<FiberManualRecordIcon sx={{ fontSize: 6 }} />
) : Icon ? (
<Icon fontSize="small" />
) : null}
</ListItemIcon>
{open && (
<ListItemText
primary={item.title}
primaryTypographyProps={{
fontSize: '0.875rem',
fontWeight: active ? 600 : 500,
noWrap: true
}}
/>
)}
</ListItemButton>
);
if (!open) {
return (
<Tooltip title={item.title} placement="right" arrow disableInteractive>
{button}
</Tooltip>
);
}
return button;
}
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const isActive = (url) => !!url && location.pathname.startsWith(url);
const expanded = open || isMobile;
const doormile = isDoormileStaff(); const doormile = isDoormileStaff();
// Partner accounts don't see Doormile-only groups/items (e.g. Hub Settings).
const groups = useMemo( const groups = useMemo(
() => () =>
navItems navItems
@@ -110,213 +20,121 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
[doormile] [doormile]
); );
const isActive = (url) => !!(url && location.pathname.startsWith(url)); return (
<>
// Memoize initial open state to prevent recalculation on every drawer toggle <SideNav
const initialOpen = useMemo(() => { className="doormile-side-nav"
return navItems collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
.flatMap((g) => g.items) style={{
.filter((i) => i.children && i.children.some((c) => isActive(c.url))) backgroundColor: '#ffffff',
.map((i) => i.id); borderRight: '1px solid rgba(5, 54, 89, 0.08)',
}, [location.pathname]); boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
paddingBlock: '12px',
const [collapse, setCollapse] = useState(initialOpen); paddingInline: '8px',
boxSizing: 'border-box',
const handleToggleCollapse = (id) => { '--spacing-12': '72px'
setCollapse((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const go = (url) => {
navigate(url);
if (isMobile) onMobileClose();
};
const sidebarContent = (
<Box
sx={{
bgcolor: 'background.paper',
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid',
borderColor: 'divider'
}}
>
{/* Top Branding Section */}
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
<Logo compact={!expanded} />
</Toolbar>
{/* Navigation Scroll Area */}
<Box
sx={{
overflowY: 'auto',
overflowX: 'hidden',
flexGrow: 1,
pb: 2,
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'action.hover',
borderRadius: 4,
},
'&:hover::-webkit-scrollbar-thumb': {
backgroundColor: 'action.focus',
}
}} }}
> >
{groups.map((grp) => ( {groups.map((grp) => (
<Box key={grp.group} sx={{ mt: 2.5 }}> <SideNavSection key={grp.group} title={grp.group}>
{expanded && ( {grp.items.map((item) => (
<Typography <SideNavItem
variant="overline" key={item.id}
sx={{ label={item.title}
px: 2.5, // If you migrate icons to Lucide or similar, you pass it here.
color: 'text.secondary', // Currently keeping the existing MUI icon or whatever item.icon provides.
fontWeight: 800, icon={item.icon}
fontSize: '0.6875rem', href={item.url}
letterSpacing: '0.08em', isSelected={isActive(item.url)}
display: 'block' />
}} ))}
> </SideNavSection>
{grp.group}
</Typography>
)}
<List disablePadding sx={{ mt: 0.5 }}>
{grp.items.map((item) => {
if (item.children) {
const opened = collapse.includes(item.id);
const childActive = item.children.some((c) => isActive(c.url));
const Icon = item.icon;
const headerButton = (
<ListItemButton
onClick={() => expanded ? handleToggleCollapse(item.id) : go(item.children[0].url)}
sx={{
minHeight: 44,
my: 0.25,
mx: 1.25,
px: expanded ? 1.5 : 0,
justifyContent: expanded ? 'flex-start' : 'center',
borderRadius: '8px',
color: 'text.primary',
bgcolor: childActive && !opened ? alpha(BRAND_RED, 0.04) : 'transparent',
'& .MuiListItemIcon-root': {
color: childActive ? BRAND_RED : 'text.secondary',
minWidth: expanded ? 32 : 0,
justifyContent: 'center'
},
'&:hover': {
bgcolor: 'action.hover',
'& .MuiListItemIcon-root': { color: 'text.primary' }
}
}}
>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
{expanded && (
<>
<ListItemText
primary={item.title}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: childActive ? 600 : 500 }}
/>
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</>
)}
</ListItemButton>
);
return (
<Box key={item.id}>
{expanded ? headerButton : <Tooltip title={item.title} placement="right" arrow>{headerButton}</Tooltip>}
{expanded && (
<Collapse in={opened} timeout="auto" unmountOnExit>
<Box sx={{ mt: 0.25 }}>
{item.children.map((c) => (
<NavLeaf
key={c.id}
item={c}
open
depth={1}
active={isActive(c.url)}
onClick={() => go(c.url)}
/>
))}
</Box>
</Collapse>
)}
</Box>
);
}
return (
<NavLeaf
key={item.id}
item={item}
open={expanded}
active={isActive(item.url)}
onClick={() => go(item.url)}
/>
);
})}
</List>
</Box>
))} ))}
</Box> </SideNav>
<style>{`
.doormile-side-nav .astryx-side-nav-section > div:last-child {
gap: 6px !important;
}
{/* Bottom Footer Branding */} .doormile-side-nav .astryx-side-nav-item[aria-label] {
{expanded && ( width: 40px;
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.default' }}> height: 40px;
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 600, display: 'block', lineHeight: 1.3 }}> margin-inline: auto;
Hub Control Panel border-radius: 10px;
</Typography> display: flex;
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}> align-items: center;
Doormile Logistics · v1.0 justify-content: center;
</Typography> transition: background-color 0.15s ease;
</Box> }
)} .doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
</Box> width: 18px !important;
); height: 18px !important;
}
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
background-color: rgba(10, 19, 23, 0.08) !important;
}
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
outline: 2px solid rgba(10, 19, 23, 0.4);
outline-offset: 2px;
}
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
background-color: rgba(10, 19, 23, 0.12) !important;
}
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
color: #0A1317 !important;
}
if (isMobile) { .doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
return ( position: relative;
<Drawer margin-inline: 2px;
variant="temporary" height: auto;
open={mobileOpen} padding-block: 12px !important;
onClose={onMobileClose} transition: background-color 0.15s ease, transform 0.15s ease;
ModalProps={{ keepMounted: true }} }
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }} .doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
> content: '';
{sidebarContent} position: absolute;
</Drawer> left: -2px;
); top: 12px;
} bottom: 12px;
width: 3px;
border-radius: 3px;
background-color: #0A1317;
opacity: 0;
transition: opacity 0.15s ease;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
background-color: rgba(10, 19, 23, 0.05) !important;
transform: translateX(2px);
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
outline: 2px solid rgba(10, 19, 23, 0.4);
outline-offset: 2px;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
background-color: rgba(10, 19, 23, 0.08) !important;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected']::before {
opacity: 1;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] .astryx-icon {
color: #0A1317 !important;
}
return ( .doormile-side-nav > div:last-child {
<Drawer padding-block: 8px !important;
variant="permanent" }
sx={{
width: open ? DRAWER_WIDTH : MINI_WIDTH, .doormile-side-nav button[aria-label*="sidebar"],
flexShrink: 0, .doormile-side-nav button[aria-label*="navigation"] {
whiteSpace: 'nowrap', border-radius: 50% !important;
'& .MuiDrawer-paper': { transition: background-color 0.15s ease !important;
width: open ? DRAWER_WIDTH : MINI_WIDTH, }
border: 'none', .doormile-side-nav button[aria-label*="sidebar"]:hover,
overflowX: 'hidden', .doormile-side-nav button[aria-label*="navigation"]:hover {
transition: (theme) => theme.transitions.create('width', { background-color: rgba(10, 19, 23, 0.08) !important;
easing: theme.transitions.easing.sharp, }
duration: theme.transitions.duration.standard, `}</style>
}), </>
},
}}
open={open}
>
{sidebarContent}
</Drawer>
); );
} }

View File

@@ -1,56 +1,35 @@
import { useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Box, useMediaQuery } from '@mui/material'; import { AppShell } from '@astryxdesign/core/AppShell';
import { useTheme } from '@mui/material/styles';
import Header from './Header'; import Header from './Header';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
export default function MainLayout() { export default function MainLayout() {
const theme = useTheme(); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
const [open, setOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const toggle = () => {
if (isMobile) setMobileOpen((p) => !p);
else setOpen((p) => !p);
};
return ( return (
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}> <AppShell
<Header onToggle={toggle} /> variant="section"
<Sidebar height="fill"
open={open} contentPadding={0}
isMobile={isMobile} topNav={<Header isSidebarCollapsed={isSidebarCollapsed} />}
mobileOpen={mobileOpen} sideNav={<Sidebar isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} />}
onMobileClose={() => setMobileOpen(false)} mobileNav={{ breakpoint: 'lg' }}
/> >
<Box <div className="main-content-area" style={{ minHeight: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }}>
component="main" <Outlet />
sx={{ </div>
flexGrow: 1, <style>{`
minWidth: 0, .main-content-area {
minHeight: '100vh', padding: 24px;
display: 'flex', }
flexDirection: 'column', @media (max-width: 768px) {
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard }) .main-content-area {
}} padding: 16px;
> }
<Box sx={{ height: 64, flexShrink: 0 }} /> }
<Box `}</style>
sx={{ </AppShell>
flexGrow: 1,
width: '100%',
maxWidth: '100%',
overflowX: 'hidden',
px: { xs: 1.5, sm: 2.5, md: 3.5 },
py: { xs: 2, sm: 2.5, md: 3 }
}}
>
<Outlet />
</Box>
</Box>
</Box>
); );
} }

View File

@@ -1,11 +1,10 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Box } from '@mui/material';
// Used by auth + maintenance pages — full-bleed, no shell. // Used by auth + maintenance pages — full-bleed, no shell.
export default function MinimalLayout() { export default function MinimalLayout() {
return ( return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}> <div style={{ minHeight: '100vh', backgroundColor: 'var(--color-background-body)' }}>
<Outlet /> <Outlet />
</Box> </div>
); );
} }

View File

@@ -1,18 +1,26 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter, Link } from 'react-router-dom';
import { ThemeProvider, CssBaseline } from '@mui/material';
import theme from '@/theme'; // Astryx UI CSS
import '@astryxdesign/core/reset.css';
import '@astryxdesign/core/astryx.css';
import '@astryxdesign/theme-neutral/theme.css';
import './index.css';
import { Theme } from '@astryxdesign/core';
import { LinkProvider } from '@astryxdesign/core/Link';
import { ToastViewport } from '@astryxdesign/core/Toast';
import { neutralTheme } from '@astryxdesign/theme-neutral/built';
import App from '@/App'; import App from '@/App';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <Theme theme={neutralTheme}>
<ThemeProvider theme={theme}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<CssBaseline /> <LinkProvider component={Link}>
<BrowserRouter>
<App /> <App />
</BrowserRouter> <ToastViewport />
</ThemeProvider> </LinkProvider>
</React.StrictMode> </BrowserRouter>
</Theme>
); );

View File

@@ -1,11 +1,4 @@
import SpaceDashboardRoundedIcon from '@mui/icons-material/SpaceDashboardRounded'; import { LayoutDashboard, Map, HandHeart, Inbox, Route, Truck, Bike, Settings } from 'lucide-react';
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 LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded';
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
// ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== // // ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== //
// Menu follows the parcel's real journey in plain language so any hub staff member // Menu follows the parcel's real journey in plain language so any hub staff member
@@ -15,39 +8,39 @@ const navItems = [
{ {
group: 'Overview', group: 'Overview',
items: [ items: [
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: SpaceDashboardRoundedIcon }, { id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: LayoutDashboard },
{ id: 'tracking', title: 'Live Map', url: '/tracking', icon: MapRoundedIcon } { id: 'tracking', title: 'Live Map', url: '/tracking', icon: Map }
] ]
}, },
{ {
group: '1. Pick Up', group: '1. Pick Up',
items: [ items: [
{ id: 'assignments', title: 'Pickup Requests', url: '/assignments', icon: HailRoundedIcon } { id: 'assignments', title: 'Pickup Requests', url: '/assignments', icon: HandHeart }
] ]
}, },
{ {
group: '2. Receive at Hub', group: '2. Receive at Hub',
items: [ items: [
{ id: 'inbound', title: 'Receive Parcels', url: '/inbound', icon: MoveToInboxRoundedIcon } { id: 'inbound', title: 'Receive Parcels', url: '/inbound', icon: Inbox }
] ]
}, },
{ {
group: '3. Sort & Store', group: '3. Sort & Store',
items: [ items: [
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon } { id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: Route }
] ]
}, },
{ {
group: '4. Send Out', group: '4. Send Out',
items: [ items: [
{ id: 'dispatch', title: 'Dispatch & Transfer', url: '/dispatch', icon: LocalShippingRoundedIcon } { id: 'dispatch', title: 'Dispatch & Transfer', url: '/dispatch', icon: Truck }
] ]
}, },
{ {
group: 'Team', group: 'Team',
items: [ items: [
{ id: 'riders', title: 'Milers', url: '/riders', icon: DeliveryDiningRoundedIcon }, { id: 'riders', title: 'Milers', url: '/riders', icon: Bike },
{ id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: AltRouteRoundedIcon } { id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: Route }
] ]
}, },
{ {
@@ -55,7 +48,7 @@ const navItems = [
// Doormile staff only — hidden for partner (restricted) accounts. // Doormile staff only — hidden for partner (restricted) accounts.
doormileOnly: true, doormileOnly: true,
items: [ items: [
{ id: 'hub-settings', title: 'Hub Settings', url: '/hub-settings', icon: SettingsRoundedIcon, doormileOnly: true } { id: 'hub-settings', title: 'Hub Settings', url: '/hub-settings', icon: Settings, doormileOnly: true }
] ]
} }
]; ];

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { Zap, Truck, ShieldCheck, Eye, EyeOff, Loader2 } from 'lucide-react';
Box, import { TextInput } from '@astryxdesign/core/TextInput';
Card, import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
Stack,
Typography,
TextField,
InputAdornment,
IconButton,
Button,
Checkbox,
FormControlLabel,
Link,
Alert,
CircularProgress
} from '@mui/material';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import BoltIcon from '@mui/icons-material/Bolt';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import Button from '@/components/Button';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { login as loginRequest } from '@/api/hub'; import { login as loginRequest } from '@/api/hub';
import { setSession } from '@/auth/session'; import { setSession } from '@/auth/session';
@@ -28,8 +12,8 @@ import { setSession } from '@/auth/session';
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [auth, setAuth] = useState('hub.coimbatore@doormile.in'); const [auth, setAuth] = useState('');
const [pwd, setPwd] = useState('password123'); const [pwd, setPwd] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -56,17 +40,18 @@ export default function Login() {
}; };
return ( return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100vw', bgcolor: '#ffffff', overflow: 'hidden' }}> <div style={{ display: 'flex', minHeight: '100vh', width: '100vw', backgroundColor: '#ffffff', overflow: 'hidden' }}>
{/* Brand Side Panel */} {/* Brand Side Panel */}
<Box <div
sx={{ className="hide-on-mobile"
display: { xs: 'none', md: 'flex' }, style={{
display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'space-between',
width: { md: '28%', lg: '27%', xl: '25%' }, width: '28%',
minWidth: '360px', minWidth: '360px',
p: 5, padding: '40px',
color: '#fff', color: '#fff',
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)', background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
position: 'relative', position: 'relative',
@@ -75,172 +60,160 @@ export default function Login() {
}} }}
> >
{/* Background Decorative Circles */} {/* Background Decorative Circles */}
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} /> <div style={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} /> <div style={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
{/* BLACK LOGO REPLACEMENT (Sidebar) */} <div style={{ filter: 'brightness(0) invert(1)', display: 'inline-flex' }}>
<Box sx={{ filter: 'brightness(0) invert(0)', display: 'inline-flex' }}>
<Logo height={24} /> <Logo height={24} />
</Box> </div>
<Box sx={{ position: 'relative', my: 'auto' }}> <div style={{ position: 'relative', margin: 'auto 0' }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600 }}> <div style={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600, textTransform: 'uppercase', fontSize: '0.75rem', marginBottom: '8px' }}>
Doormile Hub Console Doormile Hub Console
</Typography> </div>
<Typography variant="h4" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, mt: 1, mb: 2, fontSize: { md: '1.8rem', lg: '2.2rem' } }}> <h1 style={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, margin: '8px 0 16px', fontSize: '2rem' }}>
Every parcel, Every parcel,<br /> handled with ease.
<br /> handled with ease. </h1>
</Typography> <p style={{ color: 'rgba(255,255,255,0.8)', marginBottom: '32px', fontSize: '0.9rem', lineHeight: 1.5 }}>
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
Receive parcels, sort them, and send them out for delivery or transfer to another city all from one simple screen. Receive parcels, sort them, and send them out for delivery or transfer to another city all from one simple screen.
</Typography> </p>
<Stack spacing={2.5}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{[ {[
{ icon: BoltIcon, t: 'We tell you which shelf each parcel goes on' }, { icon: Zap, t: 'We tell you which shelf each parcel goes on' },
{ icon: LocalShippingOutlinedIcon, t: 'Scan parcels in as trucks arrive' }, { icon: Truck, t: 'Scan parcels in as trucks arrive' },
{ icon: VerifiedOutlinedIcon, t: 'Keep an eye on cold-storage parcels' } { icon: ShieldCheck, t: 'Keep an eye on cold-storage parcels' }
].map((f) => ( ].map((f) => (
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center"> <div key={f.t} style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<f.icon fontSize="small" /> <f.icon size={16} />
</Box> </div>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</Typography> <div style={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</div>
</Stack> </div>
))} ))}
</Stack> </div>
</Box> </div>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 3 }}> <div style={{ color: 'rgba(255,255,255,0.5)', marginTop: '24px', fontSize: '0.75rem' }}>
© 2026 Doormile Logistics Pvt. Ltd. © 2026 Doormile Logistics Pvt. Ltd.
</Typography> </div>
</Box> </div>
{/* Form Panel */} {/* Form Panel */}
<Box <div
sx={{ style={{
flexGrow: 1, flexGrow: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
p: { xs: 3, sm: 6 }, padding: '48px',
bgcolor: '#ffffff' backgroundColor: '#ffffff'
}} }}
> >
<Card <div
elevation={0} style={{
sx={{
width: '100%', width: '100%',
maxWidth: 420, maxWidth: 420,
p: { xs: 3, sm: 4.5 }, padding: '36px',
border: '1px solid #eaeaea', border: '1px solid #eaeaea',
borderRadius: 3, borderRadius: 12,
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)' boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
}} }}
> >
{/* BLACK LOGO REPLACEMENT (Mobile View) */} <div className="show-on-mobile" style={{ marginBottom: '24px', filter: 'brightness(0)' }}>
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 3, filter: 'brightness(0)' }}>
<Logo /> <Logo />
</Box> </div>
<Typography variant="h4" sx={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem' }}>Hub Sign In</Typography> <h2 style={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem', margin: '0 0 4px 0' }}>Hub Sign In</h2>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 4 }}> <p style={{ color: '#64748b', fontSize: '0.875rem', margin: '0 0 32px 0' }}>
Sign in to your Doormile Hub operations account. Sign in to your Doormile Hub operations account.
</Typography> </p>
<Stack spacing={3}> <div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Box> <div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>
Username / Email Username / Email
</Typography> </div>
<TextField <TextInput
fullWidth style={{ width: '100%' }}
placeholder="Enter your email" placeholder="Enter your email"
value={auth} value={auth}
onChange={(e) => setAuth(e.target.value)} onChange={setAuth}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()} onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
/> />
</Box> </div>
<Box> <div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>
Password Password
</Typography> </div>
<TextField <div style={{ position: 'relative' }}>
fullWidth <TextInput
type={show ? 'text' : 'password'} style={{ width: '100%', paddingRight: '40px' }}
placeholder="Enter your password" type={show ? 'text' : 'password'}
value={pwd} placeholder="Enter your password"
onChange={(e) => setPwd(e.target.value)} value={pwd}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()} onChange={setPwd}
InputProps={{ onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
endAdornment: ( />
<InputAdornment position="end"> <button
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small"> onClick={() => setShow(!show)}
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />} style={{ position: 'absolute', right: 12, top: 8, background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
</IconButton> >
</InputAdornment> {show ? <EyeOff size={18} /> : <Eye size={18} />}
) </button>
}} </div>
/> </div>
</Box>
<Stack direction="row" justifyContent="space-between" alignItems="center"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<FormControlLabel <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
control={<Checkbox defaultChecked size="small" sx={{ color: '#C01227', '&.Mui-checked': { color: '#C01227' } }} />} <CheckboxInput defaultChecked />
label={<Typography variant="body2" sx={{ color: '#555' }}>Remember me</Typography>} <span style={{ fontSize: '0.875rem', color: '#555' }}>Remember me</span>
/> </div>
<Link href="#" underline="hover" variant="body2" sx={{ color: '#C01227', fontWeight: 600 }}> <a href="#" style={{ fontSize: '0.875rem', color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}>
Forgot password? Forgot password?
</Link> </a>
</Stack> </div>
{error && ( {error && (
<Alert severity="error" sx={{ borderRadius: 2 }}> <div style={{ backgroundColor: '#fef2f2', color: '#b91c1c', padding: '12px', borderRadius: '8px', fontSize: '0.875rem' }}>
{error} {error}
</Alert> </div>
)} )}
<Button <Button
fullWidth variant="primary"
size="large"
variant="contained"
onClick={handleSignIn} onClick={handleSignIn}
disabled={loading} disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null} style={{ width: '100%', justifyContent: 'center' }}
sx={{
bgcolor: '#C01227',
color: '#fff',
py: 1.5,
fontWeight: 600,
borderRadius: 2,
textTransform: 'none',
boxShadow: 'none',
'&:hover': {
bgcolor: '#9E0E20',
boxShadow: 'none'
}
}}
> >
{loading && <Loader2 size={16} className="spin" style={{ marginRight: '8px' }} />}
{loading ? 'Signing in…' : 'Sign In'} {loading ? 'Signing in…' : 'Sign In'}
</Button> </Button>
<Box sx={{ textAlign: 'center', mt: 1 }}> <div style={{ textAlign: 'center', marginTop: '8px', fontSize: '0.875rem', color: '#64748b' }}>
<Typography variant="body2" color="text.secondary"> New to Hub Operations?{' '}
New to Hub Operations?{' '} <a
<Link href="#"
href="#" onClick={(e) => { e.preventDefault(); navigate('/signup'); }}
onClick={(e) => { e.preventDefault(); navigate('/signup'); }} style={{ color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}
underline="hover" >
sx={{ color: '#C01227', fontWeight: 600 }} Create an account
> </a>
Create an account </div>
</Link> </div>
</Typography> </div>
</Box> </div>
</Stack> <style>{`
</Card> .spin { animation: spin 1s linear infinite; }
</Box> @keyframes spin { 100% { transform: rotate(360deg); } }
</Box> @media (max-width: 768px) {
.hide-on-mobile { display: none !important; }
}
@media (min-width: 769px) {
.show-on-mobile { display: none !important; }
}
`}</style>
</div>
); );
} }

View File

@@ -1,28 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { Network, UserCheck, Shield, Eye, EyeOff } from 'lucide-react';
Box, import { TextInput } from '@astryxdesign/core/TextInput';
Card, import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
Grid,
Stack,
Typography,
TextField,
InputAdornment,
IconButton,
Button,
Link,
MenuItem,
FormControl,
Select,
Checkbox,
FormControlLabel
} from '@mui/material';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import HubIcon from '@mui/icons-material/Hub';
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import Button from '@/components/Button';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
const HUBS = [ const HUBS = [
@@ -58,17 +40,18 @@ export default function Signup() {
}; };
return ( return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100vw', bgcolor: '#ffffff', overflow: 'hidden' }}> <div style={{ display: 'flex', minHeight: '100vh', width: '100vw', backgroundColor: '#ffffff', overflow: 'hidden' }}>
{/* Brand Side Panel */} {/* Brand Side Panel */}
<Box <div
sx={{ className="hide-on-mobile"
display: { xs: 'none', md: 'flex' }, style={{
display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'space-between',
width: { md: '28%', lg: '27%', xl: '25%' }, width: '28%',
minWidth: '360px', minWidth: '360px',
p: 5, padding: '40px',
color: '#fff', color: '#fff',
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)', background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
position: 'relative', position: 'relative',
@@ -77,185 +60,172 @@ export default function Signup() {
}} }}
> >
{/* Background Decorative Circles */} {/* Background Decorative Circles */}
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} /> <div style={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} /> <div style={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
{/* BLACK LOGO REPLACEMENT (Sidebar) */} <div style={{ filter: 'brightness(0) invert(1)', display: 'inline-flex' }}>
<Box sx={{ filter: 'brightness(0) invert(0)', display: 'inline-flex' }}>
<Logo height={24} /> <Logo height={24} />
</Box> </div>
<Box sx={{ position: 'relative', my: 'auto' }}> <div style={{ position: 'relative', margin: 'auto 0' }}>
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600 }}> <div style={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600, textTransform: 'uppercase', fontSize: '0.75rem', marginBottom: '8px' }}>
Hub Registration Gateway Hub Registration Gateway
</Typography> </div>
<Typography variant="h4" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, mt: 1, mb: 2, fontSize: { md: '1.8rem', lg: '2.2rem' } }}> <h1 style={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, margin: '8px 0 16px', fontSize: '2rem' }}>
Join the Connected Join the Connected<br /> Logistics Network.
<br /> Logistics Network. </h1>
</Typography> <p style={{ color: 'rgba(255,255,255,0.8)', marginBottom: '32px', fontSize: '0.9rem', lineHeight: 1.5 }}>
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
Create an operational profile to access state-of-the-art sorting stations, live manifest creations, and miler optimization modules. Create an operational profile to access state-of-the-art sorting stations, live manifest creations, and miler optimization modules.
</Typography> </p>
<Stack spacing={2.5}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{[ {[
{ icon: HubIcon, t: 'Connect to any of the 15+ nationwide hubs' }, { icon: Network, t: 'Connect to any of the 15+ nationwide hubs' },
{ icon: AssignmentIndIcon, t: 'Role-based access controls for security' }, { icon: UserCheck, t: 'Role-based access controls for security' },
{ icon: VerifiedUserIcon, t: 'Activity logging and dispatch compliance verification' } { icon: Shield, t: 'Activity logging and dispatch compliance verification' }
].map((f) => ( ].map((f) => (
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center"> <div key={f.t} style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<f.icon fontSize="small" /> <f.icon size={16} />
</Box> </div>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</Typography> <div style={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</div>
</Stack> </div>
))} ))}
</Stack> </div>
</Box> </div>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 3 }}> <div style={{ color: 'rgba(255,255,255,0.5)', marginTop: '24px', fontSize: '0.75rem' }}>
© 2026 Doormile Logistics Pvt. Ltd. © 2026 Doormile Logistics Pvt. Ltd.
</Typography> </div>
</Box> </div>
{/* Form Panel */} {/* Form Panel */}
<Box <div
sx={{ style={{
flexGrow: 1, flexGrow: 1,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
p: { xs: 3, sm: 6 }, padding: '48px',
bgcolor: '#ffffff' backgroundColor: '#ffffff'
}} }}
> >
<Card <div
elevation={0} style={{
sx={{
width: '100%', width: '100%',
maxWidth: 480, maxWidth: 480,
p: { xs: 3, sm: 4.5 }, padding: '36px',
border: '1px solid #eaeaea', border: '1px solid #eaeaea',
borderRadius: 3, borderRadius: 12,
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)' boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
}} }}
> >
{/* BLACK LOGO REPLACEMENT (Mobile View) */} <div className="show-on-mobile" style={{ marginBottom: '24px', filter: 'brightness(0)' }}>
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 3, filter: 'brightness(0)' }}>
<Logo /> <Logo />
</Box> </div>
<Typography variant="h4" sx={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem' }}>Request Access</Typography> <h2 style={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem', margin: '0 0 4px 0' }}>Request Access</h2>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 4 }}> <p style={{ color: '#64748b', fontSize: '0.875rem', margin: '0 0 32px 0' }}>
Register your credentials to join hub operations. Register your credentials to join hub operations.
</Typography> </p>
<Box component="form" onSubmit={handleSignUp}> <form onSubmit={handleSignUp} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Stack spacing={3}> <div>
<Box> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Full Name</div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Full Name</Typography> <TextInput style={{ width: '100%' }} required placeholder="Enter full name" value={name} onChange={setName} />
<TextField fullWidth required placeholder="Enter full name" value={name} onChange={(e) => setName(e.target.value)} /> </div>
</Box>
<Box> <div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Work Email</Typography> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Work Email</div>
<TextField fullWidth required type="email" placeholder="Enter work email" value={email} onChange={(e) => setEmail(e.target.value)} /> <TextInput style={{ width: '100%' }} required type="email" placeholder="Enter work email" value={email} onChange={setEmail} />
</Box> </div>
<Grid container spacing={2}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<Grid size={{ xs: 12, sm: 6 }} > <div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Assign Hub</Typography> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Assign Hub</div>
<FormControl fullWidth> <select
<Select value={hub} onChange={(e) => setHub(e.target.value)}> value={hub}
{HUBS.map((h) => ( onChange={(e) => setHub(e.target.value)}
<MenuItem key={h.value} value={h.value}>{h.label}</MenuItem> style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.875rem' }}
))} >
</Select> {HUBS.map((h) => (
</FormControl> <option key={h.value} value={h.value}>{h.label}</option>
</Grid> ))}
<Grid size={{ xs: 12, sm: 6 }} > </select>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Select Role</Typography> </div>
<FormControl fullWidth> <div>
<Select value={role} onChange={(e) => setRole(e.target.value)}> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Select Role</div>
{ROLES.map((r) => ( <select
<MenuItem key={r.value} value={r.value}>{r.label}</MenuItem> value={role}
))} onChange={(e) => setRole(e.target.value)}
</Select> style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.875rem' }}
</FormControl> >
</Grid> {ROLES.map((r) => (
</Grid> <option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</div>
</div>
<Box> <div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Password</Typography> <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Password</div>
<TextField <div style={{ position: 'relative' }}>
fullWidth <TextInput
style={{ width: '100%', paddingRight: '40px' }}
required required
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
placeholder="Create password" placeholder="Create password"
value={pwd} value={pwd}
onChange={(e) => setPwd(e.target.value)} onChange={setPwd}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
</InputAdornment>
)
}}
/> />
</Box> <button
type="button"
onClick={() => setShow(!show)}
style={{ position: 'absolute', right: 12, top: 8, background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
>
{show ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<FormControlLabel <div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
control={<Checkbox checked={agree} onChange={(e) => setAgree(e.target.checked)} size="small" sx={{ color: '#C01227', '&.Mui-checked': { color: '#C01227' } }} required />} <CheckboxInput checked={agree} onCheckedChange={setAgree} required style={{ marginTop: '2px' }} />
label={ <div style={{ fontSize: '0.875rem', color: '#64748b', lineHeight: 1.4 }}>
<Typography variant="body2" color="text.secondary"> I agree to the{' '}
I agree to the{' '} <a href="#" style={{ color: 'var(--color-brand)', fontWeight: 500, textDecoration: 'none' }}>Terms of Service</a> and{' '}
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Terms of Service</Link> and{' '} <a href="#" style={{ color: 'var(--color-brand)', fontWeight: 500, textDecoration: 'none' }}>Operations Guidelines</a>.
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Operations Guidelines</Link>. </div>
</Typography> </div>
}
/>
<Button <Button
fullWidth variant="primary"
size="large" type="submit"
variant="contained" style={{ width: '100%', justifyContent: 'center' }}
type="submit" >
sx={{ Register Account
bgcolor: '#C01227', </Button>
color: '#fff',
py: 1.5, <div style={{ textAlign: 'center', marginTop: '8px', fontSize: '0.875rem', color: '#64748b' }}>
fontWeight: 600, Already have an account?{' '}
borderRadius: 2, <a
textTransform: 'none', href="#"
boxShadow: 'none', onClick={(e) => { e.preventDefault(); navigate('/login'); }}
'&:hover': { style={{ color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}
bgcolor: '#9E0E20',
boxShadow: 'none'
}
}}
> >
Register Account Sign In
</Button> </a>
</div>
<Box sx={{ textAlign: 'center', mt: 1 }}> </form>
<Typography variant="body2" color="text.secondary"> </div>
Already have an account?{' '} </div>
<Link <style>{`
href="#" @media (max-width: 768px) {
onClick={(e) => { e.preventDefault(); navigate('/login'); }} .hide-on-mobile { display: none !important; }
underline="hover" }
sx={{ color: '#C01227', fontWeight: 600 }} @media (min-width: 769px) {
> .show-on-mobile { display: none !important; }
Sign In }
</Link> `}</style>
</Typography> </div>
</Box>
</Stack>
</Box>
</Card>
</Box>
</Box>
); );
} }

View File

@@ -1,56 +1,57 @@
import { useState, useEffect, useCallback } from 'react'; /* eslint-disable react/prop-types */
import { import { useState, useEffect, useCallback, useMemo } from 'react';
Box, import { Truck, Plus, ArrowRight } from 'lucide-react';
Typography, import dayjs from 'dayjs';
Card,
CardContent,
CardHeader,
Grid,
Button,
Stack,
TextField,
MenuItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Snackbar,
Alert,
Avatar,
Divider,
useMediaQuery,
CircularProgress
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AddIcon from '@mui/icons-material/Add';
import SendIcon from '@mui/icons-material/Send';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import EastRoundedIcon from '@mui/icons-material/EastRounded';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
import { Card } from '@astryxdesign/core/Card';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Banner } from '@astryxdesign/core/Banner';
import { Badge } from '@astryxdesign/core/Badge';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
import { HStack, VStack } from '@astryxdesign/core/Layout';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { useToast } from '@astryxdesign/core/Toast';
import Panel from '@/components/Panel';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { getBatches, createBatch, updateBatchStatus } from '@/api/hub'; import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
import { getBatchesRange, createBatch, updateBatchStatus } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
function useMediaQuery(query) {
const [matches, setMatches] = useState(window.matchMedia(query).matches);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
const STATUS_META = { const STATUS_META = {
Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' }, Preparing: { variant: 'warning', label: 'Preparing' },
Ready: { color: '#1A73E8', bg: '#E8F0FE', label: 'Ready to send' }, Ready: { variant: 'info', label: 'Ready to send' },
Sent: { color: '#1E8E3E', bg: '#E6F4EA', label: 'Sent' } Sent: { variant: 'success', 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 API_TO_UI_STATUS = { Draft: 'Preparing', Ready: 'Ready', Dispatched: 'Sent' };
const ROUTE_OPTIONS = [
{ value: 'Transfer to Mumbai Hub', label: 'Transfer to another city — Mumbai Hub' },
{ value: 'Transfer to Bengaluru Hub', label: 'Transfer to another city — Bengaluru Hub' },
{ value: 'Local Delivery: Dwarka', label: 'Local Delivery — Dwarka' },
{ value: 'Local Delivery: Saket', label: 'Local Delivery — Saket' },
{ value: 'Local Delivery: Rohini', label: 'Local Delivery — Rohini' }
];
const timeAgo = (iso, verb = 'Created') => { const timeAgo = (iso, verb = 'Created') => {
if (!iso) return `${verb} recently`; if (!iso) return `${verb} recently`;
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
@@ -63,13 +64,22 @@ const timeAgo = (iso, verb = 'Created') => {
return `${verb} ${Math.round(hrs / 24)} d ago`; return `${verb} ${Math.round(hrs / 24)} d ago`;
}; };
// Route/destination color per batch status — same categorical tokens as
// Badge, so the highlighted destination reads as part of the same status
// language as the rest of the app instead of a one-off hex value.
const destinationTone = (variant) => {
if (variant === 'warning') return 'var(--color-warning)';
if (variant === 'info') return 'var(--color-text-primary)';
return 'var(--color-icon-green)';
};
export default function Dispatch() { export default function Dispatch() {
const theme = useTheme(); const isMdDown = useMediaQuery('(max-width: 900px)');
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext(); const hub = getHubContext();
const hubName = hub.hubname || 'this hub'; const hubName = hub.hubname || 'this hub';
const toast = useToast();
const notify = (msg, status = 'success') => toast({ body: msg, type: status });
// Real backend: tripsheetno / route / destination / item_count / kind (no vehicle field).
const mapBatch = useCallback( const mapBatch = useCallback(
(b) => ({ (b) => ({
tripsheetid: b.tripsheetid, tripsheetid: b.tripsheetid,
@@ -94,27 +104,32 @@ export default function Dispatch() {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState(null); const [busyId, setBusyId] = useState(null);
// Create form state const today = dayjs().format(DATE_FMT);
const [range, setRange] = useState({ from: today, to: today });
const isToday = range.from === today && range.to === today;
const rangeLabel = isToday
? 'today'
: range.from === range.to
? dayjs(range.from).format('DD MMM')
: `${dayjs(range.from).format('DD MMM')} ${dayjs(range.to).format('DD MMM')}`;
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub'); const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
const [newDestination, setNewDestination] = useState(''); const [newDestination, setNewDestination] = useState('');
const [newVehicle, setNewVehicle] = useState(''); const [newVehicle, setNewVehicle] = useState('');
const [newPkgsCount, setNewPkgsCount] = useState('5'); const [newPkgsCount, setNewPkgsCount] = useState('5');
// Toast state
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { try {
const res = await getBatches(); const res = await getBatchesRange(range.from, range.to);
setManifests((res?.data || []).map(mapBatch)); setManifests((res?.data || []).map(mapBatch));
} catch (err) { } catch (err) {
setLoadError(err?.message || 'Could not load batches.'); setLoadError(err?.message || 'Could not load batches.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [mapBatch]); }, [mapBatch, range.from, range.to]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -124,7 +139,7 @@ export default function Dispatch() {
e.preventDefault(); e.preventDefault();
if (creating) return; if (creating) return;
if (!newVehicle.trim() || !newDestination.trim()) { if (!newVehicle.trim() || !newDestination.trim()) {
setToast({ open: true, msg: 'Enter the destination and the miler / vehicle.', severity: 'warning' }); notify('Enter the destination and the miler / vehicle.', 'warning');
return; return;
} }
const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local'; const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local';
@@ -141,16 +156,15 @@ export default function Dispatch() {
setOpenModal(false); setOpenModal(false);
setNewVehicle(''); setNewVehicle('');
setNewDestination(''); setNewDestination('');
setToast({ open: true, msg: `Batch ${label} created successfully`, severity: 'success' }); notify(`Batch ${label} created successfully`);
load(); load();
} catch (err) { } catch (err) {
setToast({ open: true, msg: err?.message || 'Could not create the batch.', severity: 'error' }); notify(err?.message || 'Could not create the batch.', 'error');
} finally { } finally {
setCreating(false); setCreating(false);
} }
}; };
// Move a batch to the next status via PATCH.
const advance = async (m, apiStatus, uiStatus, okMsg) => { const advance = async (m, apiStatus, uiStatus, okMsg) => {
if (busyId) return; if (busyId) return;
setBusyId(m.tripsheetid); setBusyId(m.tripsheetid);
@@ -163,9 +177,9 @@ export default function Dispatch() {
: x : x
) )
); );
setToast({ open: true, msg: okMsg, severity: 'success' }); notify(okMsg);
} catch (err) { } catch (err) {
setToast({ open: true, msg: err?.message || 'Could not update this batch.', severity: 'error' }); notify(err?.message || 'Could not update this batch.', 'error');
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -174,238 +188,259 @@ export default function Dispatch() {
const handleSeal = (m) => advance(m, 'Ready', 'Ready', `Batch ${m.id} checked and ready to send.`); 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.`); 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 BatchAction = ({ m, fullWidth }) => {
const isBusy = busyId === m.tripsheetid; const isBusy = busyId === m.tripsheetid;
if (m.status === 'Preparing') { if (m.status === 'Preparing') {
return ( return (
<Button size="small" variant="outlined" color="info" fullWidth={fullWidth} disabled={isBusy} onClick={() => handleSeal(m)} <Button variant="secondary" size="sm" style={{ width: fullWidth ? '100%' : 'auto', justifyContent: 'center' }} disabled={isBusy} onClick={() => handleSeal(m)}>
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : null} sx={{ borderRadius: 2, fontWeight: 700 }}> {isBusy ? 'Working...' : 'Check & Mark Ready'}
Check &amp; Mark Ready
</Button> </Button>
); );
} }
if (m.status === 'Ready') { if (m.status === 'Ready') {
return ( return (
<Button size="small" variant="contained" color="success" fullWidth={fullWidth} disabled={isBusy} <Button variant="primary" size="sm" style={{ width: fullWidth ? '100%' : 'auto', justifyContent: 'center' }} disabled={isBusy} onClick={() => handleDispatch(m)}>
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : <SendIcon sx={{ fontSize: 16 }} />} onClick={() => handleDispatch(m)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}> {isBusy ? 'Working...' : 'Send Out'}
Send Out
</Button> </Button>
); );
} }
return <Chip size="small" icon={<CheckCircleIcon />} label="Sent" color="success" variant="outlined" sx={{ fontWeight: 700 }} />; return <Badge variant="success" label="Sent" />;
}; };
// Origin → destination journey, reused in cards and table
const Journey = ({ m }) => { const Journey = ({ m }) => {
const meta = STATUS_META[m.status] || STATUS_META.Preparing; const meta = STATUS_META[m.status] || STATUS_META.Preparing;
return ( return (
<Stack spacing={0.75}> <VStack gap={0.5}>
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75 }}> <HStack gap={1.5} align="center" wrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{m.origin}</Typography> <Text type="body" weight="bold">{m.origin}</Text>
<EastRoundedIcon sx={{ fontSize: 16, color: '#ADB5BD' }} /> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><ArrowRight size={14} /></span>
<Typography variant="body2" sx={{ fontWeight: 700, color: meta.color }}>{m.destination}</Typography> <Text type="body" weight="bold" style={{ color: destinationTone(meta.variant) }}>{m.destination}</Text>
</Stack> </HStack>
<Typography variant="caption" color="text.secondary">{m.currentLoc}</Typography> <Text type="supporting" color="secondary">{m.currentLoc}</Text>
</Stack> </VStack>
); );
}; };
const columns = useMemo(() => [
{
key: 'batch',
header: 'Batch',
width: proportional(1.2),
renderCell: (row) => (
<VStack gap={0.5}>
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
<Text type="supporting" color="secondary">{row.time}</Text>
</VStack>
)
},
{
key: 'journey',
header: 'Journey',
width: proportional(2),
renderCell: (row) => <Journey m={row} />
},
{
key: 'vehicle',
header: 'Miler / Vehicle',
width: proportional(1),
renderCell: (row) => <Text type="body" weight="semibold">{row.vehicle}</Text>
},
{
key: 'parcels',
header: 'Parcels',
width: pixel(90),
renderCell: (row) => <Text type="body" weight="bold">{row.packagesCount}</Text>
},
{
key: 'status',
header: 'Status',
width: pixel(120),
renderCell: (row) => {
const meta = STATUS_META[row.status] || STATUS_META.Preparing;
return <Badge variant={meta.variant} label={meta.label} />;
}
},
{
key: 'actions',
header: '',
width: pixel(160),
align: 'end',
renderCell: (row) => <BatchAction m={row} />
}
], []);
return ( return (
<Box> <div style={{ paddingBottom: '32px' }}>
<PageHeader <PageHeader
icon={LocalShippingIcon} icon={Truck}
title="Dispatch & Transfer" title="Dispatch & Transfer"
subtitle="Group parcels that go out together, check them, and send them either out for local delivery or transferred to another city hub." subtitle="Group parcels that go out together, check them, and send them either out for local delivery or transferred to another city hub."
action={<DateRangePicker value={range} onChange={setRange} />}
/> />
<Grid container spacing={3}> <Panel>
{/* Manifest Actions & List */} <div
<Grid size={{ xs: 12 }} > style={{
<Card> padding: '16px 20px',
<CardHeader borderBottom: '1px solid var(--color-border)',
title="Outgoing Batches" display: 'flex',
subheader={`Each batch is a group of parcels leaving ${hubName} together`} alignItems: 'center',
action={ justifyContent: 'space-between',
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}> flexWrap: 'wrap',
New Batch gap: '16px'
</Button> }}
} >
/> <HStack gap={3} align="center">
<Divider /> <span
style={{
{loadError && ( background: 'var(--color-background-blue)',
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> color: 'var(--color-icon-blue)',
{loadError} width: 36,
</Alert> height: 36,
)} borderRadius: 'var(--radius-element)',
display: 'flex',
{loading ? ( alignItems: 'center',
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}> justifyContent: 'center',
<CircularProgress /> flexShrink: 0
</Box> }}
) : manifests.length === 0 && !loadError ? ( >
<Box sx={{ py: 8, textAlign: 'center' }}> <Truck size={18} />
<LocalShippingIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> </span>
<Typography variant="body2" color="text.secondary">No outgoing batches yet. Create one to get started.</Typography> <VStack gap={0}>
</Box> <Heading level={4} style={{ margin: 0 }}>Outgoing Batches</Heading>
) : isMdDown ? ( <Text type="supporting" color="secondary">
/* ── MOBILE / TABLET: spacious cards ── */ {isToday
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}> ? `Each batch is a group of parcels leaving ${hubName} together`
{manifests.map((m) => { : `${manifests.length} batch${manifests.length === 1 ? '' : 'es'} · ${rangeLabel}`}
const meta = STATUS_META[m.status] || STATUS_META.Preparing; </Text>
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon; </VStack>
return ( </HStack>
<Card key={m.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}> <Button variant="primary" onClick={() => setOpenModal(true)} icon={<Plus size={16} />}>
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}> New Batch
<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: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}>
<KindIcon sx={{ fontSize: 20 }} />
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{m.id}</Typography>
<Typography variant="caption" color="text.secondary">{m.time}</Typography>
</Box>
</Stack>
<Chip size="small" label={meta.label} sx={{ fontWeight: 700, bgcolor: meta.bg, color: meta.color, flexShrink: 0 }} />
</Stack>
<Box sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
<Journey m={m} />
</Box>
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}>
<Chip size="small" icon={<TwoWheelerOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={m.vehicle}
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, maxWidth: '100%', '& .MuiChip-icon': { color: '#9AA0A6' } }} />
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '15px !important' }} />} label={`${m.packagesCount} parcels`}
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
</Stack>
<BatchAction m={m} fullWidth />
</CardContent>
</Card>
);
})}
</Box>
) : (
/* ── DESKTOP: spacious table ── */
<TableContainer sx={{ overflowX: 'auto' }}>
<Table sx={{ minWidth: 820 }}>
<TableHead>
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
{['Batch', 'Journey', 'Miler / Vehicle', 'Parcels', 'Status', 'Action'].map((h, i) => (
<TableCell key={h} align={i === 3 ? 'center' : i === 5 ? '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>
{manifests.map((m) => {
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon;
return (
<TableRow key={m.id} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8', py: 2.25 }, '&:last-child td': { border: 0 } }}>
<TableCell>
<Stack direction="row" alignItems="center" gap={1.5}>
<Avatar variant="rounded" sx={{ bgcolor: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}>
<KindIcon sx={{ fontSize: 20 }} />
</Avatar>
<Box>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{m.id}</Typography>
<Typography variant="caption" color="text.secondary">{m.time}</Typography>
</Box>
</Stack>
</TableCell>
<TableCell sx={{ minWidth: 240 }}><Journey m={m} /></TableCell>
<TableCell sx={{ color: '#495057', fontWeight: 600, maxWidth: 200 }}>{m.vehicle}</TableCell>
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 700, color: '#1A1A2E' }}>{m.packagesCount}</TableCell>
<TableCell>
<Chip size="small" label={meta.label} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: meta.bg, color: meta.color }} />
</TableCell>
<TableCell align="right"><BatchAction m={m} /></TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
</Grid>
</Grid>
{/* Creation Modal */}
<Dialog open={openModal} onClose={() => setOpenModal(false)} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 700 }}>Create a New Batch</DialogTitle>
<DialogContent>
<Box component="form" onSubmit={handleCreateManifest} sx={{ mt: 1 }}>
<Stack spacing={2.5}>
<TextField
select
fullWidth
label="Where is this batch going?"
value={newRoute}
onChange={(e) => setNewRoute(e.target.value)}
>
<MenuItem value="Transfer to Mumbai Hub">Transfer to another city Mumbai Hub</MenuItem>
<MenuItem value="Transfer to Bengaluru Hub">Transfer to another city Bengaluru Hub</MenuItem>
<MenuItem value="Local Delivery: Dwarka">Local Delivery Dwarka</MenuItem>
<MenuItem value="Local Delivery: Saket">Local Delivery Saket</MenuItem>
<MenuItem value="Local Delivery: Rohini">Local Delivery Rohini</MenuItem>
</TextField>
<TextField
fullWidth
label="Full destination address / hub"
placeholder="e.g. Dwarka Sector 12, Delhi"
value={newDestination}
onChange={(e) => setNewDestination(e.target.value)}
required
/>
<TextField
fullWidth
label="Miler name or vehicle number"
placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092"
value={newVehicle}
onChange={(e) => setNewVehicle(e.target.value)}
required
/>
<TextField
fullWidth
type="number"
label="How many parcels?"
value={newPkgsCount}
onChange={(e) => setNewPkgsCount(e.target.value)}
inputProps={{ min: 1 }}
required
/>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<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> </Button>
</DialogActions> </div>
</Dialog>
<Snackbar {loadError && (
open={toast.open} <div style={{ padding: '16px' }}>
autoHideDuration={4000} <Banner status="error" title="Error" description={loadError} />
onClose={() => setToast({ ...toast, open: false })} </div>
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} )}
>
<Alert severity={toast.severity} onClose={() => setToast({ ...toast, open: false })} sx={{ width: '100%' }}> {loading ? (
{toast.msg} <div style={{ padding: '48px', textAlign: 'center' }}>
</Alert> <Text type="body" color="secondary">Loading...</Text>
</Snackbar> </div>
</Box> ) : manifests.length === 0 && !loadError ? (
<div style={{ padding: '48px', textAlign: 'center' }}>
<Text type="body" color="secondary">
{isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`}
</Text>
</div>
) : isMdDown ? (
/* ── MOBILE / TABLET: cards ── */
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{manifests.map((m) => {
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
return (
<Card key={m.id} padding={3} style={{ border: '1px solid var(--color-border)' }}>
<HStack justify="between" align="center" style={{ marginBottom: '10px' }}>
<VStack gap={0}>
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{m.id}</Text>
<Text type="supporting" color="secondary">{m.time}</Text>
</VStack>
<Badge variant={meta.variant} label={meta.label} />
</HStack>
<div style={{ padding: '10px 12px', background: 'var(--color-background-muted)', borderRadius: 'var(--radius-inner)', marginBottom: '12px' }}>
<Journey m={m} />
</div>
<HStack gap={4} style={{ marginBottom: '12px' }}>
<VStack gap={0}>
<Text type="supporting" color="secondary">Miler / Vehicle</Text>
<Text type="body" weight="semibold">{m.vehicle}</Text>
</VStack>
<VStack gap={0}>
<Text type="supporting" color="secondary">Parcels</Text>
<Text type="body" weight="semibold">{m.packagesCount}</Text>
</VStack>
</HStack>
<BatchAction m={m} fullWidth />
</Card>
);
})}
</div>
) : (
/* ── DESKTOP: table ── */
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<Table
data={manifests}
columns={columns}
idKey="tripsheetid"
density="balanced"
dividers="rows"
hasHover
/>
</div>
)}
</Panel>
{/* Create Batch Dialog */}
<Dialog isOpen={openModal} onOpenChange={setOpenModal} width={480}>
<form onSubmit={handleCreateManifest}>
<Layout
header={<DialogHeader title="Create a New Batch" onOpenChange={setOpenModal} />}
content={
<LayoutContent>
<VStack gap={4} style={{ padding: '24px' }}>
<Selector
label="Where is this batch going?"
options={ROUTE_OPTIONS}
value={newRoute}
onChange={setNewRoute}
/>
<TextInput
label="Full destination address / hub"
placeholder="e.g. Dwarka Sector 12, Delhi"
value={newDestination}
onChange={(e) => setNewDestination(e.target.value)}
required
/>
<TextInput
label="Miler name or vehicle number"
placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092"
value={newVehicle}
onChange={(e) => setNewVehicle(e.target.value)}
required
/>
<TextInput
type="number"
label="How many parcels?"
value={newPkgsCount}
onChange={(e) => setNewPkgsCount(e.target.value)}
min="1"
required
/>
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', width: '100%', padding: '16px 24px' }}>
<Button variant="ghost" onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button>
<Button variant="primary" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create Batch'}
</Button>
</div>
</LayoutFooter>
}
/>
</form>
</Dialog>
</div>
); );
} }

View File

@@ -1,25 +1,43 @@
import { useState, useEffect, useCallback } from 'react'; /* eslint-disable react/prop-types */
import { import { useState, useEffect, useCallback, useMemo } from 'react';
Box, Card, CardContent, CardHeader, Button, Stack, Typography, Divider, Avatar, Chip, import { Settings, Warehouse, Plus, UserPlus, Eye, EyeOff } from 'lucide-react';
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 { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { HStack, VStack } from '@astryxdesign/core/Layout';
import { Badge } from '@astryxdesign/core/Badge';
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Banner } from '@astryxdesign/core/Banner';
import { Spinner } from '@astryxdesign/core/Spinner';
import { useToast } from '@astryxdesign/core/Toast';
import Panel from '@/components/Panel';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { getHubs, createHub, createStaff } from '@/api/hub'; import { getHubs, createHub, createStaff } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
const BRAND = '#C01227'; function useMediaQuery(query) {
const [matches, setMatches] = useState(window.matchMedia(query).matches);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
const HUB_TYPES = [ const HUB_TYPES = [
{ value: 'sorting_center', label: 'Sorting Center' }, { value: 'sorting_center', label: 'Sorting Center' },
{ value: 'delivery_hub', label: 'Delivery Hub' }, { value: 'delivery_hub', label: 'Delivery Hub' },
@@ -27,20 +45,24 @@ const HUB_TYPES = [
{ value: 'warehouse', label: 'Warehouse' } { value: 'warehouse', label: 'Warehouse' }
]; ];
const EMPTY_HUB = { hubname: '', hubtype: 'spoke', capacity: '30', contact: '', address: '', pincode: '' }; const EMPTY_HUB = { hubname: '', hubtype: 'spoke', capacity: 30, contact: '', address: '', pincode: '' };
const EMPTY_STAFF = { hubid: '', email: '', password: '', displayname: '' }; const EMPTY_STAFF = { hubid: '', email: '', password: '', displayname: '' };
const prettyType = (t) => HUB_TYPES.find((h) => h.value === t)?.label || t || '—'; const prettyType = (t) => HUB_TYPES.find((h) => h.value === t)?.label || t || '—';
function StaffBadge({ has }) {
return has ? <Badge variant="success" label="Has login" /> : <Badge variant="warning" label="No login" />;
}
export default function HubSettings() { export default function HubSettings() {
const theme = useTheme(); const isMdDown = useMediaQuery('(max-width: 900px)');
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext(); const hub = getHubContext();
const toast = useToast();
const notify = (msg, status = 'success') => toast({ body: msg, type: status });
const [hubs, setHubs] = useState([]); const [hubs, setHubs] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const [hubDialog, setHubDialog] = useState(false); const [hubDialog, setHubDialog] = useState(false);
const [hubForm, setHubForm] = useState(EMPTY_HUB); const [hubForm, setHubForm] = useState(EMPTY_HUB);
@@ -51,8 +73,6 @@ export default function HubSettings() {
const [showPwd, setShowPwd] = useState(false); const [showPwd, setShowPwd] = useState(false);
const [savingStaff, setSavingStaff] = useState(false); const [savingStaff, setSavingStaff] = useState(false);
const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity });
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
@@ -80,7 +100,8 @@ export default function HubSettings() {
setStaffDialog(true); setStaffDialog(true);
}; };
const submitHub = async () => { const submitHub = async (e) => {
e.preventDefault();
if (savingHub) return; if (savingHub) return;
if (!hubForm.hubname.trim()) { if (!hubForm.hubname.trim()) {
notify('Enter a hub name.', 'warning'); notify('Enter a hub name.', 'warning');
@@ -91,7 +112,7 @@ export default function HubSettings() {
await createHub({ await createHub({
hubname: hubForm.hubname.trim(), hubname: hubForm.hubname.trim(),
hubtype: hubForm.hubtype, hubtype: hubForm.hubtype,
capacity: parseInt(hubForm.capacity, 10) || 0, capacity: hubForm.capacity || 0,
contact: hubForm.contact.trim(), contact: hubForm.contact.trim(),
address: hubForm.address.trim(), address: hubForm.address.trim(),
pincode: hubForm.pincode.trim() pincode: hubForm.pincode.trim()
@@ -106,7 +127,8 @@ export default function HubSettings() {
} }
}; };
const submitStaff = async () => { const submitStaff = async (e) => {
e.preventDefault();
if (savingStaff) return; if (savingStaff) return;
if (!staffForm.hubid || !staffForm.email.trim() || !staffForm.password) { if (!staffForm.hubid || !staffForm.email.trim() || !staffForm.password) {
notify('Hub, email and password are all required.', 'warning'); notify('Hub, email and password are all required.', 'warning');
@@ -131,184 +153,209 @@ export default function HubSettings() {
} }
}; };
const StaffChip = ({ has }) => const columns = useMemo(() => [
has ? ( {
<Chip size="small" icon={<CheckCircleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="Has login" key: 'hubname',
sx={{ fontWeight: 700, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} /> header: 'Hub',
) : ( width: proportional(1.4),
<Chip size="small" icon={<HighlightOffOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="No login" renderCell: (h) => <Text type="body" weight="bold">{h.hubname}</Text>
sx={{ fontWeight: 700, bgcolor: '#FEF7E0', color: '#B06000', '& .MuiChip-icon': { color: '#B06000' } }} /> },
); {
key: 'hubtype',
header: 'Type',
width: proportional(1),
renderCell: (h) => <Text type="body">{prettyType(h.hubtype)}</Text>
},
{
key: 'capacity',
header: 'Capacity',
width: pixel(110),
renderCell: (h) => <Text type="body">{h.capacity}</Text>
},
{
key: 'status',
header: 'Status',
width: pixel(120),
renderCell: (h) => <Badge variant="blue" label={h.status || 'active'} />
},
{
key: 'staff',
header: 'Staff Login',
width: pixel(140),
renderCell: (h) => <StaffBadge has={h.has_staff ?? h.has_staff_account} />
}
], []);
return ( return (
<Box> <div style={{ paddingBottom: '32px' }}>
<PageHeader <PageHeader
icon={SettingsRoundedIcon} icon={Settings}
title="Hub Settings" title="Hub Settings"
subtitle={`Create and manage hubs and staff logins${hub.city ? ` across ${hub.city}` : ''}. Doormile staff only.`} subtitle={`Create and manage hubs and staff logins${hub.city ? ` across ${hub.city}` : ''}. Doormile staff only.`}
/> />
<Card> <Panel>
<CardHeader <div
title={`Hubs in ${hub.city || 'your city'}`} style={{
subheader="Every hub Doormile operates here" padding: '16px 20px',
avatar={<Avatar variant="rounded" sx={{ bgcolor: '#C0122710', color: BRAND, borderRadius: 2 }}><WarehouseOutlinedIcon /></Avatar>} borderBottom: '1px solid var(--color-border)',
action={ display: 'flex',
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}> alignItems: 'center',
<Button variant="outlined" startIcon={<PersonAddAlt1OutlinedIcon />} onClick={openStaffDialog} disabled={hubs.length === 0} justifyContent: 'space-between',
sx={{ borderRadius: 2, fontWeight: 700 }}> flexWrap: 'wrap',
Add Staff gap: '16px'
</Button> }}
<Button variant="contained" startIcon={<AddIcon />} onClick={openHubDialog} >
sx={{ borderRadius: 2, fontWeight: 700, bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}> <HStack gap={3} align="center">
Add New Hub <span
</Button> style={{
</Stack> background: 'var(--color-background-blue)',
} color: 'var(--color-icon-blue)',
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }} width: 40,
/> height: 40,
<Divider /> borderRadius: 'var(--radius-element)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
>
<Warehouse size={20} />
</span>
<VStack gap={0.5}>
<Text type="body" weight="bold">Hubs in {hub.city || 'your city'}</Text>
<Text type="supporting" color="secondary">Every hub Doormile operates here</Text>
</VStack>
</HStack>
<HStack gap={2}>
<Button variant="secondary" icon={<UserPlus size={16} />} onClick={openStaffDialog} disabled={hubs.length === 0}>Add Staff</Button>
<Button variant="primary" icon={<Plus size={16} />} onClick={openHubDialog}>Add New Hub</Button>
</HStack>
</div>
{loadError && ( {loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> <div style={{ padding: '16px' }}>
{loadError} <Banner status="error" title="Error" description={loadError} />
</Alert> </div>
)} )}
{loading ? ( {loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}> <div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
<CircularProgress /> <Spinner label="Loading hubs" size="lg" />
</Box> </div>
) : hubs.length === 0 && !loadError ? ( ) : hubs.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}> <EmptyState
<WarehouseOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Warehouse size={48} /></span>}
<Typography variant="body2" color="text.secondary">No hubs yet. Add your first hub.</Typography> title="No hubs yet"
</Box> description="Add your first hub to get started."
style={{ padding: '48px 0' }}
/>
) : isMdDown ? ( ) : isMdDown ? (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}> <div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{hubs.map((h) => ( {hubs.map((h) => (
<Card key={h.hubid} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}> <Card key={h.hubid} padding={3} style={{ border: '1px solid var(--color-border)' }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}> <HStack justify="between" align="start" gap={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}> <VStack gap={0}>
<Box> <Text type="body" weight="bold">{h.hubname}</Text>
<Typography sx={{ fontWeight: 800, color: '#1A1A2E' }}>{h.hubname}</Typography> <Text type="supporting" color="secondary">{prettyType(h.hubtype)} · Cap {h.capacity}</Text>
<Typography variant="caption" color="text.secondary">{prettyType(h.hubtype)} · Cap {h.capacity}</Typography> </VStack>
</Box> <StaffBadge has={h.has_staff ?? h.has_staff_account} />
<StaffChip has={(h.has_staff ?? h.has_staff_account)} /> </HStack>
</Stack>
</CardContent>
</Card> </Card>
))} ))}
</Box> </div>
) : ( ) : (
<TableContainer> <div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<Table sx={{ minWidth: 640 }}> <Table data={hubs} columns={columns} idKey="hubid" density="balanced" dividers="rows" hasHover />
<TableHead> </div>
<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> </Panel>
{/* Create Hub dialog */} {/* Create Hub dialog */}
<Dialog open={hubDialog} onClose={() => setHubDialog(false)} fullWidth maxWidth="sm"> <Dialog isOpen={hubDialog} onOpenChange={setHubDialog} width={480} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>Add a New Hub</DialogTitle> <form onSubmit={submitHub}>
<DialogContent> <Layout
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}> header={<DialogHeader title="Add a New Hub" onOpenChange={setHubDialog} />}
The city is set automatically from your hub it cant be changed here. content={
</Typography> <LayoutContent>
<Stack spacing={2.5} sx={{ mt: 0.5 }}> <VStack gap={4} style={{ padding: '24px' }}>
<TextField fullWidth label="Hub name" value={hubForm.hubname} required <Text type="supporting" color="secondary">The city is set automatically from your hub it can&rsquo;t be changed here.</Text>
onChange={(e) => setHubForm((f) => ({ ...f, hubname: e.target.value }))} /> <TextInput label="Hub name" value={hubForm.hubname} isRequired onChange={(e) => setHubForm((f) => ({ ...f, hubname: e.target.value }))} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> <HStack gap={3} wrap="wrap">
<TextField select fullWidth label="Hub type" value={hubForm.hubtype} <div style={{ flex: '1 1 180px' }}>
onChange={(e) => setHubForm((f) => ({ ...f, hubtype: e.target.value }))}> <Selector label="Hub type" options={HUB_TYPES} value={hubForm.hubtype} onChange={(v) => setHubForm((f) => ({ ...f, hubtype: v }))} />
{HUB_TYPES.map((t) => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)} </div>
</TextField> <div style={{ flex: '1 1 140px' }}>
<TextField fullWidth type="number" label="Capacity" value={hubForm.capacity} inputProps={{ min: 0 }} <NumberInput label="Capacity" value={hubForm.capacity} min={0} onChange={(v) => setHubForm((f) => ({ ...f, capacity: v }))} />
onChange={(e) => setHubForm((f) => ({ ...f, capacity: e.target.value }))} /> </div>
</Stack> </HStack>
<TextField fullWidth label="Contact number" value={hubForm.contact} <TextInput label="Contact number" value={hubForm.contact} onChange={(e) => setHubForm((f) => ({ ...f, contact: e.target.value }))} />
onChange={(e) => setHubForm((f) => ({ ...f, contact: e.target.value }))} /> <TextInput label="Address" value={hubForm.address} onChange={(e) => setHubForm((f) => ({ ...f, address: e.target.value }))} />
<TextField fullWidth label="Address" value={hubForm.address} <TextInput label="Pincode" value={hubForm.pincode} onChange={(e) => setHubForm((f) => ({ ...f, pincode: e.target.value }))} />
onChange={(e) => setHubForm((f) => ({ ...f, address: e.target.value }))} /> </VStack>
<TextField fullWidth label="Pincode" value={hubForm.pincode} </LayoutContent>
onChange={(e) => setHubForm((f) => ({ ...f, pincode: e.target.value }))} /> }
</Stack> footer={
</DialogContent> <LayoutFooter hasDivider>
<DialogActions> <HStack justify="end" gap={2} style={{ width: '100%', padding: '16px 24px' }}>
<Button onClick={() => setHubDialog(false)} disabled={savingHub}>Cancel</Button> <Button variant="ghost" onClick={() => setHubDialog(false)} disabled={savingHub}>Cancel</Button>
<Button variant="contained" onClick={submitHub} disabled={savingHub} <Button variant="primary" type="submit" disabled={savingHub}>{savingHub ? 'Creating…' : 'Create Hub'}</Button>
startIcon={savingHub ? <CircularProgress size={16} color="inherit" /> : null} </HStack>
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}> </LayoutFooter>
{savingHub ? 'Creating…' : 'Create Hub'} }
</Button> />
</DialogActions> </form>
</Dialog> </Dialog>
{/* Create Staff dialog */} {/* Create Staff dialog */}
<Dialog open={staffDialog} onClose={() => setStaffDialog(false)} fullWidth maxWidth="sm"> <Dialog isOpen={staffDialog} onOpenChange={setStaffDialog} width={480} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>Add a Hub Staff Login</DialogTitle> <form onSubmit={submitStaff}>
<DialogContent> <Layout
<Stack spacing={2.5} sx={{ mt: 1 }}> header={<DialogHeader title="Add a Hub Staff Login" onOpenChange={setStaffDialog} />}
<TextField select fullWidth label="Hub" value={staffForm.hubid} required content={
onChange={(e) => setStaffForm((f) => ({ ...f, hubid: e.target.value }))}> <LayoutContent>
{hubs.map((h) => <MenuItem key={h.hubid} value={h.hubid}>{h.hubname}</MenuItem>)} <VStack gap={4} style={{ padding: '24px' }}>
</TextField> <Selector
<TextField fullWidth label="Display name" value={staffForm.displayname} label="Hub"
onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} /> isRequired
<TextField fullWidth type="email" label="Email" value={staffForm.email} required options={hubs.map((h) => ({ value: String(h.hubid), label: h.hubname }))}
onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} /> value={String(staffForm.hubid)}
<TextField fullWidth type={showPwd ? 'text' : 'password'} label="Password" value={staffForm.password} required onChange={(v) => setStaffForm((f) => ({ ...f, hubid: v }))}
onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))} />
InputProps={{ <TextInput label="Display name" value={staffForm.displayname} onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} />
endAdornment: ( <TextInput type="email" label="Email" value={staffForm.email} isRequired onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} />
<InputAdornment position="end"> <HStack gap={2} align="end">
<IconButton onClick={() => setShowPwd((s) => !s)} edge="end" size="small"> <div style={{ flex: 1 }}>
{showPwd ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />} <TextInput
</IconButton> type={showPwd ? 'text' : 'password'}
</InputAdornment> label="Password"
) value={staffForm.password}
}} /> isRequired
</Stack> onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))}
</DialogContent> />
<DialogActions> </div>
<Button onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button> <IconButton
<Button variant="contained" onClick={submitStaff} disabled={savingStaff} label={showPwd ? 'Hide password' : 'Show password'}
startIcon={savingStaff ? <CircularProgress size={16} color="inherit" /> : null} tooltip={showPwd ? 'Hide password' : 'Show password'}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}> icon={showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
{savingStaff ? 'Creating…' : 'Create Login'} variant="secondary"
</Button> onClick={() => setShowPwd((s) => !s)}
</DialogActions> />
</HStack>
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack justify="end" gap={2} style={{ width: '100%', padding: '16px 24px' }}>
<Button variant="ghost" onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button>
<Button variant="primary" type="submit" disabled={savingStaff}>{savingStaff ? 'Creating…' : 'Create Login'}</Button>
</HStack>
</LayoutFooter>
}
/>
</form>
</Dialog> </Dialog>
</div>
<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,28 +1,26 @@
import { useState, useMemo, useEffect, useCallback } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import { import {
Box, Typography, Card, CardContent, Grid, TextField, Button, Stack, QrCode, Truck, CheckCircle2, ArrowRight, Inbox,
MenuItem, Table, TableBody, TableCell, TableContainer, TableHead, Package, AlertTriangle, Snowflake,
TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider, Warehouse, MapPin, Loader2, Flag
useMediaQuery, CircularProgress } from 'lucide-react';
} from '@mui/material'; import dayjs from 'dayjs';
import { useTheme } from '@mui/material/styles'; import { TextInput } from '@astryxdesign/core/TextInput';
import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined'; import { Heading, Text } from '@astryxdesign/core/Text';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import { Button } from '@astryxdesign/core/Button';
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; import { Card } from '@astryxdesign/core/Card';
import EastOutlinedIcon from '@mui/icons-material/EastOutlined'; import { FormLayout } from '@astryxdesign/core/FormLayout';
import MoveToInboxOutlinedIcon from '@mui/icons-material/MoveToInboxOutlined'; import { Field } from '@astryxdesign/core/Field';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import { Table, proportional, pixel } from '@astryxdesign/core/Table';
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'; import { Badge } from '@astryxdesign/core/Badge';
import AcUnitOutlinedIcon from '@mui/icons-material/AcUnitOutlined'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined'; import { useToast } from '@astryxdesign/core/Toast';
import ThermostatOutlinedIcon from '@mui/icons-material/ThermostatOutlined'; import { Banner } from '@astryxdesign/core/Banner';
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { getInboundToday, inboundBooking } from '@/api/hub'; import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
import StatCard from '@/components/StatCard';
import { getInboundRange, inboundBooking } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone C (Cold Room)', 'Exception Area']; const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone C (Cold Room)', 'Exception Area'];
@@ -34,11 +32,10 @@ const ORIGINS = [
{ value: 'Client Pickup', label: 'Direct Client Pickup (Local)' } { value: 'Client Pickup', label: 'Direct Client Pickup (Local)' }
]; ];
// Turn an ISO timestamp into a friendly "10 min ago" label.
const timeAgo = (iso) => { const timeAgo = (iso) => {
if (!iso) return 'Recently'; if (!iso) return 'Recently';
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
if (Number.isNaN(then) || then < 1420070400000) return 'Recently'; // guard zero/0001 dates if (Number.isNaN(then) || then < 1420070400000) return 'Recently';
const mins = Math.round((Date.now() - then) / 60000); const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return 'Just now'; if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`; if (mins < 60) return `${mins} min ago`;
@@ -47,11 +44,7 @@ const timeAgo = (iso) => {
return `${Math.round(hrs / 24)} d 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 mapInbound = (row, hubName) => {
// Backend GET /hub/inbound/today sends: originname, destinationname, updatedat.
// (chargeableweight is NOT sent by this endpoint yet — see the note to backend.)
const w = row.chargeableweight ?? row.deadweight ?? row.weight; const w = row.chargeableweight ?? row.deadweight ?? row.weight;
return { return {
bookingid: row.consignmentid ?? row.bookingid, bookingid: row.consignmentid ?? row.bookingid,
@@ -59,8 +52,7 @@ const mapInbound = (row, hubName) => {
sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'), sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'),
origin: row.originname || row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'), origin: row.originname || row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'),
currentLoc: hubName, currentLoc: hubName,
destination: destination: row.destinationname || row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
row.destinationname || row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—', weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—',
condition: row.condition || 'Good', condition: row.condition || 'Good',
temp: row.temperature || 'N/A', temp: row.temperature || 'N/A',
@@ -69,18 +61,12 @@ const mapInbound = (row, hubName) => {
}; };
}; };
const shelfStyle = (shelf) => {
if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' };
if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' };
return { color: '#1A73E8', bg: '#E8F0FE' };
};
const isGood = (c) => c === 'Good'; const isGood = (c) => c === 'Good';
export default function Inbound() { export default function Inbound() {
const theme = useTheme();
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext(); const hub = getHubContext();
const hubName = hub.hubname || 'this hub'; const hubName = hub.hubname || 'this hub';
const toast = useToast();
const [bookingId, setBookingId] = useState(''); const [bookingId, setBookingId] = useState('');
const [trackingId, setTrackingId] = useState(''); const [trackingId, setTrackingId] = useState('');
@@ -96,22 +82,28 @@ export default function Inbound() {
const [inboundLogs, setInboundLogs] = useState([]); const [inboundLogs, setInboundLogs] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
const today = dayjs().format(DATE_FMT);
const [range, setRange] = useState({ from: today, to: today });
const isToday = range.from === today && range.to === today;
const rangeLabel = isToday
? 'today'
: range.from === range.to
? dayjs(range.from).format('DD MMM')
: `${dayjs(range.from).format('DD MMM')} ${dayjs(range.to).format('DD MMM')}`;
const loadInbound = useCallback(async () => { const loadInbound = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { try {
const res = await getInboundToday(); const res = await getInboundRange(range.from, range.to);
setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName))); setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName)));
} catch (err) { } catch (err) {
setLoadError(err?.message || 'Could not load todays inbound parcels.'); setLoadError(err?.message || 'Could not load inbound parcels.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
// hubName is derived from a stable localStorage read; safe to omit. }, [range.from, range.to, hubName]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
loadInbound(); loadInbound();
@@ -125,7 +117,6 @@ export default function Inbound() {
return { received, exceptions, coldChain, pendingSort }; return { received, exceptions, coldChain, pendingSort };
}, [inboundLogs]); }, [inboundLogs]);
// Local preview of the shelf the backend is likely to recommend (it decides for real).
const recommendShelf = () => { const recommendShelf = () => {
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area'; if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area';
if (temp !== 'N/A' && temp !== '') return 'Zone C (Cold Room)'; if (temp !== 'N/A' && temp !== '') return 'Zone C (Cold Room)';
@@ -136,7 +127,7 @@ export default function Inbound() {
e.preventDefault(); e.preventDefault();
if (submitting) return; if (submitting) return;
if (!bookingId.trim() || !trackingId.trim()) { if (!bookingId.trim() || !trackingId.trim()) {
setToast({ open: true, msg: 'Enter the booking ID and tracking ID to scan a parcel in.', severity: 'warning' }); toast({ body: 'Enter the booking ID and tracking ID to scan a parcel in.', type: 'warning' });
return; return;
} }
@@ -152,7 +143,6 @@ export default function Inbound() {
const data = res?.data || {}; const data = res?.data || {};
const shelf = data.recommended_shelf || recommendShelf(); const shelf = data.recommended_shelf || recommendShelf();
// Optimistically prepend, then refresh from the server for the source of truth.
setInboundLogs((prev) => [ setInboundLogs((prev) => [
{ {
bookingid: data.bookingid ?? bookingId, bookingid: data.bookingid ?? bookingId,
@@ -169,291 +159,288 @@ export default function Inbound() {
}, },
...prev ...prev
]); ]);
setToast({ open: true, msg: `${data.trackingnumber || trackingId.trim()} received · routed to ${shelf}`, severity: 'success' }); toast({ body: `${data.trackingnumber || trackingId.trim()} received · routed to ${shelf}`, type: 'success' });
setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp(''); setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
loadInbound(); loadInbound();
} catch (err) { } catch (err) {
// 404 = the booking ID doesn't exist. This screen receives an EXISTING
// booking into the hub; it does not create a new parcel. Make that clear.
const notFound = err?.status === 404 || /not found/i.test(err?.message || ''); const notFound = err?.status === 404 || /not found/i.test(err?.message || '');
setToast({ toast({
open: true, body: notFound
msg: notFound
? `No booking found with ID "${bookingId.trim()}". This screen receives a booking that already exists — enter a Booking ID from the system (e.g. an unassigned pickup).` ? `No booking found with ID "${bookingId.trim()}". This screen receives a booking that already exists — enter a Booking ID from the system (e.g. an unassigned pickup).`
: err?.message || 'Could not scan this parcel in.', : err?.message || 'Could not scan this parcel in.',
severity: notFound ? 'warning' : 'error' type: notFound ? 'warning' : 'error'
}); });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const fieldSx = { '& .MuiOutlinedInput-root': { borderRadius: 2 } }; const columns = useMemo(() => [
{
// Reusable journey block key: 'parcel',
const Journey = ({ log }) => ( header: <div style={{ paddingLeft: '24px', whiteSpace: 'nowrap' }}>Parcel Info</div>,
<Stack spacing={0.5}> width: pixel(224),
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75 }}> renderCell: (row) => (
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.origin}</Typography> <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingLeft: '24px' }}>
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} /> <div style={{ fontWeight: 800, fontFamily: 'monospace', color: '#0f172a', fontSize: '1rem' }}>{row.trackingId}</div>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#C01227' }}>{log.currentLoc}</Typography> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.75rem', color: '#64748b' }}>
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} /> <span style={{ fontWeight: 600 }}>{row.weight}</span>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.destination.split(',')[0]}</Typography> <span style={{ color: '#cbd5e1' }}></span>
</Stack> <span>{row.time}</span>
<Stack direction="row" alignItems="center" gap={0.5}> </div>
<PlaceOutlinedIcon sx={{ fontSize: 13, color: '#9AA0A6' }} /> </div>
<Typography variant="caption" color="text.secondary" noWrap>{log.destination}</Typography> )
</Stack> },
</Stack> {
); key: 'journey',
width: proportional(2),
header: <div style={{ whiteSpace: 'nowrap' }}>Journey</div>,
renderCell: (row) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '4px 0' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<div style={{ width: 24, height: 24, borderRadius: 12, backgroundColor: '#d1fae5', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<MapPin size={12} color="#059669" />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: '#0f172a', lineHeight: 1.2 }}>{row.origin}</div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '2px' }}>Origin</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<div style={{ width: 24, height: 24, borderRadius: 12, backgroundColor: '#fee2e2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Flag size={12} color="#dc2626" />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: '#334155', lineHeight: 1.2 }}>{row.destination.split(',')[0]}</div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '200px' }}>{row.destination}</div>
</div>
</div>
</div>
)
},
{
key: 'condition',
header: <div style={{ whiteSpace: 'nowrap' }}>Condition</div>,
width: pixel(140),
renderCell: (row) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
<Badge variant={isGood(row.condition) ? 'success' : 'error'} label={row.condition} />
{row.temp !== 'N/A' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', color: '#0d9488', fontWeight: 600 }}>
<Snowflake size={12} /> {row.temp}
</div>
)}
</div>
)
},
{
key: 'shelf',
header: <div style={{ whiteSpace: 'nowrap' }}>Placement</div>,
width: pixel(180),
renderCell: (row) => {
let variant = 'info';
if (row.shelf === 'Exception Area') variant = 'error';
if (row.shelf.includes('Cold')) variant = 'neutral';
return (
<div style={{ paddingRight: '24px' }}>
<Badge variant={variant} label={row.shelf} icon={Warehouse} />
</div>
);
}
}
], []);
return ( return (
<Box> <div style={{ paddingBottom: '32px' }}>
{/* ── Header ── */}
<PageHeader <PageHeader
icon={MoveToInboxOutlinedIcon} icon={Inbox}
title="Receive Parcels" title="Receive Parcels"
subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on." subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on."
action={<DateRangePicker value={range} onChange={setRange} />}
/> />
{/* ── KPI strip ── */} {/* KPI strip - Ultra Compact */}
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mb: 3 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '24px', flexWrap: 'wrap', padding: '16px 20px', background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', marginBottom: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
{[
{ icon: MoveToInboxOutlinedIcon, label: 'Received Today', value: stats.received, color: '#1A73E8', bg: '#E8F0FE' },
{ icon: Inventory2OutlinedIcon, label: 'To Sort', value: stats.pendingSort, color: '#B06000', bg: '#FEF7E0' },
{ icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' },
{ icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' },
].map((s, i) => (
<Grid size={{ xs: 6, md: 3 }} key={i}>
<Card elevation={0} sx={{ borderRadius: 2, 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: 1, 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>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* ── Main ── */} <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
<Grid container spacing={{ xs: 2, md: 2.5 }} alignItems="stretch"> <div style={{ width: 36, height: 36, borderRadius: '8px', background: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Inbox size={18} /></div>
<div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{isToday ? 'Received Today' : 'Received'}</div>
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.received}</div>
</div>
</div>
<div style={{ width: 1, height: 32, background: '#e2e8f0', display: 'none', '@media (min-width: 640px)': { display: 'block' } }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#fff7ed', color: '#f97316', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Package size={18} /></div>
<div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>To Sort</div>
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.pendingSort}</div>
</div>
</div>
<div style={{ width: 1, height: 32, background: '#e2e8f0', display: 'none', '@media (min-width: 640px)': { display: 'block' } }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#fef2f2', color: '#ef4444', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><AlertTriangle size={18} /></div>
<div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Needs Checking</div>
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.exceptions}</div>
</div>
</div>
<div style={{ width: 1, height: 32, background: '#e2e8f0', display: 'none', '@media (min-width: 640px)': { display: 'block' } }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#f0fdfa', color: '#14b8a6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Snowflake size={18} /></div>
<div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Cold Items</div>
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.coldChain}</div>
</div>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '24px', alignItems: 'stretch' }}>
{/* Scanner Panel */} {/* Scanner Panel */}
<Grid size={{ xs: 12, lg: 4 }}> <div style={{ flex: '1 1 350px', display: 'flex' }}>
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}> <Card style={{ width: '100%', padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2.5, pb: 2 }}> <div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '16px' }}>
<Stack <div style={{ width: 40, height: 40, borderRadius: '8px', backgroundColor: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
direction="row" <QrCode size={20} />
alignItems="center" </div>
spacing={2} // Increase this value <div>
> <Heading level={4} style={{ margin: 0 }}>Add a Parcel</Heading>
<Avatar <Text type="supporting" color="secondary">Record a parcel arriving at {hubName}</Text>
variant="rounded" </div>
sx={{ </div>
bgcolor: "#C0122710",
color: "#C01227",
borderRadius: 1,
width: 40,
height: 40,
}}
>
<QrCodeScannerOutlinedIcon />
</Avatar>
<Box> <form onSubmit={handleSubmit} style={{ padding: '20px' }}>
<Typography <FormLayout>
variant="h5" <Field label="Booking ID" description="Must be an existing booking in the system">
sx={{ fontWeight: 700, color: "#1A1A2E" }} <TextInput required placeholder="e.g. 15" value={bookingId} onChange={setBookingId} />
> </Field>
Add a Parcel
</Typography>
<Typography variant="caption" color="text.secondary"> <Field label="Tracking ID">
Record a parcel arriving at {hubName} <TextInput placeholder="e.g. DM-882204" value={trackingId} onChange={setTrackingId} />
</Typography> </Field>
</Box>
</Stack>
</Box>
<Divider />
<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="Must be an existing booking in the system (this receives it — it doesn't create a new one)"
InputProps={{ startAdornment: <InputAdornment position="start"><Inventory2OutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId} <Field label="Where it came from">
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx} <select
InputProps={{ value={origin}
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> onChange={(e) => setOrigin(e.target.value)}
}} /> style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #cbd5e1', background: '#fff', color: '#0f172a', fontSize: '0.875rem' }}
>
{ORIGINS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</Field>
<TextField select fullWidth label="Where it came from" value={origin} <Field label="Sender Address">
onChange={(e) => setOrigin(e.target.value)} sx={fieldSx} <TextInput placeholder="e.g. Andheri East, Mumbai" value={senderAddress} onChange={setSenderAddress} />
InputProps={{ startAdornment: <InputAdornment position="start"><HomeWorkOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }}> </Field>
{ORIGINS.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField fullWidth label="Sender Address" placeholder="e.g. Andheri East, Mumbai" value={senderAddress} <Field label="Where it's going (delivery address)">
onChange={(e) => setSenderAddress(e.target.value)} sx={fieldSx} /> <TextInput required placeholder="e.g. Rohini Sec 9, Delhi" value={destination} onChange={setDestination} />
</Field>
<TextField fullWidth label="Where it's going (delivery address)" placeholder="e.g. Rohini Sec 9, Delhi" value={destination} <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
onChange={(e) => setDestination(e.target.value)} required sx={fieldSx} <Field label="Weight">
InputProps={{ startAdornment: <InputAdornment position="start"><PlaceOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} /> <TextInput placeholder="2.4 kg" value={weight} onChange={setWeight} />
</Field>
<Field label="Condition">
<select
value={condition}
onChange={(e) => setCondition(e.target.value)}
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #cbd5e1', background: '#fff', color: '#0f172a', fontSize: '0.875rem' }}
>
<option value="Good">Good</option>
<option value="Damaged Box">Damaged Box</option>
<option value="Wet / Crushed">Wet / Crushed</option>
<option value="Missing Label">Missing Label</option>
</select>
</Field>
</div>
<Stack direction="row" spacing={2}> <Field label="Temperature (Cold Chain)">
<TextField fullWidth label="Weight" placeholder="2.4 kg" value={weight} <TextInput placeholder="4.0°C — or N/A if dry" value={temp} onChange={setTemp} />
onChange={(e) => setWeight(e.target.value)} sx={fieldSx} </Field>
InputProps={{ startAdornment: <InputAdornment position="start"><ScaleOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 18 }} /></InputAdornment> }} />
<TextField fullWidth select label="Condition" value={condition}
onChange={(e) => setCondition(e.target.value)} sx={fieldSx}>
<MenuItem value="Good">Good</MenuItem>
<MenuItem value="Damaged Box">Damaged Box</MenuItem>
<MenuItem value="Wet / Crushed">Wet / Crushed</MenuItem>
<MenuItem value="Missing Label">Missing Label</MenuItem>
</TextField>
</Stack>
<TextField fullWidth label="Temperature (Cold Chain)" placeholder="4.0°C — or N/A if dry" value={temp} <div style={{ marginTop: '16px' }}>
onChange={(e) => setTemp(e.target.value)} sx={fieldSx} <Button
InputProps={{ startAdornment: <InputAdornment position="start"><ThermostatOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} /> variant="primary"
type="submit"
<Button fullWidth size="large" variant="contained" type="submit" disabled={submitting} disabled={submitting}
startIcon={submitting ? <CircularProgress size={18} color="inherit" /> : <CheckCircleOutlinedIcon />} icon={submitting ? <Loader2 size={16} className="spin" /> : <CheckCircle2 size={16} />}
sx={{ mt: 0.5, py: 1.3, borderRadius: 2, bgcolor: '#C01227', fontWeight: 700, style={{ width: '100%', justifyContent: 'center' }}
boxShadow: '0 4px 14px rgba(192,18,39,0.30)', '&:hover': { bgcolor: '#9E0E20' } }}> >
{submitting ? 'Scanning in…' : 'Mark Received at Hub'} {submitting ? 'Scanning in…' : 'Mark Received at Hub'}
</Button> </Button>
</Stack> </div>
</Box> </FormLayout>
</CardContent> </form>
</Card> </Card>
</Grid> </div>
{/* Ledger Panel */} {/* Ledger Panel */}
<Grid size={{ xs: 12, lg: 8 }}> <div style={{ flex: '2 1 600px', display: 'flex' }}>
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%', display: 'flex', flexDirection: 'column' }}> <Card style={{ width: '100%', padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2.5, pb: 2 }}> <div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}> <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Stack direction="row" alignItems="center" gap={2} spacing={2}> <div style={{ width: 40, height: 40, borderRadius: '8px', backgroundColor: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Avatar variant="rounded" sx={{ bgcolor: '#E8F0FE', color: '#1A73E8', borderRadius: 2, width: 40, height: 40 }}> <Truck size={20} />
<LocalShippingOutlinedIcon /> </div>
</Avatar> <div>
<Box> <Heading level={4} style={{ margin: 0 }}>Recently Received</Heading>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1A1A2E' }}>Recently Received</Typography> <Text type="supporting" color="secondary">Parcels logged at the hub {rangeLabel}</Text>
<Typography variant="caption" color="text.secondary">Parcels logged at the hub today</Typography> </div>
</Box> </div>
</Stack> <div style={{ padding: '4px 12px', borderRadius: '12px', background: '#f1f5f9', color: '#475569', fontSize: '0.75rem', fontWeight: 700 }}>
<Chip label={`${inboundLogs.length} total`} size="small" {inboundLogs.length} total
sx={{ fontWeight: 700, bgcolor: '#F1F3F5', color: '#5F6368', borderRadius: 2 }} /> </div>
</Stack> </div>
</Box>
<Divider />
{loadError && ( {loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> <div style={{ padding: '16px' }}>
{loadError} <Banner status="error" title="Error" description={loadError} />
</Alert> </div>
)} )}
{loading ? ( {loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
<CircularProgress /> <Loader2 size={32} className="spin" color="#2563eb" />
</Box> </div>
) : 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);
return (
<Card key={i} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E' }}>{log.trackingId}</Typography>
<Stack direction="row" alignItems="center" gap={0.5}>
<AccessTimeOutlinedIcon sx={{ fontSize: 13, color: '#9AA0A6' }} />
<Typography variant="caption" color="text.secondary">{log.time}</Typography>
</Stack>
</Stack>
<Journey log={log} />
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mt: 1.5 }}>
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.weight}
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
<Chip size="small" label={log.condition}
sx={{ fontWeight: 700, bgcolor: isGood(log.condition) ? '#E6F4EA' : '#FCE8E6', color: isGood(log.condition) ? '#1E8E3E' : '#D93025' }} />
{log.temp !== 'N/A' && (
<Chip size="small" icon={<ThermostatOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.temp}
sx={{ bgcolor: '#E0F7FA', color: '#00838F', fontWeight: 600, '& .MuiChip-icon': { color: '#00838F' } }} />
)}
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.shelf}
sx={{ bgcolor: ss.bg, color: ss.color, fontWeight: 600, '& .MuiChip-icon': { color: ss.color } }} />
</Stack>
</CardContent>
</Card>
);
})}
</Box>
) : ( ) : (
/* ── DESKTOP: table with horizontal scroll guard ── */ <div className="hide-scrollbar" style={{ flex: 1, padding: '16px 0', overflowX: 'auto', overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
<TableContainer sx={{ flexGrow: 1 }}> <Table
<Table sx={{ minWidth: 820 }}> data={inboundLogs}
<TableHead> columns={columns}
<TableRow sx={{ bgcolor: '#FAFBFC' }}> idKey="trackingId"
{['Parcel No.', 'Journey', 'Weight', 'Condition', 'Temp', 'Goes On Shelf', 'Time'].map((h, i) => ( density="balanced"
<TableCell key={h} align={i >= 2 && i <= 4 ? 'center' : i === 6 ? 'right' : 'left'} dividers="rows"
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, borderBottom: '1px solid #ECEEF1', py: 1.5, whiteSpace: 'nowrap' }}> emptyState={
{h} !loadError && (
</TableCell> <EmptyState
))} icon={<Inbox size={48} color="#cbd5e1" />}
</TableRow> title={isToday ? 'No parcels received yet today.' : `No parcels received ${rangeLabel}.`}
</TableHead> description="Waiting for incoming shipments."
<TableBody> style={{ padding: '64px 0' }}
{inboundLogs.map((log, index) => { />
const ss = shelfStyle(log.shelf); )
return ( }
<TableRow key={index} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8' }, '&:last-child td': { border: 0 } }}> />
<TableCell sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', whiteSpace: 'nowrap' }}>{log.trackingId}</TableCell> </div>
<TableCell sx={{ minWidth: 240 }}><Journey log={log} /></TableCell>
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 600, color: '#495057' }}>{log.weight}</TableCell>
<TableCell align="center">
<Chip size="small" label={log.condition}
sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: isGood(log.condition) ? '#E6F4EA' : '#FCE8E6', color: isGood(log.condition) ? '#1E8E3E' : '#D93025' }} />
</TableCell>
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 600, color: log.temp !== 'N/A' ? '#00838F' : '#9AA0A6' }}>{log.temp}</TableCell>
<TableCell>
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.shelf}
sx={{ fontWeight: 600, whiteSpace: 'nowrap', bgcolor: ss.bg, color: ss.color, '& .MuiChip-icon': { color: ss.color } }} />
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap', color: '#9AA0A6', fontSize: '0.78rem' }}>{log.time}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)} )}
</Card> </Card>
</Grid> </div>
</Grid> </div>
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })} <style>{`
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> .spin { animation: spin 1s linear infinite; }
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} @keyframes spin { 100% { transform: rotate(360deg); } }
sx={{ borderRadius: 2, fontWeight: 600, boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}> .hide-scrollbar::-webkit-scrollbar { display: none; }
{toast.msg} .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</Alert> `}</style>
</Snackbar> </div>
</Box>
); );
} }

View File

@@ -1,54 +1,56 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { import { UserCheck, Bike, Sparkles, Star } from 'lucide-react';
Box, import dayjs from 'dayjs';
Typography,
Card,
CardContent,
CardHeader,
Button,
Divider,
Avatar,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItemButton,
ListItemText,
ListItemAvatar,
Radio,
Badge,
Checkbox,
useMediaQuery,
CircularProgress,
Alert,
Snackbar
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
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 { Banner } from '@astryxdesign/core/Banner';
import { Badge } from '@astryxdesign/core/Badge';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Table, proportional, pixel, useTableSelection, useTableSelectionState } from '@astryxdesign/core/Table';
import { HStack, VStack, Card } from '@astryxdesign/core/Layout';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { useToast } from '@astryxdesign/core/Toast';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import Panel from '@/components/Panel';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { getUnassignedBookings, getMilers, assignMiler, autoAssignBooking } from '@/api/hub'; import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
import { getBookingsRange, getMilers, assignMiler, autoAssignBooking } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
function useMediaQuery(query) {
const [matches, setMatches] = useState(window.matchMedia(query).matches);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
const PENDING = 'Pending Assignment'; const PENDING = 'Pending Assignment';
const isPendingStatus = (s) => {
const v = (s || '').toLowerCase();
return !v || v === 'pending' || v === 'unassigned' || v === 'pending assignment';
};
const statusChip = (status) => {
if (status === PENDING) return { label: 'Needs a miler', variant: 'warning' };
if (/^cancel/i.test(status)) return { label: 'Cancelled', variant: 'error' };
if (/deliver/i.test(status)) return { label: 'Delivered', variant: 'success' };
if (/picked/i.test(status)) return { label: 'Picked up', variant: 'info' };
if (/no miler/i.test(status)) return { label: 'No miler in range', variant: 'error' };
if (/assigning/i.test(status)) return { label: 'Assigning…', variant: 'info' };
return { label: 'Assigned', variant: 'success' };
};
const timeAgo = (iso) => { const timeAgo = (iso) => {
if (!iso) return 'Recently'; if (!iso) return 'Recently';
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
@@ -61,21 +63,27 @@ const timeAgo = (iso) => {
return `${Math.round(hrs / 24)} d ago`; return `${Math.round(hrs / 24)} d ago`;
}; };
// Real backend: snake_case fields + a parcels[] array (no packagedescription).
const mapOrder = (b) => { const mapOrder = (b) => {
const parcels = b.parcels || []; const parcels = b.parcels || [];
const totalWeight = parcels.reduce((s, p) => s + (p.weight || 0), 0); const totalWeight = parcels.reduce((s, p) => s + (p.weight || 0), 0);
const pkg = parcels.length const pkg = parcels.length
? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}` ? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}`
: b.packagedescription || '—'; : b.packagedescription || '—';
const raw = (b.status || '').toLowerCase();
let status;
if (isPendingStatus(raw)) status = PENDING;
else if (raw === 'cancelled') status = 'Cancelled';
else if (raw === 'delivered') status = 'Delivered';
else if (raw === 'picked_up') status = 'Picked up';
else status = b.milername ? `Assigned to ${b.milername}` : 'Assigned';
return { return {
id: b.bookingid, id: b.bookingid ?? b.consignmentid ?? b.booking_id ?? b.id,
customer: b.customer_name || b.customerName || 'Customer', customer: b.customer_name || b.customerName || 'Customer',
pickup: b.pickup_address || b.pickupaddress || '—', pickup: b.pickup_address || b.pickupaddress || '—',
drop: b.delivery_address || b.deliveryaddress || '—', drop: b.delivery_address || b.deliveryaddress || '—',
package: pkg, package: pkg,
time: timeAgo(b.created_at || b.createdat), time: timeAgo(b.created_at || b.createdat),
status: PENDING status
}; };
}; };
@@ -88,8 +96,7 @@ const mapMiler = (m) => ({
}); });
export default function OrderAssignment() { export default function OrderAssignment() {
const theme = useTheme(); const isMdDown = useMediaQuery('(max-width: 900px)');
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
const hub = getHubContext(); const hub = getHubContext();
const [orders, setOrders] = useState([]); const [orders, setOrders] = useState([]);
@@ -97,22 +104,30 @@ export default function OrderAssignment() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); const toast = useToast();
const [selectedOrders, setSelectedOrders] = useState([]); const [selectedOrders, setSelectedOrders] = useState(() => new Set());
const today = dayjs().format(DATE_FMT);
const [range, setRange] = useState({ from: today, to: today });
const isToday = range.from === today && range.to === today;
const rangeLabel = isToday
? 'today'
: range.from === range.to
? dayjs(range.from).format('DD MMM')
: `${dayjs(range.from).format('DD MMM')} ${dayjs(range.to).format('DD MMM')}`;
// Single Assign Dialog State
const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null); const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null);
const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [selectedMiler, setSelectedMiler] = useState(''); const [selectedMiler, setSelectedMiler] = useState('');
const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity }); const notify = (msg, status = 'success') => toast({ body: msg, type: status });
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { try {
const [bookings, milerRes] = await Promise.all([getUnassignedBookings(), getMilers().catch(() => null)]); const [bookings, milerRes] = await Promise.all([getBookingsRange(range.from, range.to), getMilers().catch(() => null)]);
setOrders((bookings?.data || []).map(mapOrder)); setOrders((bookings?.data || []).map(mapOrder));
if (milerRes?.data) setMilers(milerRes.data.map(mapMiler)); if (milerRes?.data) setMilers(milerRes.data.map(mapMiler));
} catch (err) { } catch (err) {
@@ -120,30 +135,21 @@ export default function OrderAssignment() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [range.from, range.to]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
// Milers that can take a new pickup right now.
const availableMilers = milers.filter((m) => ['Available', 'Assigned'].includes(m.status)); const availableMilers = milers.filter((m) => ['Available', 'Assigned'].includes(m.status));
const handleSelectAll = (event) => { const handleSelectOne = (id, isSelected) => {
if (event.target.checked) { setSelectedOrders((prev) => {
const pendingIds = orders.filter(o => o.status === PENDING).map(o => o.id); const next = new Set(prev);
setSelectedOrders(pendingIds); if (isSelected) next.add(String(id));
} else { else next.delete(String(id));
setSelectedOrders([]); return next;
} });
};
const handleSelectOne = (id) => {
if (selectedOrders.includes(id)) {
setSelectedOrders(selectedOrders.filter(selectedId => selectedId !== id));
} else {
setSelectedOrders([...selectedOrders, id]);
}
}; };
const handleOpenAssign = (order) => { const handleOpenAssign = (order) => {
@@ -152,7 +158,6 @@ export default function OrderAssignment() {
setAssignDialogOpen(true); setAssignDialogOpen(true);
}; };
// Assign a specific miler to a single booking.
const handleAssign = async () => { const handleAssign = async () => {
if (!selectedMiler || busy) return; if (!selectedMiler || busy) return;
const miler = availableMilers.find((m) => m.id === selectedMiler); const miler = availableMilers.find((m) => m.id === selectedMiler);
@@ -162,7 +167,11 @@ export default function OrderAssignment() {
const res = await assignMiler(order.id, selectedMiler); const res = await assignMiler(order.id, selectedMiler);
const name = res?.data?.milername || miler?.name || 'miler'; const name = res?.data?.milername || miler?.name || 'miler';
setOrders((prev) => prev.map((o) => (o.id === order.id ? { ...o, status: `Assigned to ${name}` } : o))); setOrders((prev) => prev.map((o) => (o.id === order.id ? { ...o, status: `Assigned to ${name}` } : o)));
setSelectedOrders((prev) => prev.filter((id) => id !== order.id)); setSelectedOrders((prev) => {
const next = new Set(prev);
next.delete(String(order.id));
return next;
});
setAssignDialogOpen(false); setAssignDialogOpen(false);
notify(`Pickup #${order.id} assigned to ${name}.`); notify(`Pickup #${order.id} assigned to ${name}.`);
} catch (err) { } catch (err) {
@@ -172,10 +181,9 @@ export default function OrderAssignment() {
} }
}; };
// Let the AI engine assign every selected booking (POST /hub/bookings/:id/auto-assign).
const handleAutoAssignAll = async () => { const handleAutoAssignAll = async () => {
if (selectedOrders.length === 0 || busy) return; if (selectedOrders.size === 0 || busy) return;
const ids = orders.filter((o) => selectedOrders.includes(o.id) && o.status === PENDING).map((o) => o.id); const ids = orders.filter((o) => selectedOrders.has(String(o.id)) && o.status === PENDING).map((o) => o.id);
setBusy(true); setBusy(true);
let ok = 0; let ok = 0;
let pending = 0; let pending = 0;
@@ -183,7 +191,6 @@ export default function OrderAssignment() {
for (const id of ids) { for (const id of ids) {
try { try {
const res = await autoAssignBooking(id); const res = await autoAssignBooking(id);
// 202 → engine still working: success:true but no miler yet.
const name = res?.data?.milername; const name = res?.data?.milername;
if (name) { if (name) {
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: `Assigned to ${name}` } : o))); setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: `Assigned to ${name}` } : o)));
@@ -193,257 +200,351 @@ export default function OrderAssignment() {
pending += 1; pending += 1;
} }
} catch (err) { } catch (err) {
// 422 → no eligible miler in range; other codes → generic failure.
failed += 1; failed += 1;
if (err?.status === 422) { if (err?.status === 422) {
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'No miler in range' } : o))); setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'No miler in range' } : o)));
} }
} }
} }
setSelectedOrders([]); setSelectedOrders(new Set());
setBusy(false); setBusy(false);
const parts = []; const parts = [];
if (ok) parts.push(`assigned ${ok}`); if (ok) parts.push(`assigned ${ok}`);
if (pending) parts.push(`${pending} in progress`); if (pending) parts.push(`${pending} in progress`);
if (failed) parts.push(`${failed} failed`); if (failed) parts.push(`${failed} failed`);
notify(`Auto-assign: ${parts.join(', ')}.`, failed ? 'warning' : 'success'); notify(`Auto-assign: ${parts.join(', ')}.`, failed ? 'warning' : 'success');
load(); // refresh from server for the true state load();
}; };
const pendingCount = orders.filter((o) => o.status === PENDING).length; const { selectionConfig } = useTableSelectionState({
const isAllSelected = selectedOrders.length > 0 && selectedOrders.length === pendingCount; data: orders,
idKey: 'id',
getIsItemSelectable: (o) => o.status === PENDING,
selectedKeys: selectedOrders,
setSelectedKeys: setSelectedOrders
});
const selectionPlugin = useTableSelection(selectionConfig);
const columns = useMemo(() => [
{
key: 'request',
header: 'Request Details',
width: pixel(160),
renderCell: (row) => (
<VStack gap={0.5}>
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
<Text type="supporting" color="secondary" maxLines={1} style={{ maxWidth: 150 }}>{row.package}</Text>
</VStack>
)
},
{
key: 'journey',
header: 'Journey',
width: proportional(2),
renderCell: (row) => (
<VStack gap={2}>
<VStack gap={0}>
<Text type="supporting" color="secondary">{row.customer}</Text>
<Text type="body">{row.pickup}</Text>
</VStack>
<VStack gap={0}>
<Text type="supporting" color="secondary">Going to</Text>
<Text type="body">{row.drop}</Text>
</VStack>
</VStack>
)
},
{
key: 'status',
header: 'Status',
width: pixel(140),
renderCell: (row) => {
const c = statusChip(row.status);
return <Badge variant={c.variant} label={c.label} />;
}
},
{
key: 'action',
header: 'Assignment',
width: pixel(180),
renderCell: (row) => {
const isAssigned = row.status !== PENDING;
const c = statusChip(row.status);
return !isAssigned ? (
<Button variant="secondary" size="sm" onClick={() => handleOpenAssign(row)} style={{ width: '100%', justifyContent: 'center' }}>
Choose Miler
</Button>
) : (
<Text type="body" weight="semibold" style={{ color: c.variant === 'success' ? 'var(--color-icon-green)' : 'var(--color-text-secondary)' }}>
{row.status}
</Text>
);
}
}
], []);
return ( return (
<Box> <div style={{ paddingBottom: '32px' }}>
<PageHeader <PageHeader
icon={AssignmentIndIcon} icon={UserCheck}
title="Pickup Requests" title="Pickup Requests"
subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once." subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once."
action={<DateRangePicker value={range} onChange={setRange} />}
/> />
<Card> <Panel>
<CardHeader <div
title="Waiting for a Miler" style={{
subheader={`New pickup requests around ${hub.city || 'your city'}`} padding: '20px 24px',
avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>} borderBottom: '1px solid var(--color-border)',
action={ display: 'flex',
<Button alignItems: 'center',
variant={selectedOrders.length === 0 ? 'outlined' : 'contained'} justifyContent: 'space-between',
color="primary" flexWrap: 'wrap',
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />} gap: '16px'
onClick={handleAutoAssignAll} }}
disabled={selectedOrders.length === 0 || busy} >
sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }} <HStack gap={4} align="center">
<span
style={{
background: 'var(--color-background-blue)',
color: 'var(--color-icon-blue)',
width: 40,
height: 40,
borderRadius: 'var(--radius-element)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
> >
{selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`} <UserCheck size={20} />
</Button> </span>
} <VStack gap={0.5}>
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }} <Heading level={4} style={{ margin: 0 }}>{isToday ? 'Waiting for a Miler' : `Pickup requests · ${rangeLabel}`}</Heading>
/> <Text type="supporting" color="secondary">
<Divider /> {isToday
? `New pickup requests around ${hub.city || 'your city'}`
: `${orders.length} pickup request${orders.length === 1 ? '' : 's'} in this range around ${hub.city || 'your city'}`}
</Text>
</VStack>
</HStack>
<Button
variant={selectedOrders.size === 0 ? 'secondary' : 'primary'}
onClick={handleAutoAssignAll}
disabled={selectedOrders.size === 0 || busy}
icon={!busy ? <Sparkles size={16} /> : undefined}
>
{busy ? 'Working...' : `Auto-Assign ${selectedOrders.size > 0 ? `(${selectedOrders.size})` : ''}`}
</Button>
</div>
{loadError && ( {loadError && (
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}> <div style={{ padding: '16px' }}>
{loadError} <Banner status="error" title="Error" description={loadError} />
</Alert> </div>
)} )}
{loading ? ( {loading ? (
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}> <div style={{ padding: '64px', textAlign: 'center' }}>
<CircularProgress /> <Text type="body" color="secondary">Loading...</Text>
</Box> </div>
) : 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 ? ( ) : isMdDown ? (
/* ── MOBILE / TABLET: cards ── */ /* ── MOBILE / TABLET: cards ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}> orders.length === 0 && !loadError ? (
{orders.map((row) => { <EmptyState
const isSelected = selectedOrders.includes(row.id); icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><UserCheck size={48} /></span>}
const isAssigned = row.status !== 'Pending Assignment'; title={isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
return ( description="There is no data available."
<Card key={row.id} elevation={0} style={{ padding: '64px 0' }}
sx={{ borderRadius: 2, border: '1px solid', borderColor: isSelected ? 'primary.main' : '#ECEEF1', bgcolor: isAssigned ? '#FAFBFC' : '#fff' }}> />
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}> ) : (
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1} sx={{ mb: 1.5 }}> <div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}> {orders.map((row) => {
const isSelected = selectedOrders.has(String(row.id));
const isAssigned = row.status !== PENDING;
const c = statusChip(row.status);
return (
<Card
key={row.id}
padding={4}
style={{
border: `1px solid ${isSelected ? 'var(--color-accent)' : 'var(--color-border)'}`,
background: isAssigned ? 'var(--color-background-muted)' : 'var(--color-background-surface)'
}}
>
<HStack gap={2} justify="between" align="center" style={{ marginBottom: '12px' }}>
<HStack gap={2} align="center">
{!isAssigned && ( {!isAssigned && (
<Checkbox size="small" sx={{ p: 0 }} checked={isSelected} onChange={() => handleSelectOne(row.id)} /> <CheckboxInput
label={`Select ${row.id}`}
isLabelHidden
value={isSelected}
onChange={(checked) => handleSelectOne(row.id, checked)}
/>
)} )}
<Box sx={{ minWidth: 0 }}> <VStack gap={0}>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{row.id}</Typography> <Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
<Typography variant="caption" color="text.secondary">{row.time}</Typography> <Text type="supporting" color="secondary">{row.time}</Text>
</Box> </VStack>
</Stack> </HStack>
{!isAssigned <Badge variant={c.variant} label={c.label} />
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#FEF7E0', color: '#B06000' }} /> </HStack>
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
</Stack>
<Stack spacing={1} sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}> <VStack gap={2} style={{ padding: '12px', background: 'var(--color-background-muted)', borderRadius: 'var(--radius-inner)', marginBottom: '16px' }}>
<Stack direction="row" alignItems="center" gap={1}> <Text type="body" weight="semibold">{row.customer}</Text>
<PersonOutlineRoundedIcon sx={{ fontSize: 17, color: '#9AA0A6' }} /> <Text type="supporting" color="secondary"><b>Pick up:</b> {row.pickup}</Text>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.customer}</Typography> <Text type="supporting" color="secondary"><b>Going to:</b> {row.drop}</Text>
</Stack> <Text type="supporting" color="secondary">{row.package}</Text>
<Stack direction="row" alignItems="center" gap={1}> </VStack>
<PlaceOutlinedIcon sx={{ fontSize: 17, color: '#1E8E3E' }} />
<Typography variant="body2" color="text.secondary"><b>Pick up:</b> {row.pickup}</Typography>
</Stack>
<Stack direction="row" alignItems="center" gap={1}>
<FlagOutlinedIcon sx={{ fontSize: 17, color: '#C01227' }} />
<Typography variant="body2" color="text.secondary"><b>Going to:</b> {row.drop}</Typography>
</Stack>
<Stack direction="row" alignItems="center" gap={1}>
<Inventory2OutlinedIcon sx={{ fontSize: 17, color: '#9AA0A6' }} />
<Typography variant="body2" color="text.secondary">{row.package}</Typography>
</Stack>
</Stack>
{!isAssigned ? ( {!isAssigned ? (
<Button fullWidth size="small" variant="contained" startIcon={<TwoWheelerIcon sx={{ fontSize: 16 }} />} <Button style={{ width: '100%', justifyContent: 'center' }} variant="primary" onClick={() => handleOpenAssign(row)}>
onClick={() => handleOpenAssign(row)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}>
Choose a Miler Choose a Miler
</Button> </Button>
) : ( ) : (
<Typography variant="body2" sx={{ textAlign: 'center', fontWeight: 700, color: '#1E8E3E' }}>{row.status}</Typography> <Text
type="body"
weight="bold"
style={{ textAlign: 'center', display: 'block', color: c.variant === 'success' ? 'var(--color-icon-green)' : 'var(--color-text-secondary)' }}
>
{row.status}
</Text>
)} )}
</CardContent> </Card>
</Card> );
); })}
})} </div>
</Box> )
) : ( ) : (
/* ── DESKTOP: spacious table ── */ /* ── DESKTOP: spacious table ── */
<TableContainer sx={{ overflowX: 'auto' }}> /* ── DESKTOP: spacious table ── */
<Table sx={{ minWidth: 820 }}> <div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<TableHead> <Table
<TableRow sx={{ bgcolor: '#FAFBFC' }}> data={orders}
<TableCell padding="checkbox"> columns={columns}
<Checkbox idKey="id"
indeterminate={selectedOrders.length > 0 && selectedOrders.length < pendingCount} density="balanced"
checked={isAllSelected && pendingCount > 0} dividers="rows"
onChange={handleSelectAll} hasHover
disabled={pendingCount === 0} plugins={{ selection: selectionPlugin }}
/> emptyState={
</TableCell> !loadError && (
{['Request', 'Pick Up From', 'Going To', 'Parcel', 'Status', 'Action'].map((h, i) => ( <EmptyState
<TableCell key={h} align={i === 5 ? 'right' : 'left'} icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><UserCheck size={48} /></span>}
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}> title={isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
{h} description="There is no data available."
</TableCell> style={{ padding: '64px 0' }}
))}
</TableRow>
</TableHead>
<TableBody>
{orders.map((row) => {
const isSelected = selectedOrders.includes(row.id);
const isAssigned = row.status !== 'Pending Assignment';
return (
<TableRow key={row.id} selected={isSelected} hover
sx={{ bgcolor: isAssigned ? '#FAFBFC' : 'inherit', '& td': { borderBottom: '1px solid #F4F6F8', py: 2 }, '&:last-child td': { border: 0 } }}>
<TableCell padding="checkbox">
<Checkbox checked={isSelected} onChange={() => handleSelectOne(row.id)} disabled={isAssigned} />
</TableCell>
<TableCell sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', whiteSpace: 'nowrap' }}>{row.id}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={700}>{row.customer}</Typography>
<Stack direction="row" alignItems="center" gap={0.5}>
<PlaceOutlinedIcon sx={{ fontSize: 14, color: '#1E8E3E' }} />
<Typography variant="caption" color="text.secondary">{row.pickup}</Typography>
</Stack>
</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#495057' }}>{row.drop}</TableCell>
<TableCell sx={{ color: '#495057' }}>{row.package}</TableCell>
<TableCell>
{!isAssigned
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#FEF7E0', color: '#B06000' }} />
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
</TableCell>
<TableCell align="right">
{!isAssigned ? (
<Button variant="outlined" size="small" startIcon={<TwoWheelerIcon sx={{ fontSize: 16 }} />}
onClick={() => handleOpenAssign(row)} sx={{ borderRadius: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
Choose Miler
</Button>
) : (
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1E8E3E', whiteSpace: 'nowrap' }}>{row.status}</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
<Dialog open={assignDialogOpen} onClose={() => setAssignDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Choose a Miler for pickup #{selectedOrderForAssign?.id}</DialogTitle>
<DialogContent dividers>
<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>
{availableMilers.map((miler) => (
<ListItemButton
key={miler.id}
onClick={() => setSelectedMiler(miler.id)}
sx={{
border: '1px solid',
borderColor: selectedMiler === miler.id ? 'primary.main' : 'divider',
borderRadius: 2,
mb: 1,
bgcolor: selectedMiler === miler.id ? 'primary.lighter' : 'transparent'
}}
>
<ListItemAvatar>
<Badge color="success" variant="dot" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
<Avatar sx={{ bgcolor: 'grey.200', color: 'grey.700' }}><TwoWheelerIcon /></Avatar>
</Badge>
</ListItemAvatar>
<ListItemText
primary={miler.name}
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> </div>
)} )}
</DialogContent> </Panel>
<DialogActions>
<Button onClick={() => setAssignDialogOpen(false)} color="inherit">Cancel</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 })} {/* Assign Dialog Overlay */}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> <Dialog isOpen={assignDialogOpen} onOpenChange={setAssignDialogOpen} width={500}>
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}> <Layout
{toast.msg} header={<DialogHeader title={`Choose a Miler for pickup #${selectedOrderForAssign?.id}`} onOpenChange={setAssignDialogOpen} />}
</Alert> content={
</Snackbar> <LayoutContent>
</Box> <div style={{ padding: '24px', maxHeight: '60vh', overflowY: 'auto' }}>
<Text type="supporting" weight="semibold" style={{ marginBottom: '16px', display: 'block' }}>Milers at {hub.hubname || 'this hub'}:</Text>
{availableMilers.length === 0 ? (
<EmptyState
title="No milers available"
description="No available milers right now. Try Auto-Assign, or check the Milers page."
isCompact
/>
) : (
<VStack gap={2}>
{availableMilers.map((miler) => (
<div
key={miler.id}
onClick={() => setSelectedMiler(miler.id)}
style={{
border: `1px solid ${selectedMiler === miler.id ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-inner)',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: selectedMiler === miler.id ? 'var(--color-background-muted)' : 'transparent',
cursor: 'pointer'
}}
>
<HStack gap={4} align="center">
<div style={{ position: 'relative' }}>
<span
style={{
width: '40px',
height: '40px',
borderRadius: 'var(--radius-full)',
background: 'var(--color-neutral)',
color: 'var(--color-text-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Bike size={20} />
</span>
<span
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '12px',
height: '12px',
borderRadius: 'var(--radius-full)',
background: 'var(--color-icon-green)',
border: '2px solid var(--color-background-surface)'
}}
/>
</div>
<VStack gap={0}>
<Text type="body" weight="semibold">{miler.name}</Text>
<HStack gap={1} align="center">
<Text type="supporting" color="secondary">{miler.status}</Text>
{miler.rating != null && (
<>
<Text type="supporting" color="secondary"></Text>
<span style={{ color: 'var(--color-icon-orange)', display: 'flex' }}>
<Star size={12} fill="currentColor" />
</span>
<Text type="supporting" color="secondary">{miler.rating}</Text>
</>
)}
</HStack>
</VStack>
</HStack>
<input
type="radio"
checked={selectedMiler === miler.id}
readOnly
style={{ width: '18px', height: '18px', accentColor: 'var(--color-accent)' }}
/>
</div>
))}
</VStack>
)}
</div>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', width: '100%', padding: '16px 24px' }}>
<Button variant="ghost" onClick={() => setAssignDialogOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={handleAssign} disabled={!selectedMiler || busy}>
{busy ? 'Working...' : 'Confirm Assignment'}
</Button>
</div>
</LayoutFooter>
}
/>
</Dialog>
</div>
); );
} }

View File

@@ -1,46 +1,47 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import {
Box, Typography, Card, CardContent, Avatar, Chip, Stack, Button, Grid,
IconButton, List, ListItemButton, ListItemText, Collapse, Tooltip, Divider,
LinearProgress, Menu, MenuItem, Drawer, Paper
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import {
Route, Play, Pause, RotateCcw, Gauge, Eye, EyeOff, ChevronDown, ChevronUp,
Warehouse, Flag, Package, CheckCircle2, XCircle, Bike, Store, Users, Ruler,
Wallet, Phone, MapPin, Clock, User, ArrowLeft, Scale, CalendarClock, FileText
} from 'lucide-react';
import { Card } from '@astryxdesign/core/Card';
import { Heading, Text } from '@astryxdesign/core/Text';
import { HStack, VStack } from '@astryxdesign/core/Layout';
import { Grid } from '@astryxdesign/core/Grid';
import { Avatar } from '@astryxdesign/core/Avatar';
import { Badge } from '@astryxdesign/core/Badge';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { IconButton } from '@astryxdesign/core/IconButton';
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { Dialog } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent } from '@astryxdesign/core/Layout';
import { Divider } from '@astryxdesign/core/Divider';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import Panel from '@/components/Panel';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import { getRiderRoutes } from '@/api/hub'; import { getRiderRoutes } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined'; function useMediaQuery(query) {
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; const [matches, setMatches] = useState(window.matchMedia(query).matches);
import PauseRoundedIcon from '@mui/icons-material/PauseRounded'; useEffect(() => {
import ReplayRoundedIcon from '@mui/icons-material/ReplayRounded'; const media = window.matchMedia(query);
import SpeedRoundedIcon from '@mui/icons-material/SpeedRounded'; if (media.matches !== matches) {
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; setMatches(media.matches);
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'; }
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded'; const listener = () => setMatches(media.matches);
import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded'; media.addEventListener('change', listener);
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded'; return () => media.removeEventListener('change', listener);
import FlagRoundedIcon from '@mui/icons-material/FlagRounded'; }, [matches, query]);
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; return matches;
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; }
import CancelRoundedIcon from '@mui/icons-material/CancelRounded';
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import StraightenRoundedIcon from '@mui/icons-material/StraightenRounded';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
// Leaflet base-icon fix (same approach as TrackingMap.jsx) // Leaflet base-icon fix (same approach as TrackingMap.jsx)
@@ -138,8 +139,20 @@ const moverIcon = (color) => new L.DivIcon({
const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' }; const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' };
// Palette assigned to milers round-robin so each route line is a distinct colour. // Palette assigned to milers round-robin so each route line is a distinct colour.
// These are intentionally literal (not design tokens) — they exist purely to tell
// routes apart at a glance, the same way a legend uses arbitrary swatch colours.
const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F']; const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
// Mixes a hex colour with white/alpha for tints — a tiny stand-in for the
// alpha() helper MUI provided, kept local since these are per-rider swatch
// colours rather than themed tokens.
const hexAlpha = (hex, a) => {
const h = hex.replace('#', '');
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
const n = parseInt(full, 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
};
// Map an API rider-route (from GET /hub/rider-routes) into the structure this // 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 // page renders: a `pickup` trip with a list of order stops. Fields the API does
// not provide (customer, weight, COD, slot, instructions) default gracefully. // not provide (customer, weight, COD, slot, instructions) default gracefully.
@@ -193,19 +206,18 @@ const mapRoute = (r, i) => {
}; };
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
const inr = (n) => `${(Number(n) || 0).toLocaleString('en-IN')}`; const inr = (n) => `${(Number(n) || 0).toLocaleString('en-IN')}`;
const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t }); const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
const STATUS_META = { const STATUS_META = {
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' }, Picked: { variant: 'success', icon: CheckCircle2, label: 'Picked up' },
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' }, Missed: { variant: 'error', icon: XCircle, label: 'Missed' },
'In progress': { color: '#F29900', icon: DeliveryDiningRoundedIcon, label: 'In progress' }, 'In progress': { variant: 'warning', icon: Bike, label: 'In progress' },
Pending: { color: '#80868B', icon: ScheduleOutlinedIcon, label: 'Pending' }, Pending: { variant: 'neutral', icon: CalendarClock, label: 'Pending' }
}; };
const MODES = { const MODES = {
pickup: { label: 'Pickups', icon: StorefrontOutlinedIcon, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' }, pickup: { label: 'Pickups', icon: Store, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' }
}; };
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
@@ -225,144 +237,107 @@ async function fetchRoadRoute(stops) {
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
// Small presentational pieces // Small presentational pieces
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
function KpiCard({ icon: Icon, label, value, sub, color, bg }) {
return (
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1 }}>
<Avatar variant="rounded" sx={{ bgcolor: bg, color, width: 34, height: 34, borderRadius: 2 }}>
<Icon sx={{ fontSize: 18 }} />
</Avatar>
<Typography sx={{ fontSize: '0.68rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.5, textTransform: 'uppercase' }}>
{label}
</Typography>
</Stack>
<Typography sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.1 }}>{value}</Typography>
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
</CardContent>
</Card>
);
}
function DetailRow({ icon: Icon, label, value, valueColor }) { function DetailRow({ icon: Icon, label, value, valueColor }) {
if (value === undefined || value === null || value === '') return null; if (value === undefined || value === null || value === '') return null;
return ( return (
<Stack direction="row" spacing={2} alignItems="flex-start"> <HStack gap={2} align="start">
<Icon sx={{ fontSize: 20, color: '#9AA0A6', mt: 0.25 }} /> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex', marginTop: 2 }}><Icon size={16} /></span>
<Box sx={{ flex: 1, minWidth: 0 }}> <VStack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{label}</Typography> <Text type="supporting" color="secondary">{label}</Text>
<Typography sx={{ fontWeight: 600, color: valueColor || '#343A40', wordBreak: 'break-word' }}>{value}</Typography> <Text type="body" weight="semibold" style={{ color: valueColor, wordBreak: 'break-word' }}>{value}</Text>
</Box> </VStack>
</Stack> </HStack>
); );
} }
// Full order/pickup detail panel — rendered INLINE in the left column (not a modal) // Full order/pickup detail panel — rendered in the right-side sheet so the map
// so the map stays visible and zoomed to the stop while these details are read. // stays visible and zoomed to the stop while these details are read.
function OrderDetailPanel({ data, onBack }) { function OrderDetailPanel({ data, onBack }) {
const { order, rider, mode, index } = data; const { order, rider, mode, index } = data;
const meta = STATUS_META[order.status] || {}; const meta = STATUS_META[order.status] || {};
const StatusIcon = meta.icon || CheckCircleRoundedIcon;
const isPickup = mode === 'pickup'; const isPickup = mode === 'pickup';
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: '#fff' }}> <Layout
{/* Header */} height="fill"
<Box sx={{ background: `linear-gradient(135deg, ${rider.color} 0%, ${alpha(rider.color, 0.82)} 100%)`, px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}> header={
<Button onClick={onBack} startIcon={<ArrowBackRoundedIcon />} size="small" <div style={{ background: rider.color, color: '#fff', padding: '20px 24px' }}>
sx={{ color: 'rgba(255,255,255,0.95)', textTransform: 'none', fontWeight: 600, mb: 1.25, ml: -0.5, '&:hover': { bgcolor: 'rgba(255,255,255,0.12)' } }}> <Button variant="ghost" onClick={onBack} icon={<ArrowLeft size={16} />} style={{ color: '#fff', marginBottom: '12px', marginLeft: '-8px' }}>Close</Button>
Close <HStack gap={1.5} wrap="wrap" style={{ marginBottom: '10px' }}>
</Button> <Badge variant="neutral" label={isPickup ? 'Pickup' : 'Delivery'} style={{ background: 'rgba(255,255,255,0.2)', color: '#fff' }} />
<Stack direction="row" alignItems="center" spacing={0.75} useFlexGap sx={{ flexWrap: 'wrap', mb: 1 }}> <Badge variant="neutral" label={meta.label || order.status} style={{ background: 'rgba(255,255,255,0.2)', color: '#fff' }} />
<Chip size="small" icon={(isPickup ? <StorefrontOutlinedIcon /> : <DeliveryDiningRoundedIcon />)} label={isPickup ? 'Pickup' : 'Delivery'} </HStack>
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700, '& .MuiChip-icon': { color: '#fff' } }} /> <Heading level={4} style={{ color: '#fff', margin: 0 }}>{order.customer}</Heading>
<Chip size="small" icon={<StatusIcon sx={{ color: '#fff !important' }} />} label={meta.label || order.status} <Text type="supporting" style={{ color: '#fff', opacity: 0.9, fontFamily: 'monospace' }}>#{order.orderId}</Text>
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} /> </div>
</Stack> }
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25 }}>{order.customer}</Typography> content={
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mt: 0.25 }}>#{order.orderId}</Typography> <LayoutContent isScrollable padding={0}>
</Box> <VStack gap={3} style={{ padding: '20px', background: 'var(--color-background-muted)' }}>
<Card padding={3}>
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>
{isPickup ? 'Merchant / Sender' : 'Customer'}
</Text>
<VStack gap={3}>
<DetailRow icon={isPickup ? Store : User} label="Name" value={order.customer} />
<DetailRow icon={Phone} label="Phone" value={order.phone} />
<DetailRow icon={MapPin} label="Pickup address" value={order.address} />
</VStack>
</Card>
{/* Body */} <Card padding={3}>
<Box sx={{ p: 2, bgcolor: '#F8F9FB', flex: 1, overflow: 'auto' }}> <Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Pickup Summary</Text>
<Stack spacing={1.75}> <Grid columns={2} gap={3}>
<DetailRow icon={Clock} label="Picked at" value={order.time} />
<DetailRow icon={CalendarClock} label="Time slot" value={order.slot} />
<DetailRow icon={Package} label="Items" value={order.items != null ? `${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}` : ''} />
<DetailRow icon={Scale} label="Weight" value={order.weight} />
<DetailRow icon={Ruler} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} />
<DetailRow icon={Wallet} label="Payment" value={order.payment} />
</Grid>
</Card>
{/* Contact */} {order.cod > 0 && (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}> <div style={{ padding: '14px', borderRadius: 'var(--radius-element)', background: 'var(--color-background-orange)', border: '1px solid var(--color-icon-orange)' }}>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}> <HStack gap={2} align="center">
{isPickup ? 'MERCHANT / SENDER' : 'CUSTOMER'} <span style={{ color: 'var(--color-icon-orange)', display: 'flex' }}><Wallet size={20} /></span>
</Typography> <VStack gap={0}>
<Stack spacing={1.75}> <Text type="supporting" weight="bold" style={{ color: 'var(--color-text-orange)' }}>CASH ON PICKUP</Text>
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : PersonOutlineOutlinedIcon} label="Name" value={order.customer} /> <Text type="body" weight="bold" style={{ color: 'var(--color-text-orange)' }}>{inr(order.cod)}</Text>
<DetailRow icon={PhoneOutlinedIcon} label="Phone" value={order.phone} /> </VStack>
<DetailRow icon={PlaceOutlinedIcon} label="Pickup address" value={order.address} /> </HStack>
</Stack> </div>
</Paper> )}
{/* Pickup summary */} <Card padding={3}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}> <Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Route &amp; Location</Text>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}> <VStack gap={3}>
PICKUP SUMMARY <DetailRow icon={isPickup ? Store : Warehouse} label="Route leg" value={isPickup ? `${order.customer}${HUB.label}` : `${HUB.label}${order.customer}`} />
</Typography> <DetailRow icon={MapPin} label="Coordinates" value={order.lat != null && order.lng != null ? `${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}` : ''} />
<Grid container rowSpacing={2} columnSpacing={1.5}> {order.instructions && <DetailRow icon={FileText} label="Instructions" value={order.instructions} valueColor="var(--color-text-secondary)" />}
<Grid item xs={6}><DetailRow icon={AccessTimeOutlinedIcon} label="Picked at" value={order.time} /></Grid> </VStack>
<Grid item xs={6}><DetailRow icon={ScheduleOutlinedIcon} label="Time slot" value={order.slot} /></Grid> </Card>
<Grid item xs={6}><DetailRow icon={Inventory2OutlinedIcon} label="Items" value={order.items != null ? `${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}` : ''} /></Grid>
<Grid item xs={6}><DetailRow icon={ScaleOutlinedIcon} label="Weight" value={order.weight} /></Grid>
<Grid item xs={6}><DetailRow icon={StraightenRoundedIcon} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} /></Grid>
<Grid item xs={6}><DetailRow icon={PaymentsOutlinedIcon} label="Payment" value={order.payment} /></Grid>
</Grid>
</Paper>
{order.cod > 0 && ( <Card padding={3}>
<Box sx={{ p: 2, borderRadius: 2, bgcolor: alpha('#F29900', 0.1), border: `1px solid ${alpha('#F29900', 0.3)}` }}> <Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Assigned Miler</Text>
<Stack direction="row" alignItems="center" spacing={1.5}> <HStack gap={3} align="center">
<PaymentsOutlinedIcon sx={{ color: '#B06000' }} /> <Avatar name={rider.name} size={40} style={{ backgroundColor: hexAlpha(rider.color, 0.14), color: rider.color }} />
<Box> <VStack gap={0} style={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#B06000', fontWeight: 700 }}>CASH ON PICKUP</Typography> <Text type="body" weight="bold">{rider.name}</Text>
<Typography sx={{ fontWeight: 800, color: '#B06000' }}>{inr(order.cod)}</Typography> <Text type="supporting" color="secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Text>
</Box> </VStack>
</Stack> </HStack>
</Box> </Card>
)} </VStack>
</LayoutContent>
{/* Route & location */} }
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}> footer={
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}> <div style={{ padding: '16px', borderTop: '1px solid var(--color-border)', display: 'flex', gap: '10px' }}>
ROUTE &amp; LOCATION <Button variant="secondary" style={{ flex: 1, justifyContent: 'center' }} icon={<Phone size={16} />} onClick={() => window.open(`tel:${order.phone}`, '_self')}>Call</Button>
</Typography> <Button variant="primary" style={{ flex: 1, justifyContent: 'center', background: rider.color }} icon={<CheckCircle2 size={16} />} onClick={onBack}>Done</Button>
<Stack spacing={1.75}> </div>
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg" }
value={isPickup ? `${order.customer}${HUB.label}` : `${HUB.label}${order.customer}`} /> />
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates"
value={order.lat != null && order.lng != null ? `${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}` : ''} />
{order.instructions && <DetailRow icon={NotesOutlinedIcon} label="Instructions" value={order.instructions} valueColor="#5F6368" />}
</Stack>
</Paper>
{/* Assigned miler */}
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
ASSIGNED MILER
</Typography>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 40, height: 40, bgcolor: alpha(rider.color, 0.14), color: rider.color, fontWeight: 700 }}>{initials(rider.name)}</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }} noWrap>{rider.name}</Typography>
<Typography variant="caption" color="text.secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Typography>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
{/* Footer */}
<Box sx={{ p: 2, borderTop: '1px solid #E9ECEF', bgcolor: '#fff', display: 'flex', gap: 1.25, flexShrink: 0 }}>
<Button fullWidth variant="outlined" startIcon={<CallOutlinedIcon />} href={`tel:${order.phone}`} sx={{ borderRadius: 2 }}>Call</Button>
<Button fullWidth variant="contained" startIcon={<CheckCircleRoundedIcon />} onClick={onBack} sx={{ borderRadius: 2, bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }}>Done</Button>
</Box>
</Box>
); );
} }
@@ -370,6 +345,7 @@ function OrderDetailPanel({ data, onBack }) {
// MAIN COMPONENT // MAIN COMPONENT
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
export default function RiderRoutes() { export default function RiderRoutes() {
const isMdDown = useMediaQuery('(max-width: 900px)');
const [mode] = useState('pickup'); // pickups only const [mode] = useState('pickup'); // pickups only
const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -384,7 +360,6 @@ export default function RiderRoutes() {
const [playing, setPlaying] = useState(null); const [playing, setPlaying] = useState(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [speed, setSpeed] = useState(1); const [speed, setSpeed] = useState(1);
const [speedAnchor, setSpeedAnchor] = useState(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const lastTsRef = useRef(0); const lastTsRef = useRef(0);
const resolvedRef = useRef({}); // keys we've already fetched/attempted const resolvedRef = useRef({}); // keys we've already fetched/attempted
@@ -519,59 +494,50 @@ export default function RiderRoutes() {
return { rider, path, travelled, pos }; return { rider, path, travelled, pos };
}, [playing, progress, pathFor, riders]); }, [playing, progress, pathFor, riders]);
return ( const speedMenuItems = [0.5, 1, 2, 4].map((s) => ({
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}> label: `${s}× speed`,
{/* ── Header ── */} icon: speed === s ? <CheckCircle2 size={14} /> : undefined,
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems={{ md: 'center' }} gap={1} mb={2} sx={{ mb: 2 }}> onClick: () => setSpeed(s)
<Stack direction="row" alignItems="center" spacing={2.5}> }));
<Avatar sx={{ bgcolor: alpha('#C01227', 0.1), color: '#C01227', width: 56, height: 56, borderRadius: 2 }}>
<RouteOutlinedIcon sx={{ fontSize: 30 }} />
</Avatar>
<Box>
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: '-0.4px', lineHeight: 1.2 }}>Rider Routes</Typography>
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
Pickups each miler covered today open any order for full details, or press play to replay the trip.
</Typography>
</Box>
</Stack>
</Stack> return (
<div style={{ paddingBottom: '32px' }}>
<PageHeader
icon={Route}
title="Rider Routes"
subtitle="Pickups each miler covered today — open any order for full details, or press play to replay the trip."
/>
{!loading && riders.length === 0 && ( {!loading && riders.length === 0 && (
<Card elevation={0} sx={{ borderRadius: 2, border: '1px dashed #CED4DA', py: 8, textAlign: 'center', mt: 3 }}> <EmptyState
<RouteOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Route size={48} /></span>}
<Typography variant="h6" sx={{ fontWeight: 700, color: '#495057' }}>No rider routes today</Typography> title="No rider routes today"
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> description="Once milers are assigned pickups, their planned stops will show up here."
Once milers are assigned pickups, their planned stops will show up here. style={{ padding: '48px 0', marginBottom: '20px' }}
</Typography> />
</Card>
)} )}
{/* ── Analysis KPI strip ── */} {/* KPI strip */}
<Grid container spacing={2.5} mt={4} sx={{ mb: 2 }}> <Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={1.5} style={{ marginBottom: '16px' }}>
{[ <StatCard size="sm" icon={Users} label="Active Milers" value={kpi.activeRiders} tone="blue" />
{ icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${riders.length}`, color: '#1A73E8', bg: '#E8F0FE' }, <StatCard size="sm" icon={Store} label={modeCfg.label} value={kpi.orders} tone="red" />
{ icon: modeCfg.icon, label: modeCfg.label, value: kpi.orders, sub: 'pickups covered', color: '#C01227', bg: alpha('#C01227', 0.1) }, <StatCard size="sm" icon={CheckCircle2} label={modeCfg.doneLabel} value={kpi.done} tone="green" />
{ icon: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' }, <StatCard size="sm" icon={Ruler} label="Distance" value={`${kpi.km} km`} tone="purple" />
{ icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
].map((k, i) => (
<Grid item xs={6} sm={6} md={3} key={i}><KpiCard {...k} /></Grid>
))}
</Grid> </Grid>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, pb: 4 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px' }}>
{/* ── Left control panel — the milers list; clicking an order opens the {/* ── Left control panel — the milers list; clicking an order opens the
full detail in a right-side drawer (below). ── */} full detail in a right-side sheet (below). ── */}
<Box sx={{ width: { xs: '100%', lg: 380 }, flexShrink: 0 }}> <div style={{ flex: '1 1 340px', maxWidth: 400 }}>
<Card sx={{ borderRadius: 2, border: '1px solid #ECEEF1', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}> <Panel>
<Box sx={{ px: 3, py: 2.25, borderBottom: '1px solid #F1F3F5', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <HStack justify="between" align="center" style={{ padding: '14px 20px', borderBottom: '1px solid var(--color-border)' }}>
<Typography sx={{ fontWeight: 700 }}>Milers &amp; their {modeCfg.label.toLowerCase()}</Typography> <Text type="body" weight="bold">Milers &amp; their {modeCfg.label.toLowerCase()}</Text>
{(playing || progress > 0) && ( {(playing || progress > 0) && (
<Button size="small" startIcon={<ReplayRoundedIcon />} onClick={resetAnim} sx={{ textTransform: 'none', color: '#5F6368' }}>Reset</Button> <Button variant="ghost" size="sm" icon={<RotateCcw size={14} />} onClick={resetAnim}>Reset</Button>
)} )}
</Box> </HStack>
<List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}> <div style={{ maxHeight: 640, overflowY: 'auto' }}>
{riders.map((rider) => { {riders.map((rider) => {
const trip = rider[mode]; const trip = rider[mode];
const orders = trip.stops.filter((s) => s.kind === 'order'); const orders = trip.stops.filter((s) => s.kind === 'order');
@@ -580,174 +546,150 @@ export default function RiderRoutes() {
const isPlaying = playing === rider.id; const isPlaying = playing === rider.id;
const isVisible = visible[rider.id]; const isVisible = visible[rider.id];
return ( return (
<Box key={rider.id} sx={{ borderBottom: '1px solid #F4F5F7' }}> <div key={rider.id} style={{ borderBottom: '1px solid var(--color-border)', opacity: isVisible ? 1 : 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5, gap: 1, opacity: isVisible ? 1 : 0.5 }}> <HStack gap={2} align="center" style={{ padding: '14px 16px 8px' }}>
<Box sx={{ width: 6, height: 40, borderRadius: 2, bgcolor: rider.color, flexShrink: 0 }} /> <span style={{ width: 5, height: 36, borderRadius: 'var(--radius-full)', background: rider.color, flexShrink: 0 }} />
<Avatar sx={{ width: 38, height: 38, bgcolor: alpha(rider.color, 0.12), color: rider.color, fontWeight: 700, fontSize: 14 }}>{initials(rider.name)}</Avatar> <Avatar name={rider.name} size={36} style={{ backgroundColor: hexAlpha(rider.color, 0.14), color: rider.color, borderRadius: '50%' }} />
<ListItemButton disableGutters onClick={() => setExpanded(isOpen ? null : rider.id)} sx={{ flex: 1, borderRadius: 2, px: 1, py: 0.5, minWidth: 0 }}> <button
<ListItemText type="button"
primary={<Typography sx={{ fontWeight: 700, fontSize: '0.92rem' }} noWrap>{rider.name}</Typography>} onClick={() => setExpanded(isOpen ? null : rider.id)}
secondary={<Typography variant="caption" color="text.secondary" noWrap>{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km</Typography>} style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', textAlign: 'left' }}
/> >
{isOpen ? <ExpandLessRoundedIcon sx={{ color: '#9AA0A6' }} /> : <ExpandMoreRoundedIcon sx={{ color: '#9AA0A6' }} />} <VStack gap={0} style={{ minWidth: 0 }}>
</ListItemButton> <Text type="body" weight="bold" maxLines={1}>{rider.name}</Text>
</Box> <Text type="supporting" color="secondary" maxLines={1}>{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km</Text>
</VStack>
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex', flexShrink: 0 }}>
{isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</span>
</button>
</HStack>
{/* per-rider stat chips (analysis) */} {/* per-rider stat badges (analysis) */}
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ px: 2, pb: 1.5 }}> <HStack gap={1} wrap="wrap" style={{ padding: '0 16px 10px' }}>
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${orders.length} ${modeCfg.label.toLowerCase()}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} /> <Badge variant="neutral" label={`${orders.length} ${modeCfg.label.toLowerCase()}`} />
<Chip size="small" icon={<CheckCircleRoundedIcon sx={{ fontSize: '14px !important', color: '#1E8E3E !important' }} />} label={done} sx={{ height: 24, fontWeight: 600, bgcolor: alpha('#1E8E3E', 0.1), color: '#1E8E3E' }} /> <Badge variant="green" label={String(done)} />
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.distanceKm} km`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} /> <Badge variant="neutral" label={`${trip.distanceKm} km`} />
<Chip size="small" icon={<AccessTimeOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.startTime}${trip.endTime}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} /> </HStack>
</Stack>
{/* action row */} {/* action row */}
<Stack direction="row" gap={1.25} sx={{ px: 2, pb: 2 }}> <HStack gap={2} style={{ padding: '0 16px 14px' }}>
<Button <Button
size="small" size="sm"
variant={isPlaying ? 'contained' : 'outlined'} variant={isPlaying ? 'primary' : 'secondary'}
startIcon={isPlaying ? <PauseRoundedIcon /> : <PlayArrowRoundedIcon />} icon={isPlaying ? <Pause size={14} /> : <Play size={14} />}
onClick={() => startAnim(rider.id)} onClick={() => startAnim(rider.id)}
sx={{ style={{ flex: 1, justifyContent: 'center', ...(isPlaying ? { background: rider.color } : { color: rider.color, borderColor: hexAlpha(rider.color, 0.5) }) }}
textTransform: 'none', fontWeight: 600, borderRadius: 2, flex: 1,
...(isPlaying
? { bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }
: { color: rider.color, borderColor: alpha(rider.color, 0.5), '&:hover': { borderColor: rider.color, bgcolor: alpha(rider.color, 0.06) } }),
}}
> >
{isPlaying ? 'Playing…' : 'Animate route'} {isPlaying ? 'Playing…' : 'Animate route'}
</Button> </Button>
<Tooltip title={isVisible ? 'Hide route' : 'Show route'}> <IconButton
<IconButton size="small" onClick={() => toggleVisible(rider.id)} sx={{ border: '1px solid #E9ECEF', borderRadius: 2 }}> size="sm"
{isVisible ? <VisibilityOutlinedIcon fontSize="small" /> : <VisibilityOffOutlinedIcon fontSize="small" />} variant="secondary"
</IconButton> label={isVisible ? 'Hide route' : 'Show route'}
</Tooltip> tooltip={isVisible ? 'Hide route' : 'Show route'}
</Stack> icon={isVisible ? <Eye size={14} /> : <EyeOff size={14} />}
onClick={() => toggleVisible(rider.id)}
/>
</HStack>
{isPlaying && ( {isPlaying && (
<Box sx={{ px: 2, pb: 1.5 }}> <div style={{ padding: '0 16px 14px' }}>
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: rider.color, borderRadius: 2 } }} /> <ProgressBar label={`${rider.name} playback`} isLabelHidden value={progress * 100} variant="accent" />
</Box> </div>
)} )}
{/* expandable stops — rich order cards; click to open detail */} {/* expandable stops — rich order cards; click to open detail */}
<Collapse in={isOpen} unmountOnExit> {isOpen && (
<Box sx={{ px: 2, pb: 2, pt: 0.5 }}> <VStack gap={1.5} style={{ padding: '0 16px 16px' }}>
<Stack spacing={1.25}> {trip.stops.map((s, i) => {
{trip.stops.map((s, i) => { const key = `${rider.id}-${i}`;
const key = `${rider.id}-${i}`; const meta = STATUS_META[s.status];
const isHub = s.kind === 'hub'; const StatusIcon = meta?.icon;
const meta = STATUS_META[s.status]; return (
const StatusIcon = meta?.icon; <div
key={key}
onMouseEnter={() => setFocusedStop(key)}
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
onClick={() => openDetail(rider, s, i)}
style={{
padding: '12px', borderRadius: 'var(--radius-element)', cursor: 'pointer',
border: `1px solid ${focusedStop === key ? hexAlpha(rider.color, 0.55) : 'var(--color-border)'}`,
background: focusedStop === key ? hexAlpha(rider.color, 0.05) : 'var(--color-background-surface)'
}}
>
<HStack justify="between" align="center" gap={1}>
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
<span style={{ width: 24, height: 24, borderRadius: 'var(--radius-inner)', flexShrink: 0, background: hexAlpha(rider.color, 0.14), color: rider.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 12 }}>{i}</span>
<Text type="body" weight="bold" maxLines={1}>
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 600 }}>Order </span>#{s.orderId}
</Text>
</HStack>
{meta && <Badge variant={meta.variant} label={meta.label} icon={StatusIcon ? <StatusIcon size={12} /> : undefined} />}
</HStack>
// Hub return — compact row, not a card. <HStack justify="between" align="center" gap={1} style={{ marginTop: 8 }}>
if (isHub) { <HStack gap={1} align="center" style={{ minWidth: 0 }}>
return ( <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Bike size={14} /></span>
<Stack key={key} direction="row" alignItems="center" gap={1.25} sx={{ px: 0.5, py: 0.5 }}> <Text type="supporting" color="secondary" maxLines={1}>{rider.name}</Text>
<Box sx={{ width: 28, height: 28, borderRadius: 2, flexShrink: 0, bgcolor: alpha('#C01227', 0.12), color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> </HStack>
<WarehouseRoundedIcon sx={{ fontSize: 16 }} /> <HStack gap={1} align="center" style={{ flexShrink: 0 }}>
</Box> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Clock size={13} /></span>
<Box sx={{ minWidth: 0 }}> <Text type="supporting" color="secondary">{s.time}</Text>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.label}</Typography> </HStack>
<Typography variant="caption" color="text.secondary">Back to hub · {s.time}</Typography> </HStack>
</Box>
</Stack>
);
}
return ( <Divider style={{ margin: '10px 0' }} />
<Box
key={key}
onMouseEnter={() => setFocusedStop(key)}
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
onClick={() => openDetail(rider, s, i)}
sx={{
p: 1.5, borderRadius: 2, cursor: 'pointer', transition: 'all .15s',
border: '1px solid', borderColor: focusedStop === key ? alpha(rider.color, 0.55) : '#ECEEF1',
bgcolor: focusedStop === key ? alpha(rider.color, 0.04) : '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.03)',
'&:hover': { borderColor: alpha(rider.color, 0.55), boxShadow: '0 4px 14px rgba(0,0,0,0.07)' },
}}
>
{/* order id + status */}
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
<Box sx={{ width: 26, height: 26, borderRadius: 2, flexShrink: 0, bgcolor: alpha(rider.color, 0.14), color: rider.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 12 }}>{i}</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>
<Box component="span" sx={{ color: '#9AA0A6', fontWeight: 600 }}>Order </Box>#{s.orderId}
</Typography>
</Stack>
{meta && (
<Chip size="small" icon={<StatusIcon sx={{ fontSize: '14px !important', color: `${meta.color} !important` }} />} label={meta.label}
sx={{ height: 22, fontSize: '0.68rem', fontWeight: 700, color: meta.color, bgcolor: alpha(meta.color, 0.1), flexShrink: 0 }} />
)}
</Stack>
{/* rider + time */} <HStack gap={1} align="center">
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mt: 1 }}> <span style={{ color: rider.color, display: 'flex' }}><Store size={14} /></span>
<Stack direction="row" alignItems="center" gap={0.75} sx={{ minWidth: 0 }}> <Text type="body" weight="bold" maxLines={1}>{s.customer}</Text>
<DeliveryDiningRoundedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0 }} /> </HStack>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }} noWrap>{rider.name}</Typography> <HStack gap={1} align="start" style={{ marginTop: 4 }}>
</Stack> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex', marginTop: 1 }}><MapPin size={14} /></span>
<Stack direction="row" alignItems="center" gap={0.5} sx={{ flexShrink: 0 }}> <Text type="supporting" color="secondary" maxLines={1} style={{ flex: 1 }}>{s.address}</Text>
<AccessTimeOutlinedIcon sx={{ fontSize: 14, color: '#9AA0A6' }} /> </HStack>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{s.time}</Typography>
</Stack>
</Stack>
<Divider sx={{ my: 1.25 }} /> {(s.legKm != null || s.weight || s.items != null) && (
<HStack gap={1} wrap="wrap" style={{ marginTop: 10 }}>
{/* merchant + address */} {s.legKm != null && <Badge variant="neutral" label={`${s.legKm} km`} />}
<Stack direction="row" alignItems="center" gap={0.75}> {s.weight && <Badge variant="neutral" label={s.weight} />}
<StorefrontOutlinedIcon sx={{ fontSize: 16, color: rider.color, flexShrink: 0 }} /> {s.items != null && <Badge variant="neutral" label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} />}
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.customer}</Typography> </HStack>
</Stack> )}
<Stack direction="row" alignItems="flex-start" gap={0.75} sx={{ mt: 0.5 }}> </div>
<PlaceOutlinedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0, mt: '1px' }} /> );
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography> })}
</Stack> </VStack>
)}
{/* metric chips — only render fields the API actually returned */} </div>
{(s.legKm != null || s.weight || s.items != null) && (
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.75, mt: 1.25 }}>
{s.legKm != null && <Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
{s.weight && <Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
{s.items != null && <Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
</Stack>
)}
</Box>
);
})}
</Stack>
</Box>
</Collapse>
</Box>
); );
})} })}
</List> </div>
</Card> </Panel>
</Box> </div>
{/* ── Map ── */} {/* ── Map ── */}
<Box ref={mapRef} sx={{ flex: 1, minWidth: 0, scrollMarginTop: 72 }}> <div ref={mapRef} style={{ flex: '2 1 480px', minWidth: 0, scrollMarginTop: 72 }}>
<Card sx={{ height: { xs: 460, sm: 560, lg: 700 }, borderRadius: 2, border: '1px solid #ECEEF1', overflow: 'hidden', position: 'relative', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}> <Card padding={0} style={{ height: isMdDown ? 400 : 560, border: '1px solid var(--color-border)', overflow: 'hidden', position: 'relative' }}>
{playState && ( {playState && (
<Box sx={{ position: 'absolute', top: 14, left: 14, zIndex: 1000, minWidth: 230, bgcolor: 'rgba(255,255,255,0.97)', borderRadius: 2, p: 1.75, boxShadow: '0 8px 28px rgba(0,0,0,0.14)', border: `1px solid ${alpha(playState.rider.color, 0.3)}` }}> <div style={{ position: 'absolute', top: 14, left: 14, zIndex: 1000, minWidth: 220, background: 'var(--color-background-surface)', borderRadius: 'var(--radius-element)', padding: '14px', boxShadow: 'var(--shadow-high, 0 8px 28px rgba(0,0,0,0.14))', border: `1px solid ${hexAlpha(playState.rider.color, 0.3)}` }}>
<Stack direction="row" alignItems="center" spacing={1.25} mb={1}> <HStack gap={2} align="center" style={{ marginBottom: 8 }}>
<Avatar sx={{ width: 30, height: 30, bgcolor: playState.rider.color, fontSize: 12, fontWeight: 700 }}>{initials(playState.rider.name)}</Avatar> <Avatar name={playState.rider.name} size={28} style={{ backgroundColor: playState.rider.color, color: '#fff' }} />
<Box sx={{ flex: 1, minWidth: 0 }}> <VStack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem' }} noWrap>{playState.rider.name}</Typography> <Text type="supporting" weight="bold" maxLines={1}>{playState.rider.name}</Text>
<Typography variant="caption" color="text.secondary">Replaying {modeCfg.label.toLowerCase().replace(/s$/, '')} · {Math.round(progress * 100)}%</Typography> <Text type="supporting" color="secondary">Replaying pickup · {Math.round(progress * 100)}%</Text>
</Box> </VStack>
<IconButton size="small" onClick={() => setPlaying(null)}><PauseRoundedIcon fontSize="small" /></IconButton> <IconButton size="sm" variant="ghost" label="Pause" icon={<Pause size={14} />} onClick={() => setPlaying(null)} />
<Tooltip title="Speed"><IconButton size="small" onClick={(e) => setSpeedAnchor(e.currentTarget)}><SpeedRoundedIcon fontSize="small" /></IconButton></Tooltip> <DropdownMenu
</Stack> button={{ icon: <Gauge size={14} />, variant: 'ghost', size: 'sm', isIconOnly: true, label: 'Change speed' }}
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: playState.rider.color, borderRadius: 2 } }} /> hasChevron={false}
</Box> items={speedMenuItems}
/>
</HStack>
<ProgressBar label="Playback progress" isLabelHidden value={progress * 100} variant="accent" />
</div>
)} )}
<Menu anchorEl={speedAnchor} open={Boolean(speedAnchor)} onClose={() => setSpeedAnchor(null)}>
{[0.5, 1, 2, 4].map((s) => (<MenuItem key={s} selected={speed === s} onClick={() => { setSpeed(s); setSpeedAnchor(null); }}>{s}× speed</MenuItem>))}
</Menu>
<MapContainer center={[HUB.lat, HUB.lng]} zoom={11} scrollWheelZoom style={{ height: '100%', width: '100%' }}> <MapContainer center={[HUB.lat, HUB.lng]} zoom={11} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
<MapResizeHandler /> <MapResizeHandler />
@@ -784,17 +726,17 @@ export default function RiderRoutes() {
eventHandlers={{ click: () => openDetail(rider, s, i) }} eventHandlers={{ click: () => openDetail(rider, s, i) }}
> >
<Popup> <Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{modeCfg.pointLabel} {i} · {s.customer}</Typography> <strong>{modeCfg.pointLabel} {i} · {s.customer}</strong>
<Typography variant="caption" display="block">{s.address}</Typography> <div>{s.address}</div>
<Typography variant="caption" display="block">#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label}</Typography> <div>#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label}</div>
<Typography variant="caption" display="block" sx={{ color: rider.color, fontWeight: 700, cursor: 'pointer' }} onClick={() => openDetail(rider, s, i)}>View full details </Typography> <div style={{ color: rider.color, fontWeight: 700, cursor: 'pointer' }} onClick={() => openDetail(rider, s, i)}>View full details </div>
</Popup> </Popup>
</Marker> </Marker>
); );
})} })}
{!dimmed && endStop && ( {!dimmed && endStop && (
<Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}> <Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}>
<LTooltip direction="top" offset={[0, -14]}>{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}</LTooltip> <LTooltip direction="top" offset={[0, -14]}>Returned to hub · {rider[mode].endTime}</LTooltip>
</Marker> </Marker>
)} )}
</React.Fragment> </React.Fragment>
@@ -813,49 +755,38 @@ export default function RiderRoutes() {
</Card> </Card>
{/* Legend */} {/* Legend */}
<Stack direction="row" flexWrap="wrap" gap={2} sx={{ mt: 2 }}> <HStack gap={3} wrap="wrap" style={{ marginTop: '14px' }}>
{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)}> <HStack key={r.id} gap={1.5} align="center" style={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}>
<Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} /> <span style={{ width: 18, height: 4, borderRadius: 'var(--radius-full)', background: r.color, display: 'inline-block' }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography> <Text type="supporting" weight="semibold" color="secondary">{r.name}</Text>
</Stack> </HStack>
))} ))}
<Box sx={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<Stack direction="row" alignItems="center" gap={0.75}> <HStack gap={1} align="center">
<FlagRoundedIcon sx={{ fontSize: 16, color: '#1E8E3E' }} /> <span style={{ color: 'var(--color-icon-green)', display: 'flex' }}><Flag size={16} /></span>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{mode === 'pickup' ? 'Hub return' : 'Trip end'}</Typography> <Text type="supporting" weight="semibold" color="secondary">Hub return</Text>
</Stack> </HStack>
<Stack direction="row" alignItems="center" gap={0.75}> <HStack gap={1} align="center">
<WarehouseRoundedIcon sx={{ fontSize: 16, color: '#C01227' }} /> <span style={{ color: 'var(--color-brand)', display: 'flex' }}><Warehouse size={16} /></span>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>Hub</Typography> <Text type="supporting" weight="semibold" color="secondary">Hub</Text>
</Stack> </HStack>
</Stack> </HStack>
</Box> </div>
</Box> </div>
{/* ── Order detail — right-side drawer ── */} {/* ── Order detail — right-side sheet ── */}
<Drawer <Dialog
anchor="right" isOpen={Boolean(detail)}
open={Boolean(detail)} onOpenChange={(o) => { if (!o) closeDetail(); }}
onClose={closeDetail} width={380}
sx={{ maxHeight="100vh"
// Temporary drawers render at zIndex.drawer (1200) here, below the app bar style={{ height: '100vh' }}
// (drawer + 1). On mobile the sheet starts at top:0, so its Close button position={{ top: 0, right: 0, bottom: 0 }}
// would hide under the app bar. Lift the whole modal above it. purpose="info"
zIndex: (t) => t.zIndex.modal,
'& .MuiDrawer-paper': {
width: { xs: '100%', sm: 360 },
maxWidth: '100%',
top: { xs: 0, sm: 64 },
height: { xs: '100%', sm: 'calc(100% - 64px)' },
borderTopLeftRadius: { sm: 16 },
overflow: 'hidden',
boxShadow: '-8px 0 30px rgba(0,0,0,0.12)',
},
}}
> >
{detail && <OrderDetailPanel data={detail} onBack={closeDetail} />} {detail && <OrderDetailPanel data={detail} onBack={closeDetail} />}
</Drawer> </Dialog>
</Box> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,17 @@
/* eslint-disable react/prop-types */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import { Box, QrCode, ArrowRight, HelpCircle, AlertTriangle, Truck, MapPin, Snowflake, Tag } from 'lucide-react';
Box, Typography, Card, CardContent, CardHeader, Grid, TextField, Button, import { Card } from '@astryxdesign/core/Card';
Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip, CircularProgress import { TextInput } from '@astryxdesign/core/TextInput';
} from '@mui/material'; import { Banner } from '@astryxdesign/core/Banner';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; import { Badge } from '@astryxdesign/core/Badge';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { Heading, Text } from '@astryxdesign/core/Text';
import HelpIcon from '@mui/icons-material/Help'; import { HStack, VStack } from '@astryxdesign/core/Layout';
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';
import Panel from '@/components/Panel';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader';
import { getRouting, getInboundToday } from '@/api/hub'; import { getRouting, getInboundToday } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
@@ -20,6 +19,56 @@ import { getHubContext } from '@/auth/session';
const isException = (condition) => const isException = (condition) =>
/damag|wet|crush|missing|broken/i.test(condition || ''); /damag|wet|crush|missing|broken/i.test(condition || '');
// Classify the next action from the API's nexthop + condition. Colors map to
// Astryx's categorical tokens so each queue type reads as part of the same
// status language as Badge/StatCard, not one-off hex values.
const determineNextAction = (pkg) => {
if (isException(pkg.condition)) {
return { queue: 'Needs Checking', action: 'Set aside for a supervisor', tone: 'red', icon: <AlertTriangle size={22} /> };
}
if (pkg.iscoldchain) {
return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', tone: 'cyan', icon: <Snowflake size={22} /> };
}
if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) {
return { queue: 'Transfer to Another City', action: pkg.nexthop, tone: 'purple', icon: <Truck size={22} /> };
}
if ((pkg.nexthop || '').toLowerCase().includes('local')) {
return { queue: 'Local Delivery', action: `Send to ${pkg.destination || 'the delivery lane'}`, tone: 'green', icon: <MapPin size={22} /> };
}
return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', tone: 'gray', icon: <HelpCircle size={22} /> };
};
const TONE_VARS = {
red: { icon: 'var(--color-icon-red)', bg: 'var(--color-background-red)' },
cyan: { icon: 'var(--color-icon-cyan)', bg: 'var(--color-background-cyan)' },
purple: { icon: 'var(--color-icon-purple)', bg: 'var(--color-background-purple)' },
green: { icon: 'var(--color-icon-green)', bg: 'var(--color-background-green)' },
gray: { icon: 'var(--color-icon-secondary)', bg: 'var(--color-background-muted)' }
};
function IconTile({ tone = 'blue', size = 32, children }) {
const { icon, bg } = tone === 'blue'
? { icon: 'var(--color-icon-blue)', bg: 'var(--color-background-blue)' }
: TONE_VARS[tone] || TONE_VARS.gray;
return (
<span
style={{
width: size,
height: size,
borderRadius: 'var(--radius-element)',
background: bg,
color: icon,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
>
{children}
</span>
);
}
export default function Routing() { export default function Routing() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hub = getHubContext(); const hub = getHubContext();
@@ -68,230 +117,189 @@ export default function Routing() {
} }
}, [searchParams, handleSearch]); }, [searchParams, handleSearch]);
// Classify the next action from the API's nexthop + condition.
const determineNextAction = (pkg) => {
if (isException(pkg.condition)) {
return { queue: 'Needs Checking', action: 'Set aside for a supervisor', color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon /> };
}
if (pkg.iscoldchain) {
return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', color: '#00838F', bg: '#E0F7FA', icon: <AcUnitIcon /> };
}
if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) {
return { queue: 'Transfer to Another City', action: pkg.nexthop, color: '#8E24AA', bg: '#F3E5F5', 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: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
};
return ( return (
<Box> <div style={{ paddingBottom: '32px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<Box sx={{ mb: 4 }}> <PageHeader
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography> icon={Box}
<Typography variant="body1" color="text.secondary"> title="Where Does It Go?"
Scan a parcel and we'll tell you exactly what to do with it next — deliver locally, transfer to another city, or set it aside. subtitle="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>
<Grid container spacing={3.5}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', flex: 1, alignItems: 'stretch' }}>
{/* Search Panel */} {/* Search Panel */}
<Grid size={{ xs: 12, md: 5, lg: 4 }} > <VStack gap={2} style={{ flex: '1 1 300px', display: 'flex', flexDirection: 'column' }}>
<Stack spacing={3}> <Panel style={{ flex: 3, display: 'flex', flexDirection: 'column' }}>
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}> <div style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '10px' }}>
<CardHeader <IconTile size={30}><QrCode size={16} /></IconTile>
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Scan a Parcel</Typography>} <Text type="body" weight="bold">Scan a Parcel</Text>
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><QrCodeScannerIcon /></Avatar>} </div>
<VStack gap={3} style={{ padding: '16px', flex: 1 }}>
<TextInput
label="Parcel tracking number"
placeholder="e.g. DM-1001"
value={searchId}
onChange={setSearchId}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/> />
<Divider /> <Button
<CardContent sx={{ pt: 3 }}> onClick={() => handleSearch()}
<Stack spacing={2}> style={{ width: '100%', justifyContent: 'center' }}
<TextField disabled={loading}
fullWidth variant="primary"
label="Parcel tracking number" >
placeholder="e.g. DM-1001" {loading ? 'Checking…' : 'Tell Me What To Do'}
value={searchId} </Button>
onChange={(e) => setSearchId(e.target.value)} </VStack>
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} </Panel>
InputProps={{
sx: { borderRadius: 2, bgcolor: '#fff' }
}}
/>
<Button
variant="contained"
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' } }}
>
{loading ? 'Checking' : 'Tell Me What To Do'}
</Button>
</Stack>
</CardContent>
</Card>
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}> <Panel style={{ flex: 7, display: 'flex', flexDirection: 'column' }}>
<CardHeader <div style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '10px' }}>
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Parcels Waiting</Typography>} <IconTile size={30}><Tag size={16} /></IconTile>
subheader={<Typography variant="caption" sx={{ color: 'text.secondary' }}>Tap a parcel to check it</Typography>} <VStack gap={0}>
avatar={<Avatar sx={{ bgcolor: '#E8F0FE', color: '#1A73E8', borderRadius: 2 }}><LocalOfferIcon /></Avatar>} <Text type="body" weight="bold">Parcels Waiting</Text>
/> <Text type="supporting" color="secondary">Tap a parcel to check it</Text>
<Divider /> </VStack>
<CardContent sx={{ pt: 2, p: 1 }}> </div>
<List disablePadding> <div style={{ padding: '8px', display: 'flex', flexDirection: 'column', gap: '6px', flex: 1, overflowY: 'auto' }}>
{waiting.length === 0 && ( {waiting.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}> <div style={{ padding: '16px', textAlign: 'center' }}>
No parcels inbounded today. <Text type="supporting" color="secondary">No parcels inbounded today.</Text>
</Typography> </div>
)} )}
{waiting.map((pkg) => ( {waiting.map((pkg) => {
<ListItemButton const selected = searchId === pkg.trackingno;
key={pkg.trackingno} return (
onClick={() => { <div
setSearchId(pkg.trackingno); key={pkg.trackingno}
handleSearch(pkg.trackingno); onClick={() => {
}} setSearchId(pkg.trackingno);
selected={searchId === pkg.trackingno} handleSearch(pkg.trackingno);
sx={{ }}
borderRadius: 2, mb: 1, p: 2, style={{
border: '1px solid', padding: '10px 12px',
borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea', borderRadius: 'var(--radius-inner)',
bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff', cursor: 'pointer',
'&:hover': { bgcolor: '#f8f9fa' } border: `1px solid ${selected ? 'var(--color-accent)' : 'var(--color-border)'}`,
}} backgroundColor: selected ? 'var(--color-accent-muted)' : 'var(--color-background-surface)',
> transition: 'all 0.15s ease'
<ListItemText }}
primary={pkg.trackingno} >
secondary={`Going to ${pkg.dest}`} <Text type="body" weight="bold" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>{pkg.trackingno}</Text>
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === pkg.trackingno ? '#C01227' : '#212529' }} <Text type="supporting" color="secondary" style={{ display: 'block', marginTop: 2 }}>Going to {pkg.dest}</Text>
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }} </div>
/> );
</ListItemButton> })}
))} </div>
</List> </Panel>
</CardContent> </VStack>
</Card>
</Stack>
</Grid>
{/* Visual Route Guideline Panel */} {/* Visual Route Guideline Panel */}
<Grid size={{ xs: 12, md: 7, lg: 8 }} > <div style={{ display: 'flex', flexDirection: 'column', flex: 2, minWidth: '320px' }}>
{!searched ? ( {!searched ? (
<Card sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: { xs: 8, sm: 15 }, px: 2, border: '2px dashed #CED4DA', bgcolor: '#fff', borderRadius: 2, height: '100%', boxShadow: 'none' }}> <Card style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '64px 24px', border: '2px dashed #cbd5e1', backgroundColor: '#f8fafc', boxShadow: 'none', height: '100%' }}>
<Stack alignItems="center" spacing={3}> <div style={{ background: '#e2e8f0', color: '#94a3b8', width: '80px', height: '80px', borderRadius: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '24px' }}>
<Avatar sx={{ bgcolor: '#F8F9FA', color: '#ADB5BD', width: 80, height: 80 }}> <QrCode size={40} />
<QrCodeScannerIcon sx={{ fontSize: 40 }} /> </div>
</Avatar> <Heading level={4} style={{ color: '#475569', marginBottom: '8px', textAlign: 'center' }}>Scan a parcel to begin</Heading>
<Box sx={{ textAlign: 'center' }}> <Text type="body" color="secondary" style={{ textAlign: 'center' }}>We&apos;ll show you where it needs to go.</Text>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#495057' }}>Scan a parcel to begin</Typography>
<Typography variant="body2" sx={{ mt: 1, color: '#868E96' }}>We'll show you where it needs to go.</Typography>
</Box>
</Stack>
</Card> </Card>
) : matchedPkg ? ( ) : matchedPkg ? (
<Card sx={{ height: '100%', borderRadius: 2, border: '1px solid #eaeaea', boxShadow: '0px 4px 20px rgba(0,0,0,0.06)' }}> <Panel style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
<CardHeader <div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap' }}>
title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.trackingno}</Typography>} <VStack gap={0}>
subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>{matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}</Typography>} <Text type="large" weight="bold" style={{ fontFamily: 'monospace' }}>{matchedPkg.trackingno}</Text>
action={ <Text type="supporting" color="secondary">
<Chip {matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}
label={matchedPkg.condition || 'Good'} </Text>
sx={{ fontWeight: 700, bgcolor: isException(matchedPkg.condition) ? '#FCE8E6' : '#F1F3F5', color: isException(matchedPkg.condition) ? '#D93025' : '#495057', borderRadius: 2 }} </VStack>
/> <Badge variant={isException(matchedPkg.condition) ? 'error' : 'neutral'} label={matchedPkg.condition || 'Good'} />
} </div>
sx={{ px: { xs: 2.5, sm: 4 }, pt: { xs: 3, sm: 4 }, pb: 2 }} <VStack gap={3} style={{ padding: '16px' }}>
/>
<Divider />
<CardContent sx={{ p: { xs: 2.5, sm: 4 } }}>
{/* Action Banner */} {/* Action Banner */}
<Box sx={{ mb: { xs: 3, sm: 5 }, p: { xs: 2, sm: 3 }, bgcolor: '#F8F9FB', borderRadius: 2, display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, border: '1px solid #E9ECEF' }}> <HStack
<Avatar sx={{ bgcolor: '#212529', color: '#fff', width: { xs: 50, sm: 64 }, height: { xs: 50, sm: 64 }, borderRadius: 2, flexShrink: 0 }}> gap={4}
{determineNextAction(matchedPkg).icon} align="center"
</Avatar> style={{ padding: '16px', backgroundColor: TONE_VARS[determineNextAction(matchedPkg).tone]?.bg || 'var(--color-background-muted)', borderRadius: 'var(--radius-element)' }}
<Box sx={{ minWidth: 0 }}> >
<Typography variant="overline" sx={{ display: 'block', color: '#6c757d', fontWeight: 700, letterSpacing: 1, lineHeight: 1.3 }}> <IconTile tone={determineNextAction(matchedPkg).tone} size={52}>{determineNextAction(matchedPkg).icon}</IconTile>
<VStack gap={0.5}>
<Text type="supporting" weight="bold" style={{ letterSpacing: '0.06em', textTransform: 'uppercase' }} color="secondary">
{determineNextAction(matchedPkg).queue} {determineNextAction(matchedPkg).queue}
</Typography> </Text>
<Typography sx={{ fontWeight: 800, color: '#212529', mt: 0.5, fontSize: { xs: '1.3rem', sm: '2.125rem' }, lineHeight: 1.15 }}> <Text type="large" weight="bold" style={{ lineHeight: 1.2 }}>
{determineNextAction(matchedPkg).action} {determineNextAction(matchedPkg).action}
</Typography> </Text>
</Box> </VStack>
</Box> </HStack>
{/* Parcel Journey */} {/* Parcel Journey */}
<Box sx={{ mb: { xs: 3, sm: 5 }, p: { xs: 2, sm: 3 }, bgcolor: '#f8f9fa', borderRadius: 2, border: '1px solid #eaeaea' }}> <div style={{ padding: '16px', backgroundColor: 'var(--color-background-surface)', borderRadius: 'var(--radius-element)', border: '1px solid var(--color-border)' }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mb: { xs: 2, sm: 3 }, letterSpacing: '0.08em', fontWeight: 700 }}> <Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '12px' }}>
The Parcel's Journey The Parcel&apos;s Journey
</Typography> </Text>
<Stack <HStack align="center" justify="between" wrap="wrap" gap={2}>
direction={{ xs: 'column', sm: 'row' }} <VStack gap={0.5} style={{ flex: 1, minWidth: 100 }}>
alignItems={{ xs: 'stretch', sm: 'center' }} <Text type="supporting" color="secondary">Right Now (Here)</Text>
justifyContent="space-between" <Text type="body" weight="bold">{HUB_NAME}</Text>
spacing={2} </VStack>
> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><ArrowRight size={16} /></span>
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}> <VStack gap={0.5} style={{ flex: 1, minWidth: 100, textAlign: 'center' }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Right Now (Here)</Typography> <Text type="supporting" color="secondary">Put On Shelf</Text>
<Typography variant="body1" sx={{ fontWeight: 800, color: '#C01227', mt: 0.5 }}>{HUB_NAME}</Typography> <Text type="body" weight="semibold">{matchedPkg.recommendedshelf || '—'}</Text>
</Box> </VStack>
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} /> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><ArrowRight size={16} /></span>
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}> <VStack gap={0.5} style={{ flex: 1, minWidth: 100, textAlign: 'right' }}>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Put On Shelf</Typography> <Text type="supporting" color="secondary">Going To</Text>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.recommendedshelf || ''}</Typography> <Text type="body" weight="semibold">{matchedPkg.destination || '—'}</Text>
</Box> </VStack>
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} /> </HStack>
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}> </div>
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Going To</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.destination || ''}</Typography>
</Box>
</Stack>
</Box>
{/* Handling Alerts */} {/* Handling Alerts */}
{isException(matchedPkg.condition) && ( {isException(matchedPkg.condition) && (
<Alert severity="error" variant="filled" sx={{ borderRadius: 2, mb: 2 }}> <Banner
<AlertTitle sx={{ fontWeight: 700 }}>Something's Wrong</AlertTitle> status="error"
Condition reported as <strong>{matchedPkg.condition}</strong>. Set it aside in the Exception Area for a supervisor. title="Something's Wrong"
</Alert> description={`Condition reported as ${matchedPkg.condition}. Set it aside in the Exception Area for a supervisor.`}
/>
)} )}
{matchedPkg.iscoldchain && ( {matchedPkg.iscoldchain && (
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #b2ebf2', bgcolor: '#e0f7fa', mb: 2 }}> <Banner
<AlertTitle sx={{ fontWeight: 700, color: '#00838F' }}>Cold chain parcel</AlertTitle> status="info"
Move this parcel to the <strong>Cold Room (Zone C)</strong> right away. title="Cold chain parcel"
</Alert> description="Move this parcel to the Cold Room (Zone C) right away."
/>
)} )}
{determineNextAction(matchedPkg).queue === 'Transfer to Another City' && ( {determineNextAction(matchedPkg).queue === 'Transfer to Another City' && (
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #bae1ff', bgcolor: '#e6f2ff' }}> <Banner
<AlertTitle sx={{ fontWeight: 700, color: '#0055b3' }}>Put in the transfer bin</AlertTitle> status="info"
<strong>{matchedPkg.nexthop}</strong>. It will go out with the next city transfer. title="Put in the transfer bin"
</Alert> description={`${matchedPkg.nexthop}. It will go out with the next city transfer.`}
/>
)} )}
{determineNextAction(matchedPkg).queue === 'Local Delivery' && ( {determineNextAction(matchedPkg).queue === 'Local Delivery' && (
<Alert severity="success" sx={{ borderRadius: 2, border: '1px solid #c3e6cb', bgcolor: '#d4edda' }}> <Banner
<AlertTitle sx={{ fontWeight: 700, color: '#155724' }}>Ready for local delivery</AlertTitle> status="success"
Place this parcel in the <strong>{matchedPkg.destination}</strong> lane so a miler can take it out. title="Ready for local delivery"
</Alert> description={`Place this parcel in the ${matchedPkg.destination} lane so a miler can take it out.`}
/>
)} )}
</VStack>
</CardContent> </Panel>
</Card>
) : ( ) : (
<Card sx={{ height: '100%', borderRadius: 2 }}> <Card style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 24px' }}>
<CardContent sx={{ py: { xs: 8, sm: 15 }, textAlign: 'center' }}> <Banner
<Alert severity="error" sx={{ justifyContent: 'center', borderRadius: 2 }}> status="error"
<AlertTitle sx={{ fontWeight: 700 }}>Package Not Found</AlertTitle> title="Package Not Found"
The package code <strong>{searchId}</strong> is not registered in the system. description={`The package code ${searchId} is not registered in the system.`}
</Alert> />
</CardContent>
</Card> </Card>
)} )}
</Grid> </div>
</Grid> </div>
</Box> </div>
); );
} }

View File

@@ -1,21 +1,35 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import { Bike, Truck, MapPin } from 'lucide-react';
Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem,
ListItemAvatar, ListItemText, Badge, Divider, Alert
} from '@mui/material';
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
import HubIcon from '@mui/icons-material/Hub';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { Card } from '@astryxdesign/core/Card';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Banner } from '@astryxdesign/core/Banner';
import { Badge } from '@astryxdesign/core/Badge';
import { Avatar } from '@astryxdesign/core/Avatar';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { StatusDot } from '@astryxdesign/core/StatusDot';
import { Collapsible } from '@astryxdesign/core/Collapsible';
import { getMilers, getMilerLocations, getHubs, getTripsheetsInTransit } from '@/api/hub'; import { getMilers, getMilerLocations, getHubs, getTripsheetsInTransit } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
// Keeps Leaflet's canvas sized correctly when the container resizes (sidebar function useMediaQuery(query) {
// toggle, window resize, first paint inside a flex box). Without this the map const [matches, setMatches] = useState(window.matchMedia(query).matches);
// renders grey/blank tiles — the #1 reason a real map "doesn't show". useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
function MapResizeHandler() { function MapResizeHandler() {
const map = useMap(); const map = useMap();
useEffect(() => { useEffect(() => {
@@ -29,7 +43,6 @@ function MapResizeHandler() {
return null; return null;
} }
// Fix leaflet default marker icons issue
delete L.Icon.Default.prototype._getIconUrl; delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
@@ -37,24 +50,23 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
}); });
// Custom HTML Icons for Leaflet
const createHubIcon = () => new L.DivIcon({ const createHubIcon = () => new L.DivIcon({
className: 'custom-leaflet-icon', className: 'custom-leaflet-icon',
html: `<div style="background-color: #C01227; width: 36px; height: 36px; border-radius: 50%; border: 3px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="20" height="20" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div>`, html: `<div style="background-color: #ef4444; width: 36px; height: 36px; border-radius: 50%; border: 3px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="20" height="20" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>`,
iconSize: [36, 36], iconSize: [36, 36],
iconAnchor: [18, 18], iconAnchor: [18, 18],
}); });
const createRiderIcon = () => new L.DivIcon({ const createRiderIcon = () => new L.DivIcon({
className: 'custom-leaflet-icon', className: 'custom-leaflet-icon',
html: `<div style="background-color: #0070f3; width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 10px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="16" height="16" viewBox="0 0 24 24"><path d="M19 7c0-1.1-.9-2-2-2h-3v2h3v2.65L13.52 14H10V9H6c-2.21 0-4 1.79-4 4v3h2c0 1.66 1.34 3 3 3s3-1.34 3-3h4.48L19 10.35V7zM7 17c-.55 0-1-.45-1-1h2c0 .55-.45 1-1 1z"/></svg></div>`, html: `<div style="background-color: #3b82f6; width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 10px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" viewBox="0 0 24 24"><circle cx="5.5" cy="17.5" r="3.5"/><circle cx="18.5" cy="17.5" r="3.5"/><path d="M15 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-3 11.5V14l-3-3 4-3 2 3h2"/></svg></div>`,
iconSize: [30, 30], iconSize: [30, 30],
iconAnchor: [15, 15], iconAnchor: [15, 15],
}); });
const createLinehaulIcon = () => new L.DivIcon({ const createLinehaulIcon = () => new L.DivIcon({
className: 'custom-leaflet-icon', className: 'custom-leaflet-icon',
html: `<div style="background-color: #ff9900; width: 34px; height: 34px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.18); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="18" height="18" viewBox="0 0 24 24"><path d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg></div>`, html: `<div style="background-color: #f59e0b; width: 34px; height: 34px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.18); display: flex; align-items: center; justify-content: center;"><svg fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" viewBox="0 0 24 24"><path d="M5 18H3c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1h10c.6 0 1 .4 1 1v11"/><path d="M14 9h4l4 4v5h-3"/><circle cx="7" cy="18" r="2"/><circle cx="17" cy="18" r="2"/></svg></div>`,
iconSize: [34, 34], iconSize: [34, 34],
iconAnchor: [17, 17], iconAnchor: [17, 17],
}); });
@@ -62,26 +74,26 @@ const createLinehaulIcon = () => new L.DivIcon({
const prettyHubType = (t) => const prettyHubType = (t) =>
({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub'); ({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub');
// Colour-code a miler pin/list item by their live availability status.
const statusMeta = (status) => { const statusMeta = (status) => {
switch (status) { switch (status) {
case 'Assigned': case 'Assigned':
case 'On Pickup': case 'On Pickup':
return { color: '#1A73E8', label: status }; return { color: '#3b82f6', bg: '#eff6ff', label: status, variant: 'info' };
case 'Available': case 'Available':
case 'Idle': case 'Idle':
return { color: '#1E8E3E', label: status }; return { color: '#10b981', bg: '#ecfdf5', label: status, variant: 'success' };
case 'On_Break': case 'On_Break':
case 'On Break': case 'On Break':
return { color: '#8E24AA', label: 'On Break' }; return { color: '#a855f7', bg: '#faf5ff', label: 'On Break', variant: 'warning' };
case 'Offline': case 'Offline':
return { color: '#80868B', label: 'Offline' }; return { color: '#64748b', bg: '#f8fafc', label: 'Offline', variant: 'neutral' };
default: default:
return { color: '#0070f3', label: status || 'Active' }; return { color: '#3b82f6', bg: '#eff6ff', label: status || 'Active', variant: 'info' };
} }
}; };
export default function TrackingMap() { export default function TrackingMap() {
const isMdDown = useMediaQuery('(max-width: 900px)');
const hub = getHubContext(); const hub = getHubContext();
const [milers, setMilers] = useState([]); const [milers, setMilers] = useState([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -89,7 +101,6 @@ export default function TrackingMap() {
const [linehauls, setLinehauls] = useState([]); const [linehauls, setLinehauls] = useState([]);
const didLoad = useRef(false); const didLoad = useRef(false);
// Hub pins are static per session — load once from /hub/hubs (has lat/lon).
useEffect(() => { useEffect(() => {
getHubs() getHubs()
.then((res) => .then((res) =>
@@ -102,10 +113,6 @@ export default function TrackingMap() {
.catch(() => setHubs([])); .catch(() => setHubs([]));
}, []); }, []);
// Poll live miler GPS every 5 seconds. The guide's /admin/milers/locations
// (Redis GEO) returns nothing on this backend, so we read coordinates from
// /hub/milers (currentlatitude/currentlongitude) and fall back to the GEO
// endpoint if it ever starts returning data.
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const normalize = (list) => const normalize = (list) =>
@@ -144,7 +151,6 @@ export default function TrackingMap() {
}; };
}, []); }, []);
// Poll real transfer trucks in transit every 5 seconds.
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const poll = async () => { const poll = async () => {
@@ -164,8 +170,8 @@ export default function TrackingMap() {
current: [t.currentlat, t.currentlon] current: [t.currentlat, t.currentlon]
})) }))
); );
} catch { } catch (err) {
/* leave last-known trucks on transient failure */ console.error(err);
} }
}; };
poll(); poll();
@@ -176,7 +182,6 @@ export default function TrackingMap() {
}; };
}, []); }, []);
// Centre on the milers when we have them, otherwise a sensible national view.
const mapCenter = const mapCenter =
milers.length > 0 milers.length > 0
? [ ? [
@@ -186,164 +191,169 @@ export default function TrackingMap() {
: [22.0, 76.0]; : [22.0, 76.0];
return ( return (
<Box> <div style={{ paddingBottom: '32px' }}>
<Box sx={{ mb: 4 }}> <div style={{ marginBottom: '32px' }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#111' }}>Live Map</Typography> <Heading level={2} style={{ color: '#0f172a', marginBottom: '8px' }}>Live Map</Heading>
<Typography variant="body1" color="text.secondary">See where your milers and transfer trucks are right now, on the map.</Typography> <Text type="body" color="secondary">See where your milers and transfer trucks are right now, on the map.</Text>
</Box> </div>
{error && ( {error && (
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 3, borderRadius: 2 }}> <div style={{ marginBottom: '24px' }}>
{error} <Banner status="error" title="Error" description={error} />
</Alert> </div>
)} )}
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3.5 }}> <div style={{ display: 'flex', flexDirection: 'row', gap: '24px', flexWrap: 'wrap' }}>
{/* Map Canvas Frame */} {/* Map Canvas Frame */}
<Box sx={{ flex: 2, minWidth: 0 }}> <div style={{ flex: '2 1 0%', minWidth: '300px' }}>
<Card sx={{ height: { xs: 380, sm: 480, lg: 620 }, width: '100%', position: 'relative', overflow: 'hidden', border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 24px rgba(0,0,0,0.02)' }}> <Card style={{ height: isMdDown ? '400px' : '700px', width: '100%', position: 'relative', overflow: 'hidden', padding: 0, borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
<MapContainer center={mapCenter} zoom={6} scrollWheelZoom style={{ height: '100%', width: '100%' }}> <MapContainer center={mapCenter} zoom={6} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
<MapResizeHandler /> <MapResizeHandler />
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/> />
{/* Plot Hubs */}
{hubs.map((h) => ( {hubs.map((h) => (
<Marker key={h.id} position={h.position} icon={createHubIcon()}> <Marker key={h.id} position={h.position} icon={createHubIcon()}>
<Popup> <Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{h.name}</Typography> <div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{h.name}</div>
<Typography variant="caption">{h.type}</Typography> <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{h.type}</div>
</Popup> </Popup>
</Marker> </Marker>
))} ))}
{/* Plot live Milers */}
{milers.map((m) => ( {milers.map((m) => (
<Marker key={m.userid} position={[m.lat, m.lon]} icon={createRiderIcon()}> <Marker key={m.userid} position={[m.lat, m.lon]} icon={createRiderIcon()}>
<Popup> <Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{m.displayname || `Miler ${m.userid}`}</Typography> <div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{m.displayname || `Miler ${m.userid}`}</div>
<Typography variant="caption" display="block">{statusMeta(m.status).label}</Typography> <div style={{ fontSize: '0.75rem', color: statusMeta(m.status).color }}>{statusMeta(m.status).label}</div>
{m.bookingid && <Typography variant="caption" display="block">On booking #{m.bookingid}</Typography>} {m.bookingid && <div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '4px' }}>On booking #{m.bookingid}</div>}
</Popup> </Popup>
</Marker> </Marker>
))} ))}
{/* Plot transfer trucks in transit (real positions) */}
{linehauls.map((lh) => ( {linehauls.map((lh) => (
<React.Fragment key={lh.id}> <React.Fragment key={lh.id}>
<Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} /> <Polyline positions={[lh.start, lh.end]} color="#ef4444" dashArray="10, 10" weight={3} opacity={0.5} />
<Marker position={lh.current} icon={createLinehaulIcon()}> <Marker position={lh.current} icon={createLinehaulIcon()}>
<Popup> <Popup>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography> <div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{lh.name}</div>
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography> <div style={{ fontSize: '0.75rem', color: '#64748b' }}>Progress: {Math.round(lh.progress * 100)}%</div>
</Popup> </Popup>
</Marker> </Marker>
</React.Fragment> </React.Fragment>
))} ))}
</MapContainer> </MapContainer>
</Card> </Card>
</Box> </div>
{/* Sidebar Status Trackers */} {/* Sidebar Status Trackers */}
<Box sx={{ flex: 1, minWidth: 0 }}> <div style={{ flex: '1 1 0%', minWidth: '300px', display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Stack spacing={3}>
{/* Active Network Hub Nodes Summary */} <Card style={{ padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}> <Collapsible
<CardHeader defaultIsOpen={true}
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Active Hub Nodes</Typography>} style={{ paddingRight: '20px' }}
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><HubIcon fontSize="small" /></Avatar>} trigger={
/> <div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<Divider /> <div style={{ background: '#fef2f2', color: '#ef4444', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<List disablePadding> <MapPin size={18} />
</div>
<Heading level={5} style={{ margin: 0 }}>Active Hub Nodes</Heading>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
{hubs.length === 0 && ( {hubs.length === 0 && (
<ListItem sx={{ px: 3, py: 1.5 }}> <EmptyState title="No hubs" description="No hubs to show" isCompact />
<ListItemText primary="No hubs to show" primaryTypographyProps={{ color: 'text.secondary', fontSize: '0.85rem' }} />
</ListItem>
)} )}
{hubs.map((h) => ( {hubs.map((h) => (
<ListItem key={h.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}> <div key={h.id} style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #f1f5f9' }}>
<ListItemText <div>
primary={h.name} <div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{h.name}</div>
secondary={h.type} <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{h.type}</div>
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.9rem' }} </div>
secondaryTypographyProps={{ fontSize: '0.75rem' }} <Badge variant="success" label="Online" />
/> </div>
<Chip size="small" label="Online" sx={{ bgcolor: '#00A85410', color: '#00A854', fontWeight: 600, fontSize: '0.75rem' }} />
</ListItem>
))} ))}
</List> </div>
</Card> </Collapsible>
</Card>
{/* Real-time Last Mile Miler Logs */} <Card style={{ padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}> <Collapsible
<CardHeader defaultIsOpen={true}
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Milers Out Now{hub.city ? ` (${hub.city})` : ''}</Typography>} style={{ paddingRight: '20px' }}
avatar={<Avatar sx={{ bgcolor: '#0070f310', color: '#0070f3', borderRadius: 2 }}><DeliveryDiningIcon fontSize="small" /></Avatar>} trigger={
/> <div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<Divider /> <div style={{ background: '#eff6ff', color: '#3b82f6', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{milers.length === 0 ? ( <Bike size={18} />
<Box sx={{ px: 3, py: 3 }}> </div>
<Typography variant="body2" color="text.secondary">No milers reporting a location right now.</Typography> <Heading level={5} style={{ margin: 0 }}>Milers Out Now {hub.city ? `(${hub.city})` : ''}</Heading>
</Box> </div>
) : ( }
<List disablePadding> >
{milers.map((m) => { <div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
{milers.length === 0 ? (
<EmptyState title="No active milers" description="No milers reporting a location right now." isCompact />
) : (
milers.map((m) => {
const meta = statusMeta(m.status); const meta = statusMeta(m.status);
const name = m.displayname || `Miler ${m.userid}`; const name = m.displayname || `Miler ${m.userid}`;
return ( return (
<ListItem key={m.userid} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}> <div key={m.userid} style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: '12px', borderBottom: '1px solid #f1f5f9' }}>
<ListItemAvatar> <Avatar
<Badge color="success" variant="dot" overlap="circular" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}> name={name}
<Avatar sx={{ width: 34, height: 34, bgcolor: meta.color, fontWeight: 700, fontSize: 13 }}>{name.charAt(0)}</Avatar> status={<StatusDot variant={m.status === 'Offline' ? 'error' : 'success'} label={m.status === 'Offline' ? 'Offline' : 'Online'} />}
</Badge> size="md"
</ListItemAvatar>
<ListItemText
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: meta.color, fontWeight: 700, ml: 1, bgcolor: `${meta.color}12`, px: 1, py: 0.5, borderRadius: 2, whiteSpace: 'nowrap' }}> <div style={{ flex: 1 }}>
{meta.label} <div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{name}</div>
</Typography> <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{m.bookingid ? `On booking #${m.bookingid}` : 'No active booking'}</div>
</ListItem> </div>
{m.status !== 'Offline' && <Badge variant={meta.variant} label={meta.label} />}
</div>
); );
})} })
</List> )}
)} </div>
</Card> </Collapsible>
</Card>
{/* Linehaul Fleet Shipments Tracker */} <Card style={{ padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 3, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}> <Collapsible
<CardHeader defaultIsOpen={false}
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>City Transfers</Typography>} style={{ paddingRight: '20px' }}
avatar={<Avatar sx={{ bgcolor: '#ff990010', color: '#ff9900', borderRadius: 2 }}><LocalShippingIcon fontSize="small" /></Avatar>} trigger={
/> <div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<Divider /> <div style={{ background: '#fffbeb', color: '#f59e0b', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<List disablePadding> <Truck size={18} />
</div>
<Heading level={5} style={{ margin: 0 }}>City Transfers</Heading>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
{linehauls.length === 0 && (
<EmptyState title="No transfers" description="No trucks in transit." isCompact />
)}
{linehauls.map(lh => ( {linehauls.map(lh => (
<ListItem key={lh.id} sx={{ px: 3, py: 2 }}> <div key={lh.id} style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #f1f5f9' }}>
<ListItemText <div>
primary={lh.name} <div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{lh.name}</div>
secondary={`Status: ${lh.status}`} <div style={{ fontSize: '0.75rem', color: '#64748b' }}>Status: {lh.status}</div>
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }} </div>
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.25 }} <Badge variant="warning" label={`${Math.round(lh.progress * 100)}% route`} />
/> </div>
<Typography variant="caption" sx={{ color: '#e08500', fontWeight: 700, bgcolor: '#ff990008', px: 1, py: 0.5, borderRadius: 2 }}>
{Math.round(lh.progress * 100)}% route
</Typography>
</ListItem>
))} ))}
</List> </div>
</Card> </Collapsible>
</Card>
</Stack> </div>
</Box> </div>
</Box> </div>
</Box>
); );
} }

View File

@@ -1,191 +0,0 @@
import customShadows from './shadows';
// ==============================|| DOORMILE THEME - COMPONENT OVERRIDES ||============================== //
// Clean, corporate Material Design tuning for the whole console.
export default function componentsOverride(theme) {
const { palette } = theme;
return {
MuiCssBaseline: {
styleOverrides: {
html: { WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' },
body: { backgroundColor: palette.background.default },
'*::-webkit-scrollbar': { width: 8, height: 8 },
'*::-webkit-scrollbar-thumb': { background: palette.grey[300], borderRadius: 8 },
'*::-webkit-scrollbar-thumb:hover': { background: palette.grey[400] }
}
},
MuiButton: {
defaultProps: { disableElevation: true, disableRipple: false },
styleOverrides: {
root: {
borderRadius: 8,
fontWeight: 600,
padding: '8px 18px',
transition: 'background-color .2s ease, box-shadow .2s ease, transform .12s ease, border-color .2s ease',
'&:active': { transform: 'translateY(0.5px)' },
'&.Mui-focusVisible': { boxShadow: `0 0 0 3px ${palette.primary.lighter}` }
},
containedPrimary: {
boxShadow: customShadows.primaryGlow,
'&:hover': { boxShadow: customShadows.primaryGlowHover, backgroundColor: palette.primary.dark }
},
outlined: {
borderColor: palette.grey[300],
'&:hover': { borderColor: palette.grey[400], backgroundColor: palette.grey[50] }
},
text: { '&:hover': { backgroundColor: palette.grey[100] } },
sizeLarge: { padding: '11px 24px', fontSize: '0.9375rem' },
sizeSmall: { padding: '5px 14px' },
// Give every button clear breathing room between its icon and label
startIcon: { marginRight: 10 },
endIcon: { marginLeft: 10 }
}
},
MuiIconButton: {
styleOverrides: { root: { borderRadius: 8 } }
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 14,
border: `1px solid ${palette.grey[200]}`,
boxShadow: customShadows.card,
backgroundImage: 'none',
transition: 'box-shadow .22s ease, border-color .22s ease, transform .22s ease'
}
}
},
MuiCardHeader: {
defaultProps: { titleTypographyProps: { variant: 'h5' }, subheaderTypographyProps: { variant: 'caption' } },
styleOverrides: { root: { padding: 20 } }
},
MuiCardContent: {
styleOverrides: { root: { padding: 20, '&:last-child': { paddingBottom: 20 } } }
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: { rounded: { borderRadius: 14 } }
},
MuiToggleButtonGroup: {
styleOverrides: {
root: { backgroundColor: palette.grey[100], borderRadius: 10, padding: 4, gap: 4 },
grouped: {
border: 0,
borderRadius: '8px !important',
margin: 0
}
}
},
MuiToggleButton: {
styleOverrides: {
root: {
border: 0,
borderRadius: 8,
padding: '6px 12px',
color: palette.grey[600],
transition: 'all .18s ease',
'&:hover': { backgroundColor: palette.grey[200] },
'&.Mui-selected': {
backgroundColor: palette.background.paper,
color: palette.primary.main,
boxShadow: customShadows.card,
'&:hover': { backgroundColor: palette.background.paper }
}
}
}
},
MuiChip: {
styleOverrides: {
root: { borderRadius: 6, fontWeight: 600, fontSize: '0.75rem' },
sizeSmall: { height: 22 },
label: { paddingLeft: 8, paddingRight: 8 }
}
},
MuiTableCell: {
styleOverrides: {
root: { borderColor: palette.grey[200], padding: '12px 16px', fontSize: '0.8125rem' },
head: {
fontWeight: 600,
color: palette.grey[600],
backgroundColor: palette.grey[50],
textTransform: 'none',
whiteSpace: 'nowrap'
}
}
},
MuiTableRow: {
styleOverrides: {
root: { transition: 'background-color .15s ease', '&:hover': { backgroundColor: palette.grey[50] } }
}
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: 8,
backgroundColor: palette.background.paper,
transition: 'box-shadow .2s ease, border-color .2s ease',
'& .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[300], transition: 'border-color .2s ease' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[400] },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${palette.primary.lighter}` },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: palette.primary.main, borderWidth: 1 }
},
input: { padding: '11px 14px' }
}
},
MuiSelect: {
styleOverrides: {
select: {
paddingTop: '11px',
paddingBottom: '11px',
paddingLeft: '14px',
display: 'flex',
alignItems: 'center'
}
}
},
MuiInputLabel: {
styleOverrides: { root: { color: palette.grey[600], fontSize: '0.875rem' } }
},
MuiTab: {
styleOverrides: {
root: { textTransform: 'none', fontWeight: 600, minHeight: 46, fontSize: '0.875rem' }
}
},
MuiTabs: {
styleOverrides: { indicator: { height: 3, borderRadius: 3 } }
},
MuiTooltip: {
styleOverrides: {
tooltip: { backgroundColor: palette.grey[800], borderRadius: 6, fontSize: '0.75rem', padding: '6px 10px' }
}
},
MuiDialog: {
styleOverrides: { paper: { borderRadius: 16 } }
},
MuiAvatar: {
styleOverrides: { root: { fontWeight: 600, fontSize: '0.875rem' } }
},
MuiListItemButton: {
styleOverrides: { root: { borderRadius: 8 } }
},
MuiLinearProgress: {
styleOverrides: { root: { borderRadius: 8, height: 6, backgroundColor: palette.grey[200] } }
},
MuiMenu: {
styleOverrides: { paper: { borderRadius: 12, boxShadow: customShadows.dropdown, marginTop: 6, border: `1px solid ${palette.grey[200]}` } }
},
MuiMenuItem: {
styleOverrides: {
root: {
borderRadius: 8,
margin: '2px 6px',
padding: '8px 10px',
fontSize: '0.875rem',
'&:hover': { backgroundColor: palette.grey[100] }
}
}
}
};
}

View File

@@ -1,20 +0,0 @@
import { createTheme } from '@mui/material/styles';
import palette from './palette';
import typography from './typography';
import customShadows from './shadows';
import componentsOverride from './componentsOverride';
// ==============================|| DOORMILE THEME - ENTRY ||============================== //
let theme = createTheme({
palette,
typography,
shape: { borderRadius: 6 },
customShadows,
mixins: { toolbar: { minHeight: 64 } }
});
theme.components = componentsOverride(theme);
export default theme;

View File

@@ -1,102 +0,0 @@
// ==============================|| DOORMILE THEME - PALETTE ||============================== //
// Corporate red brand palette. Brand red #C01227.
export const grey = {
0: '#FFFFFF',
50: '#F8F9FA',
100: '#F1F3F5',
200: '#E9ECEF',
300: '#DEE2E6',
400: '#CED4DA',
500: '#ADB5BD',
600: '#868E96',
700: '#495057',
800: '#343A40', // Slate
900: '#212529', // Graphite
A50: '#F8F9FA',
A100: '#E9ECEF'
};
const palette = {
mode: 'light',
common: { black: '#000000', white: '#FFFFFF' },
primary: {
lighter: '#F8E0E3',
100: '#EFBBC1',
200: '#E08A92',
light: '#D6515C',
400: '#CC2E3C',
main: '#C01227',
dark: '#9E0E20',
700: '#870C1B',
darker: '#7E0B17',
900: '#520710',
contrastText: '#FFFFFF'
},
secondary: {
lighter: grey[100],
100: grey[100],
200: grey[200],
light: grey[300],
400: grey[400],
main: grey[500],
600: grey[600],
dark: grey[700],
800: grey[800],
darker: grey[900],
A100: grey[0],
A200: grey[400],
A300: grey[700],
contrastText: grey[0]
},
error: {
lighter: '#FEEAE9',
light: '#F88078',
main: '#F04134',
dark: '#A82216',
darker: '#7A150C',
contrastText: '#FFFFFF'
},
warning: {
lighter: '#FFF7E0',
light: '#FFD666',
main: '#FFBF00',
dark: '#B38600',
darker: '#805F00',
contrastText: '#262626'
},
info: {
lighter: '#E0F7F8',
light: '#66CBD2',
main: '#00A2AE',
dark: '#00727B',
darker: '#005159',
contrastText: '#FFFFFF'
},
success: {
lighter: '#E3F6EC',
light: '#5CC98C',
main: '#00A854',
dark: '#00773B',
darker: '#00552A',
contrastText: '#FFFFFF'
},
grey,
text: {
primary: grey[800],
secondary: grey[600],
disabled: grey[400]
},
action: {
disabled: grey[300],
hover: 'rgba(192, 18, 39, 0.04)',
selected: 'rgba(192, 18, 39, 0.08)'
},
divider: grey[200],
background: {
paper: '#FFFFFF',
default: grey.A50
}
};
export default palette;

View File

@@ -1,15 +0,0 @@
// ==============================|| DOORMILE THEME - CUSTOM SHADOWS ||============================== //
// Layered elevation (a tight contact shadow + a soft ambient shadow) reads far more
// premium than a single blurry drop shadow. Brand glow on CTAs is kept restrained.
const customShadows = {
card: '0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06)',
cardHover: '0 10px 28px -6px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.05)',
widget: '0 1px 3px rgba(16, 24, 40, 0.06)',
dropdown: '0 12px 32px -8px rgba(16, 24, 40, 0.18), 0 4px 10px rgba(16, 24, 40, 0.06)',
primaryGlow: '0 4px 12px -2px rgba(192, 18, 39, 0.22)',
primaryGlowHover: '0 6px 16px -2px rgba(192, 18, 39, 0.30)',
header: '0 1px 0 rgba(16, 24, 40, 0.06)'
};
export default customShadows;

View File

@@ -1,28 +0,0 @@
// ==============================|| DOORMILE THEME - TYPOGRAPHY ||============================== //
// A deliberate type scale with optical tracking: large display text gets tighter
// negative letter-spacing, small UI text stays legible. Optical tracking like this
// is what separates a designed interface from a default Material build.
const typography = {
fontFamily: '"Public Sans", "Inter", "Helvetica", "Arial", sans-serif',
htmlFontSize: 16,
fontWeightLight: 300,
fontWeightRegular: 400,
fontWeightMedium: 500,
fontWeightBold: 700,
h1: { fontWeight: 800, fontSize: '2.375rem', lineHeight: 1.15, letterSpacing: '-0.022em' },
h2: { fontWeight: 800, fontSize: '1.875rem', lineHeight: 1.2, letterSpacing: '-0.02em' },
h3: { fontWeight: 700, fontSize: '1.5rem', lineHeight: 1.28, letterSpacing: '-0.018em' },
h4: { fontWeight: 700, fontSize: '1.25rem', lineHeight: 1.35, letterSpacing: '-0.014em' },
h5: { fontWeight: 700, fontSize: '1rem', lineHeight: 1.5, letterSpacing: '-0.01em' },
h6: { fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.57, letterSpacing: '-0.006em' },
caption: { fontWeight: 400, fontSize: '0.75rem', lineHeight: 1.66 },
body1: { fontSize: '0.875rem', lineHeight: 1.6, letterSpacing: '-0.003em' },
body2: { fontSize: '0.75rem', lineHeight: 1.66 },
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.57, letterSpacing: '-0.006em' },
subtitle2: { fontSize: '0.75rem', fontWeight: 600, lineHeight: 1.66 },
overline: { fontSize: '0.6875rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' },
button: { textTransform: 'none', fontWeight: 600, letterSpacing: '-0.006em' }
};
export default typography;