Migrate console off jupiter.nearle.app to the Doormile Express API

Retires REACT_APP_URL/URL2/URL3 in favor of REACT_APP_DOORMILE_URL across
every page (orders, deliveries, riders, tenants, pricing, profile, reports,
dispatch). Fixes several field-mapping and envelope-check bugs found along
the way, most notably that /admin/milers/:id routes (block, assign-vehicle,
edit, notify) key off milerprofileid, not userid, and that a miler's real
fields are phone/availabilitystatus/displayname, not contactno/status/
firstname+lastname (confirmed against a live read-only session).

Also fixes several silent-failure bugs uncovered during that audit: order
creation and order cancellation showed a success toast but gave no feedback
at all on failure (createorder1.js had a dead notifyadmin() call that left
the loading spinner stuck forever on every failed submit), and Tenants.js's
pricing/profile updates never surfaced a failed response to the operator.

The AI dispatch optimiser (routes.workolik.com/routemate.workolik.com) and
its jupiter.nearle.app delivery-commit call remain untouched by design —
separate solver service with no equivalent in the new API.
This commit is contained in:
2026-08-06 19:26:23 +05:30
parent 0a36212ac8
commit 59f31c8adf
39 changed files with 7006 additions and 9170 deletions

View File

@@ -5,6 +5,8 @@ metadata:
version: 2.0.0
---
> **⚠️ STALE as of the api.doormile.com migration.** Every `REACT_APP_URL` / `REACT_APP_URL2` endpoint below (jupiter.nearle.app) has been retired from the codebase. The console now calls `api.doormile.com` (`REACT_APP_DOORMILE_URL`) via `src/utils/doormileAxios.js` / `src/pages/api/doormileApi.js` — see `express-console-api.md` at the repo root for the current endpoint reference, and `src/pages/api/CLAUDE.md` for what the new backend has no equivalent for (zones/`applocationid`, live rider GPS logs, `/substitutions`, invoices, expense requests, several report breakdowns). The AI dispatch optimiser (routes.workolik.com / routemate.workolik.com) and the final-delivery-commit call are the one exception — they're unchanged, still hardcoded exactly as described below. The architecture/navigation/FCM sections below are still broadly accurate; only the API base and per-endpoint table are obsolete.
# NearlExpress Console — Project Reference
A React 18 operator console for the NearlExpress dispatch platform. Operators use it to manage orders, run the AI dispatch optimiser, watch a live map of riders, edit tenants/pricing/invoices, and pull BI reports. Production users are warehouse staff, not end customers.

10
.env
View File

@@ -4,15 +4,7 @@ GENERATE_SOURCEMAP = false
## Backend API URL
REACT_APP_API_URL=https://mock-data-api-nextjs.vercel.app/
REACT_APP_URL=https://jupiter.nearle.app/live/api/v1
REACT_APP_URL2=https://jupiter.nearle.app/live/api/v2
REACT_APP_URL3=https://jupiter.nearle.app/live/api/v3
REACT_APP_STAFF_TOKEN=
REACT_APP_RIDER_ACCESS_ID=AAAAILMpCEU:APA91bEavuOllBI6sFgYtxXAgNmAVwNA-MnCMHLGlR4_t7UqpLajAkdn3T0CZr_zaLBknLyim9ytFLMZgbeXmKqTad_PKCbqlYjHpaizVrLXtecxqyEy4UktIacK2UvHVUATHL-7VQQk
REACT_APP_DOORMILE_URL=https://api.doormile.com/api/v1
REACT_APP_RIDER_ACCESS_ID=AAAAILMpCEU:APA91bEavuOllBI6sFgYtxXAgNmAVwNA-MnCMHLGlR4_t7UqpLajAkdn3T0CZr_zaLBknLyim9ytFLMZgbeXmKqTad_PKCbqlYjHpaizVrLXtecxqyEy4UktIacK2UvHVUATHL-7VQQk

View File

@@ -1,7 +1 @@
REACT_APP_URL=https://jupiter.nearle.app/live/api/v1
REACT_APP_URL2=https://jupiter.nearle.app/live/api/v2
REACT_APP_URL3=https://jupiter.nearle.app/live/api/v3
REACT_APP_STAFF_TOKEN=
REACT_APP_RIDER_ACCESS_ID=AAAAILMpCEU:APA91bEavuOllBI6sFgYtxXAgNmAVwNA-MnCMHLGlR4_t7UqpLajAkdn3T0CZr_zaLBknLyim9ytFLMZgbeXmKqTad_PKCbqlYjHpaizVrLXtecxqyEy4UktIacK2UvHVUATHL-7VQQk
REACT_APP_DOORMILE_URL=https://api.doormile.com/api/v1

View File

@@ -1,3 +1 @@
REACT_APP_URL=https://jupiter.nearle.app/live/api/v1
REACT_APP_URL2=
REACT_APP_STAFF_TOKEN=
REACT_APP_DOORMILE_URL=https://api.doormile.com/api/v1

View File

@@ -1,5 +1,7 @@
# NearlExpress Console — API Endpoints Reference
> **⚠️ STALE — describes the retired jupiter.nearle.app backend.** `src/pages/api/api.js` no longer calls any of the endpoints below; it now talks to `api.doormile.com` (`REACT_APP_DOORMILE_URL`) via `src/pages/api/doormileApi.js`. See `express-console-api.md` at the repo root for the current endpoint reference. Kept for historical context only.
>
> Auto-generated reference of every API call in `src/pages/api/api.js`.
> Base URLs are resolved from the env files at the repo root.

View File

@@ -53,7 +53,7 @@ npm run lint
```
- **Env files at repo root**: `env.staging` is committed; `.env.development` / `.env.production` are typically gitignored. Pull from a teammate when missing.
- **Required env vars**: `REACT_APP_URL` (primary API base), `REACT_APP_URL2` (secondary API base — used for `/users/update`, `/tenants/update`, `/partners/getriderlogs`, archival `/orders/getorders`). No Maps API key is needed — maps and address search run on free Leaflet/OSM services. The optimiser URLs (`routes.workolik.com`, `routemate.workolik.com`) and the Jupiter auth URL (`jupiter.nearle.app`) are hardcoded — see the `nearlexpress-docs` skill.
- **Required env vars**: `REACT_APP_DOORMILE_URL` — the only API base (`https://api.doormile.com/api/v1`), used by every `/admin/*` call via `utils/doormileAxios.js`. The old jupiter-backed `REACT_APP_URL` / `REACT_APP_URL2` / `REACT_APP_URL3` have been fully retired — do not reintroduce them. No Maps API key is needed — maps and address search run on free Leaflet/OSM services. The optimiser URLs (`routes.workolik.com`, `routemate.workolik.com`) and the final-delivery-commit URL (`jupiter.nearle.app`) remain hardcoded — they're a separate solver service with no equivalent in the new API, deliberately left untouched by the backend migration. See the `nearlexpress-docs` skill and `express-console-api.md`.
- **Dev server runs on `http://localhost:3000`**. The user usually has it running already — assume it is up when reporting "reload to see it".
---
@@ -66,7 +66,7 @@ npm run lint
4. **Do not bypass the dispatch reconcile step.** After any manual edit on `/doormile/dispatch/preview` (rider swap, step reorder), the page **must** call `POST /optimization/reconcile-steps` before `POST /deliveries/createdeliveries`. Skipping this corrupts route sequences.
5. **Do not commit `.env*` files** beyond `env.staging` (which is the agreed-shared staging baseline).
6. **Do not introduce TypeScript files** (`.ts` / `.tsx`) into this repo. It is JavaScript; mixing creates lint and tooling friction.
7. **Do not use absolute `http://localhost` URLs** in code. Always read from `process.env.REACT_APP_URL` / `REACT_APP_URL2`.
7. **Do not use absolute `http://localhost` URLs** in code. Always read from `process.env.REACT_APP_DOORMILE_URL` (or use `doormileAxios`, which already defaults to it).
8. **Do not log `userid`, `authname`, FCM tokens, or PII** to `console.log` in production paths. The codebase has many leftover `console.log` calls — when editing nearby, remove them rather than add more.
9. **Do not add or remove items from the sidebar without updating `src/menu-items/nearle.js`** — the menu drives both display and i18n keys.
10. **Do not use destructive git** (`reset --hard`, `push --force`, branch deletion) without explicit user instruction.
@@ -248,8 +248,8 @@ These colour-code lifecycle states. Do **not** swap them for brand red — opera
## 8. State & auth
- **Auth state lives in `localStorage`**. The keys to know: `authname` (gate in `App.js`), `userid`, `roleid`, `userfcmtoken`, `applocations` (cached zone list). When adding auth-touching code, read these directly — there is no `useAuth()` hook.
- **The 401 redirect** comes from `src/utils/axios.js`. Most pages bypass that interceptor by importing raw `axios`. If you need guaranteed 401 handling for a new flow, import from `utils/axios` instead.
- **Auth state lives in `localStorage`**. The keys to know: `authname` (gate in `App.js` and `utils/session.js`'s `AUTH_PRESENCE_KEY` — unrelated to which backend authenticated the session, kept as the presence flag across the migration), `doormileToken` (the actual JWT sent as `Authorization: Bearer` on every `/admin/*` call — see `utils/doormileAxios.js`), `doormileUser`, `userid`, `roleid`, `tenantid`, `userfcmtoken`, `applocations` (cached zone list, now hub-derived). When adding auth-touching code, read these directly — there is no `useAuth()` hook.
- **The 401 redirect for the Doormile API** comes from `utils/doormileAxios.js` (auto-attaches the bearer token; on 401 does a full `localStorage.clear()` + hard navigate to `/login`, matching `utils/session.js`'s `performSessionLogout` contract). `src/utils/axios.js` is a separate, older, unrelated mock-service client — don't confuse the two.
- **Redux slices** live in `src/store/reducers/`. Use them only for cross-page state (FCM token, login user, sidebar menu open, global snackbar). Do **not** put per-page form state in Redux.
---
@@ -278,8 +278,8 @@ These colour-code lifecycle states. Do **not** swap them for brand red — opera
## 11. Common gotchas
- **`utc` plugin pollution**: `deliveries.js` extends dayjs with `utc` at module load. If you import dayjs elsewhere and call `.utc()` you'll get UTC behaviour even if you didn't ask. Match what the surrounding page does — `deliveries.js` deliberately bucket-parses in local time, not UTC, to stay in sync with the dispatch page.
- **Two API bases**: most calls hit `REACT_APP_URL`. A handful hit `REACT_APP_URL2` (`/users/update`, `/tenants/update`, archival orders, rider logs). Check `pages/api/api.js` before changing one.
- **The `applocationid` query param**: every list endpoint expects this — `0` means "All Zones". Pages default `appId = 0` and update it from `LocationAutocomplete`.
- **One API base**: `REACT_APP_DOORMILE_URL` (`api.doormile.com/api/v1`), via `utils/doormileAxios.js`. See `src/pages/api/CLAUDE.md` for the full migration-era rule sheet, including what the new backend has no equivalent for.
- **The `applocationid` "zone" concept no longer exists as a filterable resource** on the new API — it was a jupiter-only concept. `LocationAutocomplete`/`fetchAppLocations` now derive a picker list from `GET /admin/hubs` instead of a real zones endpoint; most list endpoints on the new backend don't accept an `applocationid` filter at all. Don't assume it's wired through end-to-end on a page you haven't checked.
- **`role` gating** uses `localStorage.getItem('roleid')`. Some buttons are conditionally rendered based on it. Do not hide UI based on string equality alone — check existing patterns.
- **Skeleton vs Loader vs LoaderWithImage** — these are different. Skeleton = per-row placeholder, Loader = full-screen backdrop blocking interaction, LoaderWithImage = inline branded spinner. Don't swap them.
- **No Maps API key exists in this project anymore.** Address search/geocoding goes through `AddressAutocomplete.js` (Nominatim) and routing through OSRM — both free, no key. Do not add `process.env.REACT_APP_GOOGLE_MAPS_API_KEY` or any Google Maps script/dependency back in.

View File

@@ -1,5 +1,7 @@
# NearlExpress Console — Repository Flow Graph
> **⚠️ STALE — describes the retired jupiter.nearle.app backend.** The console now talks exclusively to `api.doormile.com` via `REACT_APP_DOORMILE_URL` (see `src/utils/doormileAxios.js`, `src/pages/api/doormileApi.js`, and `express-console-api.md` at the repo root for the current endpoint reference). The `REACT_APP_URL` / `REACT_APP_URL2` env vars and every jupiter endpoint below no longer exist in the codebase — this file is kept for historical/architectural context only, not as an accurate current reference.
>
> Human-readable navigation, action, and data-flow diagrams for `nearlexpress-xpressconsole-d0ee01adebe9`.
> Open this file in a Mermaid-aware viewer (GitHub, VS Code with the *Markdown Preview Mermaid* extension, or [mermaid.live](https://mermaid.live)).
>

View File

@@ -50,10 +50,7 @@ REACT_APP_VERSION=v2.1.0
GENERATE_SOURCEMAP=false
# Backend Services Endpoint URLs
REACT_APP_API_URL=https://mock-data-api-nextjs.vercel.app/
REACT_APP_URL=https://jupiter.nearle.app/live/api/v1
REACT_APP_URL2=https://jupiter.nearle.app/live/api/v2
REACT_APP_URL3=https://jupiter.nearle.app/live/api/v3
REACT_APP_DOORMILE_URL=https://api.doormile.com/api/v1
```
No Maps API key is required — maps and address search run on free Leaflet/OpenStreetMap (Nominatim + OSRM), not Google Maps.

View File

@@ -1,3 +1 @@
REACT_APP_URL='https://jupiter.nearle.app/live/api/v1'
REACT_APP_URL2=''
REACT_APP_STAFF_TOKEN=''
REACT_APP_DOORMILE_URL='https://api.doormile.com/api/v1'

292
express-console-api.md Normal file
View File

@@ -0,0 +1,292 @@
# Doormile Express Console — API reference
The console surface only (`/admin/*`). 89 routes: 1 login + 88 authenticated.
Everything is under `https://api.doormile.com/api/v1`.
Miler-app, hub-console, customer-app and CRM routes are not in this document.
---
## Auth
```
POST /api/v1/admin/login
{ "email": "developer@doormile.com", "password": "admin@123" }
```
Returns `{ success, token, user: { id, name, email, role, tenantid } }`.
Send it on every other call as `Authorization: Bearer <token>`.
The token is a JWT carrying `userid`, `email`, `roleid`, `tenantid`, `configid`.
Roles allowed on this group: **1 admin, 3 manager, 4 executive**. Anything else
gets 401.
### Tenant scoping — read this before wiring any list screen
`tenantid` in the token decides what the account can see:
| Token `tenantid` | Who | Sees |
|---|---|---|
| `0` / null | Doormile's own staff | everything, all tenants |
| set (e.g. `13`) | a client's console login | only that tenant's rows |
Scoping is applied server-side. A client login **cannot** widen it by sending
`?tenantid=` or a body `tenantid` — on writes the server overwrites the field
with the token's tenant. Build the UI as if the API returns exactly what the
account is allowed to see, because it does.
Note the group is registered under a stale `// all open, no token required`
comment in `routes.go`; the comment is wrong, `AuthMiddleware` is applied.
---
## Dashboard, profile, reports
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/dashboard` | counts + today's numbers |
| GET | `/admin/reports` | `?from=YYYY-MM-DD&to=YYYY-MM-DD`, defaults to today (IST) |
| GET | `/admin/profile` | current account |
| GET | `/admin/me` | alias of the above |
| PUT | `/admin/profile/password` | `{ "current_password": "...", "new_password": "..." }` — snake_case |
## App users (staff logins)
| Method | Path |
|---|---|
| GET | `/admin/users` |
| POST | `/admin/users` |
| PUT | `/admin/users/:id` |
| DELETE | `/admin/users/:id` |
## Partners (fleet / rider suppliers)
Not the same thing as a tenant. A partner supplies vehicles and riders; a tenant
is a client Doormile delivers for.
| Method | Path | Body |
|---|---|---|
| GET | `/admin/partners` | |
| POST | `/admin/partners` | `{ partnername, partnertypeid, contactno, status }` |
| GET | `/admin/partners/:id` | |
| PUT | `/admin/partners/:id` | |
| DELETE | `/admin/partners/:id` | hard delete — no soft-delete column |
## Tenants (client companies)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/tenants` | |
| POST | `/admin/tenants` | `{ tenantname, primaryemail, primarycontact, status, requiredeliveryotp }` |
| GET | `/admin/tenants/:id` | |
| PUT | `/admin/tenants/:id` | `requiredeliveryotp` is a pointer — omit it to leave the setting alone |
| DELETE | `/admin/tenants/:id` | hard delete |
| GET | `/admin/tenants/:id/locations` | the client's sites (kitchens, branches, depots) |
| POST | `/admin/tenants/:id/locations` | `{ locationname, address, city, state, pincode, latitude, longitude, isprimary, status }` |
| PUT | `/admin/tenantlocations/:id` | note: **not** nested under the tenant |
`requiredeliveryotp` is opt-in per tenant and **off by default**. DailyGrubs runs
without delivery OTP by decision.
## Tenant customers (a client's own end customers)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/tenantcustomers` | |
| POST | `/admin/tenantcustomers` | `{ firstname, lastname, phone, email }` |
| GET | `/admin/tenantcustomers/:id` | |
| PUT | `/admin/tenantcustomers/:id` | |
| DELETE | `/admin/tenantcustomers/:id` | |
## B2C app customers
| Method | Path |
|---|---|
| GET | `/admin/customers` |
| PATCH | `/admin/customers/:id` |
Tenant-scoped through their bookings — a client login sees only customers who
have ordered through them.
## Hubs
| Method | Path | Body |
|---|---|---|
| GET | `/admin/hubs` | |
| POST | `/admin/hubs` | `{ hubname, hubtype, applocationid, contactno, address, latitude, longitude, pincode, status }` |
| GET | `/admin/hubs/:id` | |
| PUT | `/admin/hubs/:id` | |
| DELETE | `/admin/hubs/:id` | soft delete |
`hubtype`: `sorting_center` \| `delivery_hub`. `applocationid` is the city —
Nagercoil is 5; read the rest from `GET /admin/hubs` rather than hardcoding.
## Vehicles
| Method | Path | Body |
|---|---|---|
| GET | `/admin/vehicles` | |
| POST | `/admin/vehicles` | `{ vehicleno, vehicletype, maxweight, maxvolume, partnerid, batterypercentage, status }` |
| GET | `/admin/vehicles/:id` | |
| PUT | `/admin/vehicles/:id` | |
| DELETE | `/admin/vehicles/:id` | soft delete |
## Milers (riders)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/milers` | tenant-scoped |
| POST | `/admin/milers` | see below |
| GET | `/admin/milers/:id` | |
| PUT | `/admin/milers/:id` | |
| PUT | `/admin/milers/:id/block` | |
| PUT | `/admin/milers/:id/assign-vehicle` | |
| POST | `/admin/milers/:id/notify` | `{ title, message }``:id` is the **milerprofileid** |
```jsonc
POST /admin/milers
{
"authname": "Murali",
"email": "murali@dailygrubs.com",
"contactno": "9876543210",
"password": "1234",
"displayname": "Murali S",
"tenantid": 13,
"defaultvehicletype": "Bike",
"applocationid": 1,
"hubid": 4 // optional; without it the rider is invisible to the hub console
// configid defaults to 1001 — the partition the miler app logs in against.
// Do not override it. Riders created without it could never log in.
}
```
## Bookings
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/bookings` | tenant-scoped list |
| POST | `/admin/expressbooking` | create one — passes CityGate |
| POST | `/admin/expressbooking/bulk` | `{ "bookings": [ ... ] }`, max 200, per-row results |
| GET | `/admin/bookings/:id` | 403 if outside your tenant |
| POST | `/admin/bookings/:id/assign-miler` | |
| POST | `/admin/bookings/:id/assign-vehicle` | |
| PUT | `/admin/bookings/:id/status` | |
| POST | `/admin/bookings/:id/cancel` | |
| POST | `/admin/bookings/bulk-cancel` | |
```jsonc
POST /admin/expressbooking
{
"tenantid": 13, // required; forced to your own tenant on client logins
"pickuplocationid": 15, // a stored kitchen/branch — fills address, pincode and
// coords for you, and is what makes per-site reporting work
"customer_phone": "9876543210", // creates a Guest customer if unknown
"customer_name": "Ramesh",
"deliveryaddress": "12 Cross Cut Road, Gandhipuram",
"deliverypincode": "641012",
"deliverycity": "Coimbatore",
"deliverylatitude": 11.0168,
"deliverylongitude": 76.9558,
"service_option": "Fast", // Normal | Fast | Superfast
"finalprice": 120, // the order amount the tenant pays — passed through
"notes": "Ring the bell",
"parcels": [
{ "itemcategory": "Food", "itemdescription": "2 meal boxes", "declaredvalue": 350 }
]
}
```
Rules worth knowing:
- `parcels` must be non-empty and `tenantid` must exist.
- Pickup address + pincode are required **unless** `pickuplocationid` supplies them.
- A `pickuplocationid` belonging to another tenant is rejected.
- **CityGate**: the pickup pincode prefix must be an open city — `641`
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
Any other prefix is refused at the middleware, before the handler runs.
- Matching 3-digit pickup and delivery prefixes = hyperlocal, and the parcel goes
straight to `Out_for_Delivery` at pickup instead of routing via a hub.
- Auto-assignment fires after commit as a background retry loop (5 attempts,
2 min apart). The response returns before a rider is attached.
## Consignments
| Method | Path |
|---|---|
| GET | `/admin/consignments` |
| GET | `/admin/consignments/:id` |
| GET | `/admin/consignments/track/:trackingno` |
| PUT | `/admin/consignments/:id/status` |
## Tripsheets (hub-to-hub transport)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/tripsheets` | |
| POST | `/admin/tripsheets` | `{ sourcehubid, destinationhubid, vehicleid, driveruserid }` |
| GET | `/admin/tripsheets/:id` | |
| POST | `/admin/tripsheets/:id/items` | `{ consignmentid }` |
| DELETE | `/admin/tripsheets/:id/items/:itemid` | |
| PUT | `/admin/tripsheets/:id/dispatch` | |
| PUT | `/admin/tripsheets/:id/arrive` | |
## Pricing
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/pricing` | tenant pricing rules |
| POST | `/admin/pricing` | `{ tenantid, applocationid, vehicletype, baseprice, baseweight, priceperkg, basedistance, priceperkm, handlingcharges, effectivefrom, effectiveto, currency, priority, status }` |
| PUT | `/admin/pricing/:id` | |
| DELETE | `/admin/pricing/:id` | |
| POST | `/admin/pricing/simulate` | quote without creating anything |
| POST | `/admin/pricing/quote` | same handler as simulate |
| GET | `/admin/doormile-pricing` | Doormile's own bands |
| POST | `/admin/doormile-pricing` | |
| PUT | `/admin/doormile-pricing/:id` | |
| DELETE | `/admin/doormile-pricing/:id` | soft delete |
## Exceptions
| Method | Path | Body |
|---|---|---|
| GET | `/admin/exceptions` | |
| POST | `/admin/exceptions` | `{ consignmentid, tripsheetid, hubid, exceptiontype, severity, description }` |
| GET | `/admin/exceptions/:id` | |
| PUT | `/admin/exceptions/:id/status` | `{ resolution, status }``Resolved` \| `Closed` |
`exceptiontype`: `Lost`, `Damaged`, `Misrouted`, `Receiver_Refused`,
`Missing_Contents`, `Undeliverable`. `severity`: `Low`, `Medium`, `High`,
`Critical`.
## Competitive intel
| Method | Path |
|---|---|
| GET/POST | `/admin/competitor-branches` |
| PUT/DELETE | `/admin/competitor-branches/:id` |
| GET/POST | `/admin/carrier-pricing` |
| PUT/DELETE | `/admin/carrier-pricing/:id` |
---
## Conventions across every endpoint
- **Envelope**: `{ "success": true, "data": ... }` on success,
`{ "success": false, "message": "..." }` on failure. Lists add `total`,
paginated lists add `page`.
- **Pagination**: `?pageno=1&pagesize=100`. Default 500, cap 1000.
- **Rate limits**: 300/min per IP globally, 10/min shared across all credential
endpoints. Behind the ingress this keys on the proxy IP unless
`TRUSTED_PROXIES` is set.
- **Timestamps** are IST (`Asia/Kolkata`) wall-clock in `timestamp without time
zone` columns. Send dates as `YYYY-MM-DD`, not epochs.
- **Soft delete** exists on Hub, Vehicle, Consignment, Tripsheet, TripsheetItem,
ConsignmentException, DoormilePricing, CarrierPricing. Partner and Tenant are
hard-deleted.
## Not exercised yet
Roughly half these routes have never had a real request against them. Exercised
end-to-end so far: login, dashboard, reports, tenants + locations, milers
(create/list/notify), expressbooking (single), bookings list/detail,
assign-miler, consignments, profile password. Treat the rest as written but
unproven.

254
jupiter2doormile.md Normal file
View File

@@ -0,0 +1,254 @@
# jupiter → Doormile
What the old Nearle/jupiter API did, and what replaces it in Doormile. Two
surfaces only — the **express console** and the **miler app**. Hub console, CRM
and the B2C customer app are out of scope here.
Base URLs:
| | jupiter | Doormile |
|---|---|---|
| API | `jupiter.nearle.app/live/api/v1` | `api.doormile.com/api/v1` |
| Write path | `queue.workolik.com` (TLS verify off, hardcoded IP pin) | same host, no side channel |
**Confidence marking.** Paths marked ✅ were read off real network logs from the
live jupiter console. Paths marked ~ come from the prior-session analysis of the
jupiter codebase and have not been re-confirmed against a live request — check
the exact spelling before wiring anything to them.
Status: **Done** = built and hit with a real request · **Built** = written and
compiled, never called · **Gap** = nothing replaces it yet · **Dropped** =
deliberately not migrated.
---
## 1. Auth
| jupiter | Doormile | Status |
|---|---|---|
| ~ console login (undocumented in jupiter's own API docs — found only by reading the console source) | `POST /admin/login``{email, password}` | **Done** |
| ~ rider login | `POST /miler/login` then `POST /miler/verify-pin` | **Done** |
Two real differences:
- Doormile splits rider login into **phone → PIN**, two calls. jupiter did it in
one.
- The Doormile console token carries **`tenantid`**. jupiter had no tenant
concept on the login at all; every console user saw everything. This is the
single biggest behavioural change for a client account.
- `configid` must be **1001** on both miler calls. There is no jupiter
equivalent — it's a Doormile login partition.
---
## 2. Express console
### 2.1 Rider screens
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /deliveries/getridersummary/?applocationid=&fromdate=&todate=` | `GET /admin/milers/summary?applocationid=&from=&to=&tenantid=&hubid=` | **Done** |
| ~ rider list | `GET /admin/milers?applocationid=&hubid=&tenantid=` | **Done** |
| ~ rider detail | `GET /admin/milers/:id` | **Done** |
| ~ `riderlogs` (the 1.17M-row, zero-index table) | `GET /admin/milers/:id/logs?from=&to=&limit=` | **Done** |
| ✅ `getriderlocationsummary` *(name confirmed, path inferred)* | covered by `milers/summary` (`currentlatitude/longitude`, `lastpingat`) and `milers/:id/logs` | **Done** |
| — *(no jupiter equivalent)* | `GET /admin/milers/:id/activity?from=&to=` | **Done** |
| ~ rider create/edit | `POST /admin/milers`, `PUT /admin/milers/:id` | **Done** |
| ~ block rider | `PUT /admin/milers/:id/block` | **Built** |
| ~ assign vehicle | `PUT /admin/milers/:id/assign-vehicle` | **Built** |
| — | `POST /admin/milers/:id/notify` | **Done** |
Parameter translation: jupiter used `fromdate`/`todate`, Doormile uses
`from`/`to`. Both `YYYY-MM-DD`. jupiter's `applocationid=0` meant "all cities";
Doormile means the same by **omitting** the param.
### 2.2 Orders / deliveries
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /deliveries/getdeliveries/` | `GET /admin/bookings` + `GET /admin/consignments` | **Done** |
| ~ `getdelivery` / `getorders` | `GET /admin/bookings/:id`, `GET /admin/consignments/:id` | **Done** |
| ~ `POST /deliveries/createdeliveries` | `POST /admin/expressbooking` | **Done** |
| ~ `createdeliveries` in bulk | `POST /admin/expressbooking/bulk` (max 200, per-row results) | **Built** |
| ~ `PUT /deliveries/updatedelivery` | **split into 11 endpoints** — see §4 | **Done / partial** |
| — | `GET /admin/bookings/:id/track` | **Done** |
| — | `GET /admin/consignments/:id/logs` | **Done** |
| — | `GET /admin/consignments/track/:trackingno` | **Built** |
Two jupiter bugs that do not carry over, by construction:
- `getdeliveries` returned **every row 21×** (unconstrained `LEFT JOIN
tenantpricing`, `DISTINCT` over 87 columns that deduped nothing). Doormile's
list endpoints are paginated (`pageno`/`pagesize`, default 500, cap 1000) and
return one row per booking.
- `createdeliveries` had a quadratic insert bug — a slice declared outside the
loop kept accumulating, producing ~2× duplicate `deliveryqueues` rows
(66,446 deliveries → 132,826 rows, confirmed live). `createExpressBooking` is
a single transaction per booking; `/bulk` loops it and reports per-row.
### 2.3 Reporting
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /deliveries/getreportsummary/?applocationid=&tenantid=&locationid=&fromdate=&todate=` | `GET /admin/reports?from=&to=&tenantid=&locationid=&hubid=` | **Done** |
| ~ `getlocationsummary` | `GET /admin/locations/summary?tenantid=&locationid=&from=&to=` | **Done** |
| — | `GET /admin/dashboard?tenantid=` | **Done** |
`locationid` is supported: it narrows every figure to one client site, and
`/admin/reports` now carries a `by_location` block alongside `by_hub`,
`by_tenant` and `by_rider`.
**Attribution caveat.** Per-site figures group by `tenantlocationid` on the
booking — a column added 2026-08-06. The pre-existing `pickuplocationid` column
is *not* it: that one foreign-keys to `appcustomerlocations`, the B2C customer's
saved address, so writing a client-site id into it fails the insert. Every
booking created before 2026-08-06 has no site at all.
Since the console sends a kitchen's *address* rather than its id,
`createExpressBooking` resolves the site itself — nearest stored location within
150m, falling back to an address match. Bookings with no site are reported as
their own `"Unattributed"` row rather than dropped, so per-site rows still add
up to the summary total. Sending `tenantlocationid` explicitly is exact and
always wins.
**`applocationid` (city) is still not a report parameter.** jupiter had it;
Doormile filters by `hubid` instead. Only matters once one client runs in more
than one city.
### 2.4 Tenants and their sites
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /tenants/gettenants/` | `GET /admin/tenants` | **Done** |
| ✅ `GET /tenants/gettenantlocations/` | `GET /admin/tenants/:id/locations` | **Done** |
| ~ `getlocations` / `getlocation` / `getlocationdetails` | same as above | **Done** |
| ~ tenant create/edit | `POST /admin/tenants`, `PUT /admin/tenants/:id` | **Done** |
| ~ location create/edit | `POST /admin/tenants/:id/locations`, `PUT /admin/tenantlocations/:id` | **Done** |
| ~ `getbranches` | `GET /admin/hubs` — *jupiter "branches" ≈ Doormile hubs; verify this is the same concept before relying on it* | **Built** |
| ~ `getlocationsummary` | `GET /admin/locations/summary` — see §2.3 | **Done** |
Doormile adds `locationname` on a tenant location. jupiter identified a site by
its address alone, which does not distinguish two branches on one street.
---
## 3. Miler app
jupiter's rider app drove almost everything through one overloaded endpoint.
Doormile gives each action its own route.
| jupiter action | Doormile | Status |
|---|---|---|
| ~ rider login | `POST /miler/login` + `POST /miler/verify-pin` | **Done** |
| ~ PIN reset | `POST /miler/reset-pin` — **now admin-only**, see §5 | **Done** |
| ~ location ping | `PUT /miler/location` | **Done** |
| ~ availability toggle | `PUT /miler/availability` | **Done** |
| ~ assignment list | `GET /miler/assignments`, `GET /miler/assignments/:id` | **Done** |
| ~ accept | `POST /miler/assignments/:id/accept` | **Done** |
| ~ reject | `POST /miler/assignments/:id/reject` | **Built** |
| ~ rider logs write | `POST /miler/logs`, `POST /miler/status` | **Done** |
| ~ per-delivery logs | `POST /miler/consignments/logs` | **Done** |
| — | `POST /miler/duty/start`, `PUT /miler/duty/end`, `GET /miler/duty/current` | **Done** |
| — | `POST /miler/breaks/start`, `PUT /miler/breaks/end` | **Done** |
| — | `GET /miler/earnings` | **Done** |
| — | `POST /miler/support`, `GET /miler/support` | **Built** |
| — | `GET /miler/notifications` | **Done** |
| — | `PATCH /miler/notifications/:id/read` | **Gap** — stub, persists nothing |
---
## 4. `PUT /deliveries/updatedelivery` — the 11-way split
This is the centre of the migration. jupiter overloaded one endpoint for **11
distinct real actions**, distinguished only by which JSON fields happened to be
non-empty. Each is now its own route with its own validation and its own status
transition.
**Eight rider actions:**
| Action | Doormile |
|---|---|
| reached pickup | `POST /miler/bookings/:bookingid/reached` |
| confirm parcel + dimensions | `POST /miler/bookings/:bookingid/parcel` |
| collect payment | `POST /miler/bookings/:bookingid/payment` |
| pickup complete | `POST /miler/bookings/:bookingid/pickup-complete` |
| needs a bigger vehicle | `POST /miler/bookings/:bookingid/vehicle-required` |
| cancel before pickup | `POST /miler/bookings/:bookingid/cancel` |
| deliver | `POST /miler/consignments/:id/deliver` |
| skip / failed attempt | `POST /miler/consignments/:id/skip` |
**Three console actions** that were bundled into the same rider endpoint:
| Action | Doormile |
|---|---|
| assign a rider | `POST /admin/bookings/:id/assign-miler` |
| change status | `PUT /admin/bookings/:id/status` · `PUT /admin/consignments/:id/status` |
| cancel | `POST /admin/bookings/:id/cancel` · `POST /admin/bookings/bulk-cancel` |
`pickup-complete` is the pivot the old system had no concept of: it converts the
booking into a **consignment**, recomputes chargeable weight from the dimensions
the rider entered, and decides routing — matching 3-digit pincode prefixes go
straight to `Out_for_Delivery` (hyperlocal), everything else routes via a hub.
---
## 5. Behaviour changes that break a naive repoint
Response shapes are completely different — flat 87- and 92-column jupiter rows
versus nested Doormile JSON. Every screen that parses a response needs
rewriting, not repointing. Beyond that:
1. **Tenant scoping is real now.** A client console login sees only its own
tenant. `?tenantid=` narrows for Doormile staff; a client passing another
tenant's id gets **403**. Cross-tenant reads of a single resource return
**404**, not 403, so ids aren't probeable. jupiter had none of this.
2. **`configid` 1001** on every miler auth call. No jupiter equivalent.
3. **PIN reset is admin-only.** jupiter let anyone reset a rider PIN with just a
phone number, which is the login identifier, not a secret. Two calls took over
any account. The rider app must not call `/miler/reset-pin` — route resets
through ops.
4. **Identity comes from the token, never the body.** jupiter's telemetry
endpoints took `userid` from the request body. Doormile ignores it.
5. **Telemetry lat/long/speed/battery are strings**, and
`POST /miler/consignments/logs` takes a **bare JSON array**.
6. **Delivery OTP is opt-in per tenant** (`Tenant.Requiredeliveryotp`), default
off. Off for DailyGrubs. When on, it's verified server-side.
7. **Dates**: `from`/`to`, not `fromdate`/`todate`. IST wall-clock throughout.
8. **CityGate**: a booking's pickup pincode prefix must be an open city — `641`
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
jupiter had no such gate.
---
## 6. Gaps — jupiter did this, Doormile does not yet
| What | Detail |
|---|---|
| `applocationid` on reports | jupiter could filter a report by city. Doormile filters by `hubid`. Only bites when one client operates in several cities. |
| **Route optimisation** | jupiter used external paid services (`routes.workolik.com`) for multi-stop sequencing. Nothing in Doormile replaces true stop-ordering. `HubBatchAssign` decides *who* gets a booking, not *what order* to run stops in. |
| Notifications read-state | `PATCH /miler/notifications/:id/read` is a stub; no table exists. |
| `riderkms` / `ridercharges` backfill | Populated on new deliveries only. Rows completed before 2026-08-06 read 0 and will not backfill themselves. |
---
## 7. Dropped on purpose
| What | Why |
|---|---|
| `/v1/substitutions` CRUD | Rider substitutions. Low traffic in the old system; Suriya's call. Revisit if it turns out to matter. |
| jupiter's v2 endpoints | They wrote **only to Redis**, invisible to the v1/v3 Postgres reads — genuine split-brain, with a Redis `INCR` id space that could collide with the Postgres sequence. Doormile keeps Redis for ephemeral telemetry only; durable state is always Postgres. |
| `queue.workolik.com` write path | Separate host with TLS verification disabled and a hardcoded IP pin. Not reproduced. |
| ~20 never-populated columns on `orders`, 6 lat/lng pairs for 3 real points on `deliveries`, status spread across 6 text+timestamp column pairs | Replaced by a normalised schema with a real event-log table (`consignmenthistory`). |
---
## 8. What is not migrated at all
Nothing on the client side has moved. The rider Flutter app and
`doormile_express_console` still call `jupiter.nearle.app`. Doormile having the
endpoint does not mean traffic uses it.
Suggested order: pick one net-new console screen (the rider summary, or
reports) and wire it to Doormile first — it replaces nothing live, so it is the
cheapest real proof the cutover works. Then the higher-traffic screens
(deliveries list, rider status updates), then the rider app.

View File

@@ -1,8 +1,8 @@
import React, { forwardRef, useEffect, useState } from 'react';
import { Autocomplete, TextField, Avatar, Stack } from '@mui/material';
import axios from 'axios';
import { MdMyLocation } from 'react-icons/md';
import { fetchAppLocations } from 'pages/api/api';
// Pill variant — opt-in via `pill` prop. Mirrors the design used across the
// deliveries / dispatch filter rows (rounded pill, soft tinted bg, accent
@@ -29,16 +29,13 @@ const LocationAutocomplete = forwardRef(
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem('applocations') || '[]'));
useEffect(() => {
// Zones are derived from GET /admin/hubs (the new API has no dedicated
// zones resource) — see fetchAppLocations in pages/api/api.js.
const fetchLocations = async () => {
try {
const userid = localStorage.getItem('userid');
if (!userid) return;
const response = await axios.get(`${process.env.REACT_APP_URL}/partners/getlocations/?userid=${userid}`);
if (response.data.status) {
const updatedLocations = [...response.data.details, { locationname: 'All', applocationid: 0 }];
const updatedLocations = await fetchAppLocations();
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
setLocations(updatedLocations);
}
} catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err);
}

View File

@@ -24,7 +24,14 @@ import {
UserOutlined,
ProfileOutlined,
TeamOutlined,
MoneyCollectOutlined
MoneyCollectOutlined,
ContactsOutlined,
ShopOutlined,
CarryOutOutlined,
IdcardOutlined,
TruckOutlined,
WarningOutlined,
RadarChartOutlined
} from '@ant-design/icons';
// icons
@@ -50,7 +57,14 @@ const icons = {
UserOutlined,
ProfileOutlined,
TeamOutlined,
MoneyCollectOutlined
MoneyCollectOutlined,
ContactsOutlined,
ShopOutlined,
CarryOutOutlined,
IdcardOutlined,
TruckOutlined,
WarningOutlined,
RadarChartOutlined
};
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
@@ -103,6 +117,63 @@ const nearle = {
url: '/doormile/pricing',
icon: icons.MoneyCollectOutlined
},
{
id: 'customers',
title: <FormattedMessage id="customers" />,
type: 'item',
url: '/doormile/customers',
icon: icons.ContactsOutlined
},
{
id: 'fleetops',
title: <FormattedMessage id="fleetops" />,
type: 'collapse',
icon: icons.TruckOutlined,
children: [
{
id: 'hubs',
title: <FormattedMessage id="hubs" />,
type: 'item',
url: '/doormile/hubs',
icon: icons.ShopOutlined
},
{
id: 'vehicles',
title: <FormattedMessage id="vehicles" />,
type: 'item',
url: '/doormile/vehicles',
icon: icons.CarOutlined
},
{
id: 'tripsheets',
title: <FormattedMessage id="tripsheets" />,
type: 'item',
url: '/doormile/tripsheets',
icon: icons.CarryOutOutlined
},
{
id: 'exceptions',
title: <FormattedMessage id="exceptions" />,
type: 'item',
url: '/doormile/exceptions',
icon: icons.WarningOutlined
},
{
id: 'competitiveintel',
title: <FormattedMessage id="competitiveintel" />,
type: 'item',
url: '/doormile/competitive-intel',
icon: icons.RadarChartOutlined
},
{
id: 'appusers',
title: <FormattedMessage id="appusers" />,
type: 'item',
url: '/doormile/app-users',
icon: icons.IdcardOutlined
}
]
},
{
id: 'reports',
title: <FormattedMessage id="reports" />,

View File

@@ -1,31 +1,33 @@
# CLAUDE.md — `src/pages/api/`
Rules for editing `api.js`. This is the **central API layer** every page calls into it. The root `CLAUDE.md` covers project-wide conventions; this file is the scoped rule sheet for the API layer specifically.
Rules for editing `api.js` and `doormileApi.js` the **central API layer**, every page calls into one of these two. The root `CLAUDE.md` covers project-wide conventions; this file is the scoped rule sheet for the API layer specifically.
**The old jupiter.nearle.app backend (`REACT_APP_URL` / `REACT_APP_URL2` / `REACT_APP_URL3`) has been fully retired from this project.** Every server call now goes through `utils/doormileAxios.js``api.doormile.com/api/v1`, either directly from `doormileApi.js` or via the thin wrappers in `api.js`. Do not reintroduce `process.env.REACT_APP_URL*` or raw `axios` calls to `jupiter.nearle.app` — the one sanctioned exception is the AI dispatch optimiser (`routes.workolik.com` / `routemate.workolik.com`) and its final delivery commit, which are a separate solver service with no equivalent in the new API and are explicitly left untouched (see `createOptimisationDeliveries`, `reconcileSteps`, `fetchBatchEfficiency`, `createAutomationDeliveries`, `finalCreatedeliveries` in `api.js`).
---
## 1. Function signature patterns
## 1. Two files, two roles
- **`doormileApi.js`** — one named export per `api.doormile.com/admin/*` endpoint (see `express-console-api.md` at the repo root for the full reference). Thin: build the URL, call `doormileAxios`, unwrap the envelope, return.
- **`api.js`** — the page-facing layer. Existing function names/signatures were kept stable during the migration (so page files didn't all need parallel edits) and now delegate to `doormileApi.js` internally. New pages should import directly from `doormileApi.js` unless matching an existing `queryKey`-shaped call site in `api.js`.
## 2. Function signature patterns
### TanStack `useQuery` / `useInfiniteQuery` consumers
Destructure from `queryKey` in the order the call site declared it. The leading `_` is the query name and is intentionally discarded.
```js
// Plain query — destructure { queryKey }, skip the [0] name slot
export const fetchorderscount = async ({ queryKey }) => {
const [, appId, startdate, enddate, currentStatus, tenantid, locationid] = queryKey;
const url = `${process.env.REACT_APP_URL}/orders/getordersummary/?applocationid=${appId}&...`;
const response = await axios.get(url);
return response.data.details;
const [, , startdate, enddate] = queryKey;
return (await getReports(startdate, enddate)) || {};
};
// Infinite query — also receives pageParam (default to 1)
export const fetchOrders = async ({ pageParam = 1, queryKey }) => {
const [, appId, currentStatus, debouncedSearch, startdate, enddate, rowsPerPage, tenantid, locationid] = queryKey;
const url = `${process.env.REACT_APP_URL}/orders/tenant/getorders/?applocationid=${appId}&...&pageno=${pageParam}&pagesize=${rowsPerPage}`;
const response = await axios.get(url);
const [, , , , , , rowsPerPage] = queryKey;
const rows = (await getBookings(pageParam, rowsPerPage)) || [];
return {
rows: response.data.details,
nextPage: response.data.details.length === Number(rowsPerPage) ? pageParam + 1 : undefined
rows,
nextPage: rows.length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};
```
@@ -33,81 +35,67 @@ export const fetchOrders = async ({ pageParam = 1, queryKey }) => {
**Hard rules:**
- The query-key array order at the call site MUST match the destructure order here. Re-ordering one without the other silently breaks every caller.
- Infinite queries return `{ rows, nextPage }`. `nextPage` is `undefined` when the page size wasn't filled (signals end-of-stream to `getNextPageParam`).
- Some legacy functions return `{ data, nextPage }` instead of `{ rows, nextPage }` (e.g. `getallcustomers`). Match the existing shape rather than "fixing" it — call sites depend on the field name.
- Many `queryKey` positions that used to carry `applocationid`/`tenantid`/`locationid`/`status` filters are now unused padding — the new API doesn't accept those as query params on most resources (see §3). Don't delete the positions; call sites still destructure by index.
---
## 2. Direct positional-argument calls
A few functions take plain positional arguments instead of `queryKey` — usually when they're invoked from a `useMutation` or imperatively. Example: `getTenants(appId)`, `gettenantlocations(tenantid)`.
## 3. Base client and what the new API can't do
Pick the signature based on how the function is called:
- Called via `useQuery({ queryFn: fn })` → destructure `{ queryKey }`.
- Called via `useQuery({ queryFn: () => fn(arg) })` → take positional args.
- Called from a mutation or imperatively → take positional args.
---
## 3. Base URL selection
| Use base | When |
| Use | When |
|---|---|
| `process.env.REACT_APP_URL` | Default for ~95% of endpoints |
| `process.env.REACT_APP_URL2` | `/users/update`, `/tenants/update`, `/tenants/update/services`, archival `/orders/getorders`, `/partners/getriderlogs` |
| Hardcoded `https://routes.workolik.com` | Bike solver + reconcile-steps + batch efficiency |
| Hardcoded `https://routemate.workolik.com` | Auto / multi-trip solver |
| Hardcoded `https://jupiter.nearle.app` | Login + final `/deliveries/createdeliveries` commit |
| `doormileAxios` (via `doormileApi.js` functions) | Every `/admin/*` endpoint. Bearer token attached automatically from `localStorage.doormileToken`. |
| Hardcoded `https://routes.workolik.com` | Bike solver + reconcile-steps + batch efficiency — untouched, separate service. |
| Hardcoded `https://routemate.workolik.com` | Auto / multi-trip solver — untouched, separate service. |
| Hardcoded `https://jupiter.nearle.app` | Final `/deliveries/createdeliveries` commit only — untouched, part of the same solver pipeline. |
When adding a new endpoint, check whether the backend actually serves it on URL or URL2 — don't guess. URL2 lives on a separate service.
The new API has **no equivalent** for a number of things the old jupiter backend served. Functions covering these return safe empty defaults (`null`/`[]`/`{}`) rather than throwing — match this pattern for anything new that hits the same gap:
- Zones/`applocationid` as a filterable resource — derive a picker list from `GET /admin/hubs` instead (`fetchAppLocations` in `api.js`).
- Live rider GPS/battery/periodic logs, per-delivery GPS trail/polyline logs.
- Payment-mode types, item subcategories, rider shifts, vehicle/account types.
- Tenant tab-count summaries, per-rider/per-location report breakdowns, rider login/checkout audit logs.
- The `/substitutions` rider-absence feature (riders.js) — no backend at all.
- Invoices and expense-request approvals — no backend at all (the `requests.js` page/route is disabled, not deleted).
Before assuming a new page's data need is covered, check `express-console-api.md` — roughly half its routes are documented as "written but unproven" against the real backend, so field names on GET responses are a best-effort guess in several places (flagged inline with comments where that's true).
---
## 4. Error handling pattern
```js
export const someFetch = async () => {
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/...`);
return response.data.details; // or .summary, .data, etc — depends on backend shape
return (await someDoormileApiFn()) || [];
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return null; // or return [] / {} — match what the caller expects
return []; // or null / {} — match what the caller expects
}
};
```
- Toast on failure via `OpenToast` from `components/third-party/OpenToast`. Don't `throw` — TanStack Query's `onError` is rarely wired by callers.
- Return a sensible empty default (`null`, `[]`, `{}`) so the call site's destructuring doesn't crash.
- Don't `console.log(err)` AND toast — toast is enough. Some legacy functions do both; new functions should not.
- Return a sensible empty default so the call site's destructuring doesn't crash.
---
## 5. When NOT to add to `api.js`
The user has tolerated a number of pages making direct inline `axios.put` / `axios.post` calls for mutations (e.g. `Tenants.js` calls `axios.put('/tenants/update')` inline). Match the surrounding file:
- **Adding a new shared GET** → put it in `api.js`.
- **Adding a one-off mutation used in only one page** → tolerated inline in that page; doesn't need a new export here.
- **Adding a polling endpoint used by multiple pages** → put it here as a named export so the query cache keys are coherent.
- **Adding a new shared GET against `/admin/*`** → put it in `doormileApi.js`.
- **Adding a one-off mutation used in only one page** → tolerated inline in that page (importing straight from `doormileApi.js`); doesn't need a new export in `api.js`.
- **Adding a polling endpoint used by multiple pages** → put it in `api.js` as a named export so the query cache keys are coherent.
---
## 6. Response shape quirks
## 6. Response shape
Backend response shapes are inconsistent. Don't assume `response.data.details` — check what the specific endpoint returns:
| Backend field | Used by |
|---|---|
| `response.data.details` | Most list endpoints |
| `response.data.summary` | `getcustomersummary`, `gettenantsummary`, `getpricinglist` summary calls |
| `response.data.data` | `getRiderPeriodicLogs`, `getallcustomers` infinite-query page payload |
| `response.data.message` | Mutation success messages (for toast) |
| `response.data.status` | Boolean success flag — check before reading `.details` on some endpoints |
When unsure, log the response once during dev and pick the matching field.
`doormileApi.js` functions already unwrap the envelope: GET/list functions return `response.data.data` directly; mutations return the full `response.data` (`{success, data|message}`) so callers can check `res.success`. Don't assume `.details` / `.status` — those were the old jupiter envelope and no longer apply anywhere in this layer.
---
## 7. Things that look broken but are intentional
- `const userid = localStorage.getItem('userid');` at module top — read once at module load, intentional for the `fetchAppLocations` helper. Don't move it inside the function.
- Some functions take a `pageno` 0-indexed and others 1-indexed (`pageno: pageParam + 1` vs `pageno: pageParam`). Backend inconsistency — leave it alone unless you confirm the backend side.
- A few legacy commented-out function bodies are kept above their current implementation as historical reference. Don't delete them in a drive-by edit.
- Several `api.js` functions ignore most of their destructured `queryKey`/arguments — the new endpoint doesn't accept those filters. This is deliberate degradation, not a bug; see §3.
- A handful of pages (`createorder1.js`, `multipleOrders.js`) synthesize fallback data client-side (e.g. a fixed 09:0021:00 delivery-slot window) because the endpoint that used to supply it no longer exists. Look for the comment explaining why before "fixing" it.
- `notifyRider` / `notifyMiler` now take a **miler profile ID**, not an FCM token — the new API's `/admin/milers/:id/notify` looks the device token up server-side. Don't pass a token here.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,557 @@
import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY } from 'utils/doormileAxios';
// API layer for the new Doormile Express admin backend (api.doormile.com/api/v1/admin/*).
// Mirrors the shape of pages/api/api.js (named async exports, one per endpoint) but talks
// to a different backend with a different envelope: { success, data } / { success, message }.
// See express-console-api.md at the repo root for the full reference this was built from.
// Not wired into any page yet — this is plumbing only.
const buildQuery = (params = {}) => {
const usp = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') usp.append(key, value);
});
const qs = usp.toString();
return qs ? `?${qs}` : '';
};
// ==============================|| Auth ||============================== //
export const loginAdmin = async (email, password) => {
const response = await doormileAxios.post('/admin/login', { email, password });
if (response.data.success) {
localStorage.setItem(DOORMILE_TOKEN_KEY, response.data.token);
localStorage.setItem(DOORMILE_USER_KEY, JSON.stringify(response.data.user));
}
return response.data;
};
export const logoutAdmin = () => {
localStorage.removeItem(DOORMILE_TOKEN_KEY);
localStorage.removeItem(DOORMILE_USER_KEY);
};
// ==============================|| Dashboard, profile, reports ||============================== //
export const getDashboard = async () => {
const response = await doormileAxios.get('/admin/dashboard');
return response.data.data;
};
// jupiter used fromdate/todate + applocationid (city); Doormile uses from/to
// and no city filter at all (filters by hubid instead — see
// jupiter2doormile.md §2.3/§6). Response carries by_location/by_hub/by_tenant/
// by_rider blocks per that doc, not documented in express-console-api.md.
export const getReports = async (from, to, tenantid, locationid, hubid) => {
const response = await doormileAxios.get(`/admin/reports${buildQuery({ from, to, tenantid, locationid, hubid })}`);
return response.data.data;
};
// Confirmed against jupiter2doormile.md (Status: Done) as the replacement for
// jupiter's getlocationsummary — per-tenant-site report breakdown. Not in
// express-console-api.md.
export const getLocationsSummary = async (tenantid, locationid, from, to) => {
const response = await doormileAxios.get(`/admin/locations/summary${buildQuery({ tenantid, locationid, from, to })}`);
return response.data.data;
};
export const getProfile = async () => {
const response = await doormileAxios.get('/admin/profile');
return response.data.data;
};
export const updateProfilePassword = async (current_password, new_password) => {
const response = await doormileAxios.put('/admin/profile/password', { current_password, new_password });
return response.data;
};
// ==============================|| App users (staff logins) ||============================== //
export const getAppUsers = async () => {
const response = await doormileAxios.get('/admin/users');
return response.data.data;
};
export const createAppUser = async (data) => {
const response = await doormileAxios.post('/admin/users', data);
return response.data;
};
export const updateAppUser = async (id, data) => {
const response = await doormileAxios.put(`/admin/users/${id}`, data);
return response.data;
};
export const deleteAppUser = async (id) => {
const response = await doormileAxios.delete(`/admin/users/${id}`);
return response.data;
};
// ==============================|| Partners (fleet / rider suppliers) ||============================== //
export const getPartners = async () => {
const response = await doormileAxios.get('/admin/partners');
return response.data.data;
};
export const createPartner = async (data) => {
const response = await doormileAxios.post('/admin/partners', data);
return response.data;
};
export const getPartner = async (id) => {
const response = await doormileAxios.get(`/admin/partners/${id}`);
return response.data.data;
};
export const updatePartner = async (id, data) => {
const response = await doormileAxios.put(`/admin/partners/${id}`, data);
return response.data;
};
export const deletePartner = async (id) => {
const response = await doormileAxios.delete(`/admin/partners/${id}`);
return response.data;
};
// ==============================|| Tenants (client companies) ||============================== //
export const getAdminTenants = async () => {
const response = await doormileAxios.get('/admin/tenants');
return response.data.data;
};
export const createAdminTenant = async (data) => {
const response = await doormileAxios.post('/admin/tenants', data);
return response.data;
};
export const getAdminTenant = async (id) => {
const response = await doormileAxios.get(`/admin/tenants/${id}`);
return response.data.data;
};
export const updateAdminTenant = async (id, data) => {
// requiredeliveryotp is a pointer server-side — omit the key entirely to leave the setting alone.
const response = await doormileAxios.put(`/admin/tenants/${id}`, data);
return response.data;
};
export const deleteAdminTenant = async (id) => {
const response = await doormileAxios.delete(`/admin/tenants/${id}`);
return response.data;
};
export const getTenantLocations = async (tenantId) => {
const response = await doormileAxios.get(`/admin/tenants/${tenantId}/locations`);
return response.data.data;
};
export const createTenantLocation = async (tenantId, data) => {
const response = await doormileAxios.post(`/admin/tenants/${tenantId}/locations`, data);
return response.data;
};
export const updateTenantLocation = async (locationId, data) => {
// Not nested under the tenant — PUT /admin/tenantlocations/:id.
const response = await doormileAxios.put(`/admin/tenantlocations/${locationId}`, data);
return response.data;
};
// ==============================|| Tenant customers (a client's own end customers) ||============================== //
export const getTenantCustomers = async () => {
const response = await doormileAxios.get('/admin/tenantcustomers');
return response.data.data;
};
export const createTenantCustomer = async (data) => {
const response = await doormileAxios.post('/admin/tenantcustomers', data);
return response.data;
};
export const getTenantCustomer = async (id) => {
const response = await doormileAxios.get(`/admin/tenantcustomers/${id}`);
return response.data.data;
};
export const updateTenantCustomer = async (id, data) => {
const response = await doormileAxios.put(`/admin/tenantcustomers/${id}`, data);
return response.data;
};
export const deleteTenantCustomer = async (id) => {
const response = await doormileAxios.delete(`/admin/tenantcustomers/${id}`);
return response.data;
};
// ==============================|| B2C app customers ||============================== //
export const getAdminCustomers = async () => {
const response = await doormileAxios.get('/admin/customers');
return response.data.data;
};
export const updateAdminCustomer = async (id, data) => {
const response = await doormileAxios.patch(`/admin/customers/${id}`, data);
return response.data;
};
// ==============================|| Hubs ||============================== //
export const getHubs = async () => {
const response = await doormileAxios.get('/admin/hubs');
return response.data.data;
};
export const createHub = async (data) => {
const response = await doormileAxios.post('/admin/hubs', data);
return response.data;
};
export const getHub = async (id) => {
const response = await doormileAxios.get(`/admin/hubs/${id}`);
return response.data.data;
};
export const updateHub = async (id, data) => {
const response = await doormileAxios.put(`/admin/hubs/${id}`, data);
return response.data;
};
export const deleteHub = async (id) => {
// Soft delete server-side.
const response = await doormileAxios.delete(`/admin/hubs/${id}`);
return response.data;
};
// ==============================|| Vehicles ||============================== //
export const getVehicles = async () => {
const response = await doormileAxios.get('/admin/vehicles');
return response.data.data;
};
export const createVehicle = async (data) => {
const response = await doormileAxios.post('/admin/vehicles', data);
return response.data;
};
export const getVehicle = async (id) => {
const response = await doormileAxios.get(`/admin/vehicles/${id}`);
return response.data.data;
};
export const updateVehicle = async (id, data) => {
const response = await doormileAxios.put(`/admin/vehicles/${id}`, data);
return response.data;
};
export const deleteVehicle = async (id) => {
// Soft delete server-side.
const response = await doormileAxios.delete(`/admin/vehicles/${id}`);
return response.data;
};
// ==============================|| Milers (riders) ||============================== //
export const getMilers = async () => {
// Tenant-scoped server-side from the token — no tenantid param needed/accepted.
const response = await doormileAxios.get('/admin/milers');
return response.data.data;
};
export const createMiler = async (data) => {
// configid defaults to 1001 server-side (the miler-app login partition) — do not override it.
const response = await doormileAxios.post('/admin/milers', data);
return response.data;
};
export const getMiler = async (id) => {
const response = await doormileAxios.get(`/admin/milers/${id}`);
return response.data.data;
};
export const updateMiler = async (id, data) => {
const response = await doormileAxios.put(`/admin/milers/${id}`, data);
return response.data;
};
export const blockMiler = async (id, data) => {
const response = await doormileAxios.put(`/admin/milers/${id}/block`, data);
return response.data;
};
export const assignMilerVehicle = async (id, data) => {
const response = await doormileAxios.put(`/admin/milers/${id}/assign-vehicle`, data);
return response.data;
};
export const notifyMiler = async (milerProfileId, title, message) => {
const response = await doormileAxios.post(`/admin/milers/${milerProfileId}/notify`, { title, message });
return response.data;
};
// Confirmed against jupiter2doormile.md (Status: Done — tested against a real
// request), not in express-console-api.md. jupiter used fromdate/todate;
// Doormile uses from/to (both YYYY-MM-DD). Aggregate rider KPI counts +
// current position (currentlatitude/currentlongitude/lastpingat per the doc —
// unconfirmed beyond those three field names).
export const getMilerSummary = async (applocationid, from, to, tenantid, hubid) => {
const response = await doormileAxios.get(`/admin/milers/summary${buildQuery({ applocationid, from, to, tenantid, hubid })}`);
return response.data.data;
};
// Replaces jupiter's `riderlogs` table — the rider's GPS/status ping history.
export const getMilerLogs = async (id, from, to, limit) => {
const response = await doormileAxios.get(`/admin/milers/${id}/logs${buildQuery({ from, to, limit })}`);
return response.data.data;
};
// No jupiter equivalent — new in Doormile.
export const getMilerActivity = async (id, from, to) => {
const response = await doormileAxios.get(`/admin/milers/${id}/activity${buildQuery({ from, to })}`);
return response.data.data;
};
// ==============================|| Bookings ||============================== //
export const getBookings = async (pageno, pagesize) => {
const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`);
return response.data.data;
};
export const createExpressBooking = async (data) => {
// tenantid is forced to the caller's own tenant server-side on client logins.
// CityGate applies: pickup pincode prefix must be an open city (641/600/560/500/629).
const response = await doormileAxios.post('/admin/expressbooking', data);
return response.data;
};
export const createExpressBookingBulk = async (bookings) => {
// Max 200 per call; response carries per-row results.
const response = await doormileAxios.post('/admin/expressbooking/bulk', { bookings });
return response.data;
};
export const getBooking = async (id) => {
const response = await doormileAxios.get(`/admin/bookings/${id}`);
return response.data.data;
};
export const assignMilerToBooking = async (id, data) => {
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-miler`, data);
return response.data;
};
export const assignVehicleToBooking = async (id, data) => {
const response = await doormileAxios.post(`/admin/bookings/${id}/assign-vehicle`, data);
return response.data;
};
export const updateBookingStatus = async (id, data) => {
const response = await doormileAxios.put(`/admin/bookings/${id}/status`, data);
return response.data;
};
export const cancelBooking = async (id, data) => {
const response = await doormileAxios.post(`/admin/bookings/${id}/cancel`, data);
return response.data;
};
export const bulkCancelBookings = async (bookingIds) => {
const response = await doormileAxios.post('/admin/bookings/bulk-cancel', { bookingIds });
return response.data;
};
// Confirmed against jupiter2doormile.md (Status: Done), not in
// express-console-api.md.
export const getBookingTrack = async (id) => {
const response = await doormileAxios.get(`/admin/bookings/${id}/track`);
return response.data.data;
};
// ==============================|| Consignments ||============================== //
export const getConsignments = async () => {
const response = await doormileAxios.get('/admin/consignments');
return response.data.data;
};
// Confirmed against jupiter2doormile.md (Status: Done) — the actual GPS/status
// trail for a consignment, not in express-console-api.md.
export const getConsignmentLogs = async (id) => {
const response = await doormileAxios.get(`/admin/consignments/${id}/logs`);
return response.data.data;
};
export const getConsignment = async (id) => {
const response = await doormileAxios.get(`/admin/consignments/${id}`);
return response.data.data;
};
export const trackConsignment = async (trackingno) => {
const response = await doormileAxios.get(`/admin/consignments/track/${trackingno}`);
return response.data.data;
};
export const updateConsignmentStatus = async (id, data) => {
const response = await doormileAxios.put(`/admin/consignments/${id}/status`, data);
return response.data;
};
// ==============================|| Tripsheets (hub-to-hub transport) ||============================== //
export const getTripsheets = async () => {
const response = await doormileAxios.get('/admin/tripsheets');
return response.data.data;
};
export const createTripsheet = async (data) => {
const response = await doormileAxios.post('/admin/tripsheets', data);
return response.data;
};
export const getTripsheet = async (id) => {
const response = await doormileAxios.get(`/admin/tripsheets/${id}`);
return response.data.data;
};
export const addTripsheetItem = async (id, consignmentid) => {
const response = await doormileAxios.post(`/admin/tripsheets/${id}/items`, { consignmentid });
return response.data;
};
export const removeTripsheetItem = async (id, itemId) => {
const response = await doormileAxios.delete(`/admin/tripsheets/${id}/items/${itemId}`);
return response.data;
};
export const dispatchTripsheet = async (id) => {
const response = await doormileAxios.put(`/admin/tripsheets/${id}/dispatch`);
return response.data;
};
export const arriveTripsheet = async (id) => {
const response = await doormileAxios.put(`/admin/tripsheets/${id}/arrive`);
return response.data;
};
// ==============================|| Pricing ||============================== //
export const getAdminPricing = async () => {
const response = await doormileAxios.get('/admin/pricing');
return response.data.data;
};
export const createAdminPricing = async (data) => {
const response = await doormileAxios.post('/admin/pricing', data);
return response.data;
};
export const updateAdminPricing = async (id, data) => {
const response = await doormileAxios.put(`/admin/pricing/${id}`, data);
return response.data;
};
export const deleteAdminPricing = async (id) => {
const response = await doormileAxios.delete(`/admin/pricing/${id}`);
return response.data;
};
export const simulatePricing = async (data) => {
const response = await doormileAxios.post('/admin/pricing/simulate', data);
return response.data;
};
export const quotePricing = async (data) => {
// Same handler as simulate.
const response = await doormileAxios.post('/admin/pricing/quote', data);
return response.data;
};
export const getDoormilePricing = async () => {
const response = await doormileAxios.get('/admin/doormile-pricing');
return response.data.data;
};
export const createDoormilePricing = async (data) => {
const response = await doormileAxios.post('/admin/doormile-pricing', data);
return response.data;
};
export const updateDoormilePricing = async (id, data) => {
const response = await doormileAxios.put(`/admin/doormile-pricing/${id}`, data);
return response.data;
};
export const deleteDoormilePricing = async (id) => {
// Soft delete server-side.
const response = await doormileAxios.delete(`/admin/doormile-pricing/${id}`);
return response.data;
};
// ==============================|| Exceptions ||============================== //
export const getExceptions = async () => {
const response = await doormileAxios.get('/admin/exceptions');
return response.data.data;
};
export const createException = async (data) => {
const response = await doormileAxios.post('/admin/exceptions', data);
return response.data;
};
export const getException = async (id) => {
const response = await doormileAxios.get(`/admin/exceptions/${id}`);
return response.data.data;
};
export const updateExceptionStatus = async (id, status, resolution) => {
// status: 'Resolved' | 'Closed'.
const response = await doormileAxios.put(`/admin/exceptions/${id}/status`, { status, resolution });
return response.data;
};
// ==============================|| Competitive intel ||============================== //
export const getCompetitorBranches = async () => {
const response = await doormileAxios.get('/admin/competitor-branches');
return response.data.data;
};
export const createCompetitorBranch = async (data) => {
const response = await doormileAxios.post('/admin/competitor-branches', data);
return response.data;
};
export const updateCompetitorBranch = async (id, data) => {
const response = await doormileAxios.put(`/admin/competitor-branches/${id}`, data);
return response.data;
};
export const deleteCompetitorBranch = async (id) => {
const response = await doormileAxios.delete(`/admin/competitor-branches/${id}`);
return response.data;
};
export const getCarrierPricing = async () => {
const response = await doormileAxios.get('/admin/carrier-pricing');
return response.data.data;
};
export const createCarrierPricing = async (data) => {
const response = await doormileAxios.post('/admin/carrier-pricing', data);
return response.data;
};
export const updateCarrierPricing = async (id, data) => {
const response = await doormileAxios.put(`/admin/carrier-pricing/${id}`, data);
return response.data;
};
export const deleteCarrierPricing = async (id) => {
const response = await doormileAxios.delete(`/admin/carrier-pricing/${id}`);
return response.data;
};

View File

@@ -1,9 +1,19 @@
import React, { useMemo, useRef, useState } from 'react';
import {
Autocomplete,
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
@@ -11,6 +21,8 @@ import {
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
@@ -18,19 +30,21 @@ import {
import {
MdLocalOffer,
MdMyLocation,
MdAttachMoney,
MdGroups,
MdPlace,
MdSpeed,
MdPriceCheck,
MdStraighten,
MdReceiptLong,
MdOutlineLocalOffer,
MdOutlineGroups,
MdOutlineAttachMoney,
MdOutlinePlace
MdOutlinePlace,
MdAdd,
MdEdit,
MdDeleteOutline,
MdCalculate
} from 'react-icons/md';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
@@ -39,7 +53,9 @@ import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getallpricing } from 'pages/api/api';
import { getallpricing, getTenants, fetchAppLocations } from 'pages/api/api';
import { createAdminPricing, updateAdminPricing, deleteAdminPricing, simulatePricing } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
// ============================================================================
// Design tokens — shared with the deliveries / tenants / customers pages so every
@@ -70,6 +86,7 @@ const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const BRAND = '#662582';
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const SoftPaper = (props) => (
<Paper
@@ -106,13 +123,6 @@ const formatRupees = (value) =>
minimumFractionDigits: 2
}).format(Number(value) || 0);
const formatDecimal = (value) =>
new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2 }).format(Number(value) || 0);
// Numeric table value — plain, strong, right-readable text. The old version
// wrapped every cell in a coloured bordered pill, which made the table read
// like a rainbow; corporate data tables keep figures as quiet typography and
// let the column header carry the meaning.
const MetricPill = ({ label }) => (
<Typography
component="span"
@@ -122,7 +132,6 @@ const MetricPill = ({ label }) => (
</Typography>
);
// Subtle neutral category chip (zone / slab) — one quiet style, muted icon.
const CategoryChip = ({ icon, label }) => (
<Box
sx={{
@@ -147,55 +156,196 @@ const CategoryChip = ({ icon, label }) => (
</Box>
);
const emptyForm = {
pricingid: null,
tenant: null,
location: null,
vehicletype: 'Bike',
baseprice: '',
baseweight: '',
priceperkg: '',
basedistance: '',
priceperkm: '',
handlingcharges: '',
priority: 1,
status: 'active'
};
// ==============================|| Pricing page ||============================== //
const ClientsPricing = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const containerRef = useRef();
const queryClient = useQueryClient();
const [appId, setAppId] = useState(0);
const [locaName, setLocoName] = useState('All');
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [quoteOpen, setQuoteOpen] = useState(false);
const [quoteForm, setQuoteForm] = useState({ tenant: null, location: null, vehicletype: 'Bike', weight: '', distance: '' });
const {
data: pricing = [],
isLoading
} = useQuery({
const { data: pricing = [], isLoading } = useQuery({
queryKey: ['getallpricing', appId],
queryFn: getallpricing,
keepPreviousData: true
});
const { data: tenants = [] } = useQuery({ queryKey: ['pricing-tenants'], queryFn: getTenants });
const { data: locations = [] } = useQuery({ queryKey: ['pricing-locations'], queryFn: fetchAppLocations });
const tenantMap = useMemo(() => new Map((tenants || []).map((t) => [t.tenantid, t])), [tenants]);
const locationMap = useMemo(
() => new Map((locations || []).filter((l) => l.applocationid).map((l) => [l.applocationid, l])),
[locations]
);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]);
const tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—');
const zoneName = (row) =>
row.applocation || locationMap.get(row.applocationid)?.locationname || (row.applocationid ? `Zone #${row.applocationid}` : '—');
const rows = useMemo(() => {
if (!debouncedSearch) return pricing;
const q = debouncedSearch.toLowerCase().trim();
return pricing.filter((row) =>
[row.applocation, row.appname, row.slab, String(row.pricingid)]
[tenantName(row), row.vehicletype, zoneName(row), String(row.pricingid)]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(q))
);
}, [pricing, debouncedSearch]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pricing, debouncedSearch, tenantMap, locationMap]);
const stats = useMemo(() => {
const total = pricing.length;
const tenants = new Set(pricing.map((r) => r.appname).filter(Boolean)).size;
const avgBase = total
? pricing.reduce((sum, r) => sum + (Number(r.baseprice) || 0), 0) / total
: 0;
return { total, tenants, avgBase };
const tenantsPriced = new Set(pricing.map((r) => r.tenantid).filter(Boolean)).size;
const avgBase = total ? pricing.reduce((sum, r) => sum + (Number(r.baseprice) || 0), 0) / total : 0;
const activeCount = pricing.filter((r) => String(r.status).toLowerCase() === 'active').length;
return { total, tenantsPriced, avgBase, activeCount };
}, [pricing]);
const KPI_META = [
{ key: 'total', label: 'Total Pricing Slabs', color: BRAND, icon: MdOutlineLocalOffer, value: stats.total },
{ key: 'tenants', label: 'Tenants Priced', color: '#0ea5e9', icon: MdOutlineGroups, value: stats.tenants },
{ key: 'total', label: 'Total Pricing Rules', color: BRAND, icon: MdOutlineLocalOffer, value: stats.total },
{ key: 'tenants', label: 'Tenants Priced', color: '#0ea5e9', icon: MdOutlineGroups, value: stats.tenantsPriced },
{ key: 'avg', label: 'Avg Base Price', color: '#f59e0b', icon: MdOutlineAttachMoney, value: formatRupees(stats.avgBase) },
{ key: 'zone', label: 'Active Zone', color: '#10b981', icon: MdOutlinePlace, value: locaName || 'All Zones' }
{ key: 'active', label: 'Active Rules', color: '#10b981', icon: MdOutlinePlace, value: stats.activeCount }
];
const opentoast = (message, variant = 'error') => {
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
};
const savePricingMutation = useMutation({
mutationFn: (payload) => (payload.pricingid ? updateAdminPricing(payload.pricingid, payload.data) : createAdminPricing(payload.data)),
onSuccess: (res) => {
if (res.success) {
opentoast(form.pricingid ? 'Pricing rule updated' : 'Pricing rule created', 'success');
queryClient.invalidateQueries({ queryKey: ['getallpricing'] });
setDialogOpen(false);
setForm(emptyForm);
} else {
opentoast(res.message || 'Failed to save pricing rule');
}
},
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save pricing rule')
});
const deletePricingMutation = useMutation({
mutationFn: (id) => deleteAdminPricing(id),
onSuccess: (res) => {
if (res.success) {
opentoast('Pricing rule deleted', 'success');
queryClient.invalidateQueries({ queryKey: ['getallpricing'] });
} else {
opentoast(res.message || 'Failed to delete pricing rule');
}
},
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete pricing rule')
});
// POST /admin/pricing/simulate — request/response shape isn't documented
// beyond the endpoint existing ("same handler as quote"). Rendered
// defensively below rather than assuming a specific result field name.
const quoteMutation = useMutation({
mutationFn: (data) => simulatePricing(data),
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to get quote')
});
const handleQuote = () => {
if (!quoteForm.tenant?.tenantid || !quoteForm.distance) {
opentoast('Choose Tenant and fill Distance');
return;
}
quoteMutation.mutate({
tenantid: quoteForm.tenant.tenantid,
applocationid: quoteForm.location?.applocationid,
vehicletype: quoteForm.vehicletype,
weight: Number(quoteForm.weight) || 0,
distance: Number(quoteForm.distance) || 0
});
};
const openCreateDialog = () => {
setForm(emptyForm);
setDialogOpen(true);
};
const openEditDialog = (row) => {
setForm({
pricingid: row.pricingid,
tenant: tenantMap.get(row.tenantid) || (row.tenantid ? { tenantid: row.tenantid, tenantname: tenantName(row) } : null),
location: locationMap.get(row.applocationid) || null,
vehicletype: row.vehicletype || 'Bike',
baseprice: row.baseprice ?? '',
baseweight: row.baseweight ?? '',
priceperkg: row.priceperkg ?? '',
basedistance: row.basedistance ?? '',
priceperkm: row.priceperkm ?? '',
handlingcharges: row.handlingcharges ?? '',
priority: row.priority ?? 1,
status: row.status || 'active'
});
setDialogOpen(true);
};
const handleDelete = (row) => {
if (window.confirm(`Delete this pricing rule for ${tenantName(row)}?`)) {
deletePricingMutation.mutate(row.pricingid);
}
};
const handleSave = () => {
if (!form.tenant?.tenantid) {
opentoast('Choose Tenant');
return;
}
if (!form.baseprice || !form.priceperkm) {
opentoast('Fill Base Price and Price / KM');
return;
}
// POST/PUT /admin/pricing body per express-console-api.md.
const data = {
tenantid: form.tenant.tenantid,
applocationid: form.location?.applocationid,
vehicletype: form.vehicletype,
baseprice: Number(form.baseprice),
baseweight: Number(form.baseweight) || 0,
priceperkg: Number(form.priceperkg) || 0,
basedistance: Number(form.basedistance) || 0,
priceperkm: Number(form.priceperkm),
handlingcharges: Number(form.handlingcharges) || 0,
currency: 'INR',
priority: Number(form.priority) || 1,
status: form.status
};
savePricingMutation.mutate({ pricingid: form.pricingid, data });
};
return (
<>
{isLoading && <Loader />}
{(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />}
{/* ============================================= || Header || ============================================= */}
<PageHeader
@@ -203,6 +353,7 @@ const ClientsPricing = () => {
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={
<Stack direction="row" spacing={1.25} alignItems="center">
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
@@ -212,8 +363,25 @@ const ClientsPricing = () => {
icon={<MdMyLocation size={14} />}
placeholder="Select Zone"
paperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
sx={{ width: { xs: '100%', sm: 220 }, zIndex: 100 }}
/>
<Button
variant="outlined"
startIcon={<MdCalculate size={16} />}
onClick={() => setQuoteOpen(true)}
sx={{ borderColor: BRAND, color: BRAND, whiteSpace: 'nowrap' }}
>
Quote Price
</Button>
<Button
variant="contained"
startIcon={<MdAdd size={16} />}
onClick={openCreateDialog}
sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}
>
New Pricing Rule
</Button>
</Stack>
}
/>
@@ -312,14 +480,14 @@ const ClientsPricing = () => {
No pricing to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center' }}>
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the catalog.'}
{searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'}
</Typography>
</Stack>
) : (
<MobileCardList scroll>
{rows.map((row, index) => (
<MobileCard
key={row.pricingid || `${row.appname}-${index}`}
key={row.pricingid || `${row.tenantid}-${index}`}
accent={BRAND}
header={
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
@@ -328,26 +496,26 @@ const ClientsPricing = () => {
<MdGroups size={18} />
</AccentAvatar>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary }}
noWrap
>
{row.appname || '—'}
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
{tenantName(row)}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{row.pricingid}
</Typography>
</Stack>
</Stack>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted, flexShrink: 0 }}>
{String(index + 1).padStart(2, '0')}
</Typography>
<Stack direction="row" spacing={0.5}>
<IconButton size="small" onClick={() => openEditDialog(row)} sx={{ color: BRAND }}>
<MdEdit size={14} />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
<MdDeleteOutline size={14} />
</IconButton>
</Stack>
</Stack>
}
>
<Stack direction="row" spacing={0.75} sx={{ mt: 1, flexWrap: 'wrap', gap: 0.75 }}>
{row.applocation ? (
<Box
sx={{
display: 'inline-flex',
@@ -363,9 +531,8 @@ const ClientsPricing = () => {
fontWeight: 800
}}
>
<MdPlace size={12} /> {row.applocation}
<MdPlace size={12} /> {zoneName(row)}
</Box>
) : null}
<Box
sx={{
display: 'inline-flex',
@@ -381,44 +548,28 @@ const ClientsPricing = () => {
fontWeight: 800
}}
>
<MdSpeed size={12} /> {row.slab || '—'}
<MdSpeed size={12} /> {row.vehicletype || '—'}
</Box>
</Stack>
<MobileFieldGrid>
<MobileField label="Base Price">
<MetricPill
color={BRAND}
icon={<MdPriceCheck size={12} />}
label={formatRupees(row.baseprice)}
/>
<MetricPill label={formatRupees(row.baseprice)} />
</MobileField>
<MobileField label="Price / KM">
<MetricPill
color="#10b981"
icon={<MdAttachMoney size={12} />}
label={formatRupees(row.priceperkm)}
/>
<MetricPill label={formatRupees(row.priceperkm)} />
</MobileField>
<MobileField label="Min KM">
<MetricPill
color="#f59e0b"
icon={<MdStraighten size={12} />}
label={`${formatDecimal(row.minkm)} km`}
/>
<MobileField label="Price / KG">
<MetricPill label={formatRupees(row.priceperkg)} />
</MobileField>
<MobileField label="Max KM">
<MetricPill
color="#ef4444"
icon={<MdStraighten size={12} />}
label={`${formatDecimal(row.maxkm)} km`}
/>
<MobileField label="Handling">
<MetricPill label={formatRupees(row.handlingcharges)} />
</MobileField>
<MobileField label="Min Orders">
<MobileField label="Status">
<Stack direction="row" alignItems="center" spacing={0.5}>
<MdReceiptLong size={14} color={DT.textMuted} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
{row.minorder ?? '—'}
{row.status || '—'}
</Typography>
</Stack>
</MobileField>
@@ -433,15 +584,11 @@ const ClientsPricing = () => {
sx={{
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
'&::-webkit-scrollbar': { width: 10, height: 10 },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge(BRAND),
borderRadius: 8,
'&:hover': { backgroundColor: BRAND }
},
'&::-webkit-scrollbar-thumb': { backgroundColor: edge(BRAND), borderRadius: 8, '&:hover': { backgroundColor: BRAND } },
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
}}
>
<Table stickyHeader sx={{ minWidth: { xs: 860, md: 1080 } }}>
<Table stickyHeader sx={{ minWidth: { xs: 860, md: 1180 } }}>
<TableHead>
<TableRow
sx={{
@@ -462,20 +609,21 @@ const ClientsPricing = () => {
<TableCell>#</TableCell>
<TableCell>Tenant</TableCell>
<TableCell>Zone</TableCell>
<TableCell>Slab</TableCell>
<TableCell>Vehicle</TableCell>
<TableCell align="center">Base Price</TableCell>
<TableCell align="center">Min KM</TableCell>
<TableCell align="center">Price / KM</TableCell>
<TableCell align="center">Max KM</TableCell>
<TableCell align="center">Min Orders</TableCell>
<TableCell align="center">Price / KG</TableCell>
<TableCell align="center">Handling</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <OrdersTableSkeleton col={5} />}
{isLoading && <OrdersTableSkeleton col={6} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={9} sx={{ py: 6 }}>
<TableCell colSpan={10} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdLocalOffer size={28} />
@@ -484,7 +632,7 @@ const ClientsPricing = () => {
No pricing to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the catalog.'}
{searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'}
</Typography>
</Stack>
</TableCell>
@@ -492,14 +640,10 @@ const ClientsPricing = () => {
) : (
rows.map((row, index) => (
<TableRow
key={row.pricingid || `${row.appname}-${index}`}
key={row.pricingid || `${row.tenantid}-${index}`}
sx={{
transition: 'background-color 0.15s',
'& td': {
borderBottom: `1px solid ${DT.divider}`,
py: { xs: 1, md: 1.5 },
px: { xs: 1, md: 2 }
},
'& td': { borderBottom: `1px solid ${DT.divider}`, py: { xs: 1, md: 1.5 }, px: { xs: 1, md: 2 } },
'&:hover': { backgroundColor: DT.surfaceAlt }
}}
>
@@ -515,11 +659,8 @@ const ClientsPricing = () => {
<MdGroups size={18} />
</AccentAvatar>
<Stack>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
{row.appname || '—'}
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{tenantName(row)}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{row.pricingid}
@@ -529,62 +670,45 @@ const ClientsPricing = () => {
</TableCell>
<TableCell>
{row.applocation ? (
<CategoryChip icon={<MdPlace size={12} />} label={row.applocation} />
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
)}
<CategoryChip icon={<MdPlace size={12} />} label={zoneName(row)} />
</TableCell>
<TableCell>
<CategoryChip icon={<MdSpeed size={12} />} label={row.slab || '—'} />
<CategoryChip icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
</TableCell>
<TableCell align="center">
<MetricPill
color={BRAND}
icon={<MdPriceCheck size={12} />}
label={formatRupees(row.baseprice)}
width={110}
/>
<MetricPill label={formatRupees(row.baseprice)} />
</TableCell>
<TableCell align="center">
<MetricPill
color="#f59e0b"
icon={<MdStraighten size={12} />}
label={`${formatDecimal(row.minkm)} km`}
width={90}
/>
<MetricPill label={formatRupees(row.priceperkm)} />
</TableCell>
<TableCell align="center">
<MetricPill
color="#10b981"
icon={<MdAttachMoney size={12} />}
label={formatRupees(row.priceperkm)}
width={110}
/>
<MetricPill label={formatRupees(row.priceperkg)} />
</TableCell>
<TableCell align="center">
<MetricPill
color="#ef4444"
icon={<MdStraighten size={12} />}
label={`${formatDecimal(row.maxkm)} km`}
width={90}
/>
<MetricPill label={formatRupees(row.handlingcharges)} />
</TableCell>
<TableCell align="center">
<Stack direction="row" alignItems="center" justifyContent="center" spacing={0.5}>
<MdReceiptLong size={14} color={DT.textMuted} />
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
{row.minorder ?? '—'}
</Typography>
<CategoryChip icon={<MdPriceCheck size={12} />} label={row.status || '—'} />
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Edit rule" placement="top">
<IconButton size="small" onClick={() => openEditDialog(row)} sx={{ color: BRAND }}>
<MdEdit size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Delete rule" placement="top">
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
<MdDeleteOutline size={16} />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
@@ -595,6 +719,232 @@ const ClientsPricing = () => {
</TableContainer>
)}
</Paper>
{/* ============================================= || Create / Edit dialog || ============================================= */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>{form.pricingid ? 'Edit Pricing Rule' : 'New Pricing Rule'}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Tenant</InputLabel>
<Autocomplete
options={tenants || []}
getOptionLabel={(option) => option.tenantname || ''}
value={form.tenant}
onChange={(e, value) => setForm((f) => ({ ...f, tenant: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose tenant" />}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Zone (optional)</InputLabel>
<Autocomplete
options={locationOptions}
getOptionLabel={(option) => option.locationname || ''}
value={form.location}
onChange={(e, value) => setForm((f) => ({ ...f, location: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose zone" />}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Vehicle Type</InputLabel>
<Select fullWidth value={form.vehicletype} onChange={(e) => setForm((f) => ({ ...f, vehicletype: e.target.value }))}>
{VEHICLE_TYPES.map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Status</InputLabel>
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Base Price</InputLabel>
<TextField type="number" value={form.baseprice} onChange={(e) => setForm((f) => ({ ...f, baseprice: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Base Weight (kg)</InputLabel>
<TextField type="number" value={form.baseweight} onChange={(e) => setForm((f) => ({ ...f, baseweight: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Price / KG</InputLabel>
<TextField type="number" value={form.priceperkg} onChange={(e) => setForm((f) => ({ ...f, priceperkg: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Base Distance (km)</InputLabel>
<TextField
type="number"
value={form.basedistance}
onChange={(e) => setForm((f) => ({ ...f, basedistance: e.target.value }))}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Price / KM</InputLabel>
<TextField type="number" value={form.priceperkm} onChange={(e) => setForm((f) => ({ ...f, priceperkm: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Handling Charges</InputLabel>
<TextField
type="number"
value={form.handlingcharges}
onChange={(e) => setForm((f) => ({ ...f, handlingcharges: e.target.value }))}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Priority</InputLabel>
<TextField type="number" value={form.priority} onChange={(e) => setForm((f) => ({ ...f, priority: e.target.value }))} />
</Stack>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
{form.pricingid ? 'Save Changes' : 'Create'}
</Button>
</DialogActions>
</Dialog>
{/* ============================================= || Quote price dialog || ============================================= */}
<Dialog open={quoteOpen} onClose={() => setQuoteOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Quote Price</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Tenant</InputLabel>
<Autocomplete
options={tenants || []}
getOptionLabel={(option) => option.tenantname || ''}
value={quoteForm.tenant}
onChange={(e, value) => setQuoteForm((f) => ({ ...f, tenant: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose tenant" />}
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Zone (optional)</InputLabel>
<Autocomplete
options={locationOptions}
getOptionLabel={(option) => option.locationname || ''}
value={quoteForm.location}
onChange={(e, value) => setQuoteForm((f) => ({ ...f, location: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose zone" />}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Vehicle Type</InputLabel>
<Select
fullWidth
value={quoteForm.vehicletype}
onChange={(e) => setQuoteForm((f) => ({ ...f, vehicletype: e.target.value }))}
>
{VEHICLE_TYPES.map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Weight (kg)</InputLabel>
<TextField
type="number"
value={quoteForm.weight}
onChange={(e) => setQuoteForm((f) => ({ ...f, weight: e.target.value }))}
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Distance (km)</InputLabel>
<TextField
type="number"
value={quoteForm.distance}
onChange={(e) => setQuoteForm((f) => ({ ...f, distance: e.target.value }))}
/>
</Stack>
</Grid>
{quoteMutation.isPending && (
<Grid item xs={12}>
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
Calculating
</Typography>
</Grid>
)}
{quoteMutation.data && (
<Grid item xs={12}>
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2, bgcolor: DT.surfaceAlt }}>
{quoteMutation.data.success === false ? (
<Typography variant="body2" sx={{ color: '#ef4444' }}>
{quoteMutation.data.message || 'No quote available'}
</Typography>
) : (
<Stack spacing={0.75}>
{Object.entries(quoteMutation.data.data || quoteMutation.data).map(([key, value]) => (
<Stack key={key} direction="row" justifyContent="space-between">
<Typography variant="caption" sx={{ color: DT.textMuted, textTransform: 'uppercase', fontWeight: 700 }}>
{key}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
</Typography>
</Stack>
))}
</Stack>
)}
</Paper>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => {
setQuoteOpen(false);
quoteMutation.reset();
setQuoteForm({ tenant: null, location: null, vehicletype: 'Bike', weight: '', distance: '' });
}}
>
Close
</Button>
<Button variant="contained" onClick={handleQuote} disabled={quoteMutation.isPending} sx={{ bgcolor: BRAND }}>
Get Quote
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState, useRef, Fragment } from 'react';
import MainCard from 'components/MainCard';
import axios from 'axios';
import { useTheme } from '@mui/material/styles';
import Loader from 'components/Loader';
import { Empty } from 'antd';
@@ -19,7 +18,6 @@ import {
IconButton,
Box,
Grid,
Autocomplete,
TextField,
Tooltip,
Collapse,
@@ -39,6 +37,8 @@ import {
Skeleton,
Avatar,
Paper,
Select,
MenuItem,
useMediaQuery
} from '@mui/material';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
@@ -65,7 +65,12 @@ import {
MdOutlinePendingActions,
MdOutlineCancel,
MdMyLocation,
MdPersonPin
MdPersonPin,
MdPlace,
MdAdd,
MdEdit,
MdDeleteOutline,
MdPeopleAlt
} from 'react-icons/md';
import { LuMail } from 'react-icons/lu';
import { BiUser } from 'react-icons/bi';
@@ -73,8 +78,21 @@ import LocationAutocomplete from 'components/nearle_components/LocationAutocompl
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getalltenants, gettenantsummary } from 'pages/api/api';
import {
getAdminPricing,
getDoormilePricing,
createAdminPricing,
updateAdminTenant,
getTenantLocations,
createTenantLocation,
updateTenantLocation,
getTenantCustomers,
createTenantCustomer,
updateTenantCustomer,
deleteTenantCustomer
} from 'pages/api/doormileApi';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
// ============================================================================
@@ -103,24 +121,6 @@ const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const pillFieldSx = (color) => ({
cursor: 'pointer',
'& .MuiOutlinedInput-root': {
borderRadius: '10px',
bgcolor: '#ffffff',
fontWeight: 600,
color: DT.textPrimary,
paddingRight: '8px',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.2s',
'& fieldset': { borderColor: '#e2e8f0', borderWidth: 1 },
'&:hover fieldset': { borderColor: '#cbd5e1' },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(color)}` },
'&.Mui-focused fieldset': { borderColor: '#662582', borderWidth: 1.5 }
},
'& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: color }
});
// Status palette — drives the pill tabs and per-row badges.
const STATUS_META = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, statusKey: 'active' },
@@ -168,6 +168,370 @@ const AccentAvatar = ({ color, selected, size = 24, children }) => (
</Avatar>
);
// ==============================|| Tenant locations (branches/sites) dialog ||============================== //
// Self-contained — deliberately does not touch the existing openRowIndex1/2 +
// Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT
// /admin/tenants/:id/locations + /admin/tenantlocations/:id per
// jupiter2doormile.md §2.4 — no location UI existed anywhere before this.
const emptyLocationForm = {
locationid: null,
locationname: '',
address: '',
city: '',
state: '',
pincode: '',
isprimary: false,
status: 'active'
};
const TenantLocationsDialog = ({ tenant, open, onClose }) => {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyLocationForm);
const [formOpen, setFormOpen] = useState(false);
const { data: locations = [], isLoading } = useQuery({
queryKey: ['tenant-locations', tenant?.tenantid],
queryFn: () => getTenantLocations(tenant.tenantid),
enabled: !!tenant?.tenantid && open
});
const saveMutation = useMutation({
mutationFn: (payload) =>
payload.locationid ? updateTenantLocation(payload.locationid, payload.data) : createTenantLocation(tenant.tenantid, payload.data),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar(form.locationid ? 'Location updated' : 'Location added', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
queryClient.invalidateQueries({ queryKey: ['tenant-locations', tenant?.tenantid] });
setForm(emptyLocationForm);
setFormOpen(false);
} else {
enqueueSnackbar(res.message || 'Failed to save location', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to save location', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const handleSave = () => {
if (!form.locationname || !form.address) {
enqueueSnackbar('Fill Location Name and Address', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return;
}
const { locationid, ...data } = form;
saveMutation.mutate({ locationid, data });
};
const openEdit = (loc) => {
setForm({
locationid: loc.locationid,
locationname: loc.locationname || '',
address: loc.address || '',
city: loc.city || '',
state: loc.state || '',
pincode: loc.pincode || '',
isprimary: !!loc.isprimary,
status: loc.status || 'active'
});
setFormOpen(true);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>
Locations · {tenant?.tenantname}
</DialogTitle>
<DialogContent dividers>
{isLoading ? (
<Typography variant="body2" sx={{ color: DT.textSecondary, py: 2 }}>
Loading locations
</Typography>
) : locations.length === 0 && !formOpen ? (
<Empty description="No locations added yet" />
) : (
<Stack spacing={1.5}>
{locations.map((loc) => (
<Paper key={loc.locationid} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography sx={{ fontWeight: 700 }}>
{loc.locationname} {loc.isprimary && '· Primary'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{[loc.address, loc.city, loc.state, loc.pincode].filter(Boolean).join(', ')}
</Typography>
</Box>
<IconButton size="small" onClick={() => openEdit(loc)}>
<MdEdit size={14} />
</IconButton>
</Stack>
</Paper>
))}
</Stack>
)}
{formOpen && (
<Grid container spacing={1.5} sx={{ mt: 1.5, pt: 1.5, borderTop: `1px solid ${DT.divider}` }}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Location Name"
value={form.locationname}
onChange={(e) => setForm((f) => ({ ...f, locationname: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="Pincode" value={form.pincode} onChange={(e) => setForm((f) => ({ ...f, pincode: e.target.value }))} />
</Grid>
<Grid item xs={12}>
<TextField fullWidth label="Address" value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="City" value={form.city} onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="State" value={form.state} onChange={(e) => setForm((f) => ({ ...f, state: e.target.value }))} />
</Grid>
</Grid>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{formOpen ? (
<>
<Button
onClick={() => {
setFormOpen(false);
setForm(emptyLocationForm);
}}
>
Cancel
</Button>
<Button variant="contained" onClick={handleSave}>
{form.locationid ? 'Save Changes' : 'Add Location'}
</Button>
</>
) : (
<>
<Button onClick={onClose}>Close</Button>
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setFormOpen(true)}>
Add Location
</Button>
</>
)}
</DialogActions>
</Dialog>
);
};
// ==============================|| Tenant customers dialog ||============================== //
// GET /admin/tenantcustomers has no documented tenantid filter (unlike
// locations, which are nested under /admin/tenants/:id/locations) — filtered
// client-side here the same way fetchclientpricelist already filters
// /admin/pricing client-side, since neither endpoint accepts the param.
// createclient.js used to (incorrectly) create tenant customers instead of
// tenants; now that it correctly creates tenants, this dialog is the only
// place left that can create one.
const emptyCustomerForm = { id: null, firstname: '', lastname: '', phone: '', email: '' };
// GET /admin/tenantcustomers doesn't document its id field name either — same
// defensive fallback as customers.js's custId() rather than assuming `id`.
const tenantCustId = (row) => row.id ?? row.tenantcustomerid ?? row.customerid;
const TenantCustomersDialog = ({ tenant, open, onClose }) => {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyCustomerForm);
const [formOpen, setFormOpen] = useState(false);
const { data: allCustomers = [], isLoading } = useQuery({
queryKey: ['tenant-customers-all'],
queryFn: getTenantCustomers,
enabled: open
});
const customers = (allCustomers || []).filter((c) => !tenant?.tenantid || c.tenantid === tenant.tenantid);
const saveMutation = useMutation({
mutationFn: (payload) => (payload.id ? updateTenantCustomer(payload.id, payload.data) : createTenantCustomer(payload.data)),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar(form.id ? 'Customer updated' : 'Customer added', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
queryClient.invalidateQueries({ queryKey: ['tenant-customers-all'] });
setForm(emptyCustomerForm);
setFormOpen(false);
} else {
enqueueSnackbar(res.message || 'Failed to save customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to save customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const deleteMutation = useMutation({
mutationFn: (id) => deleteTenantCustomer(id),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar('Customer deleted', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
queryClient.invalidateQueries({ queryKey: ['tenant-customers-all'] });
} else {
enqueueSnackbar(res.message || 'Failed to delete customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to delete customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const handleSave = () => {
if (!form.firstname || !form.phone) {
enqueueSnackbar('Fill Name and Phone', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return;
}
const { id, ...data } = form;
saveMutation.mutate({ id, data });
};
const openEdit = (row) => {
setForm({
id: tenantCustId(row),
firstname: row.firstname || '',
lastname: row.lastname || '',
phone: row.phone || '',
email: row.email || ''
});
setFormOpen(true);
};
const handleDelete = (row) => {
if (window.confirm(`Delete customer "${row.firstname}"?`)) deleteMutation.mutate(tenantCustId(row));
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Customers · {tenant?.tenantname}</DialogTitle>
<DialogContent dividers>
{isLoading ? (
<Typography variant="body2" sx={{ color: DT.textSecondary, py: 2 }}>
Loading customers
</Typography>
) : customers.length === 0 && !formOpen ? (
<Empty description="No customers yet" />
) : (
<Stack spacing={1.5}>
{customers.map((c) => (
<Paper key={tenantCustId(c)} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography sx={{ fontWeight: 700 }}>
{c.firstname} {c.lastname}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{[c.phone, c.email].filter(Boolean).join(' · ')}
</Typography>
</Box>
<Stack direction="row" spacing={0.5}>
<IconButton size="small" onClick={() => openEdit(c)}>
<MdEdit size={14} />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(c)} sx={{ color: '#ef4444' }}>
<MdDeleteOutline size={14} />
</IconButton>
</Stack>
</Stack>
</Paper>
))}
</Stack>
)}
{formOpen && (
<Grid container spacing={1.5} sx={{ mt: 1.5, pt: 1.5, borderTop: `1px solid ${DT.divider}` }}>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="First Name" value={form.firstname} onChange={(e) => setForm((f) => ({ ...f, firstname: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="Last Name" value={form.lastname} onChange={(e) => setForm((f) => ({ ...f, lastname: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="Phone" value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="Email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Grid>
</Grid>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{formOpen ? (
<>
<Button
onClick={() => {
setFormOpen(false);
setForm(emptyCustomerForm);
}}
>
Cancel
</Button>
<Button variant="contained" onClick={handleSave}>
{form.id ? 'Save Changes' : 'Add Customer'}
</Button>
</>
) : (
<>
<Button onClick={onClose}>Close</Button>
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setFormOpen(true)}>
Add Customer
</Button>
</>
)}
</DialogActions>
</Dialog>
);
};
// ==============================|| Starts here||============================== //
const Clients1 = () => {
const textFieldRef = useRef(null);
@@ -189,7 +553,7 @@ const Clients1 = () => {
const [status, setstatus] = useState('active');
const [selectedCustomer, setSelectedCustomer] = useState({}); // to edit
const [dialogopen, setDialogopen] = useState(false);
const [appPricing, setAppPricing] = useState([]);
const [, setAppPricing] = useState([]);
const [selectedPricing, setSelectedPricing] = useState({});
const [isPrice, setIsprice] = useState(true);
const [city, setCity] = useState('');
@@ -197,7 +561,6 @@ const Clients1 = () => {
const [latlong, setLatlong] = useState({});
const [editClient, setEditClient] = useState({});
const [page, setPage] = useState(0);
const [tabStatus, setTabStatus] = useState('Active');
const handleChangePage = (event, newPage) => {
setPage(newPage);
@@ -212,7 +575,6 @@ const Clients1 = () => {
};
const handleChange = (event, newValue) => {
setTabStatus(newValue == 0 ? 'Active' : newValue == 1 ? 'Pending' : 'InActive');
setValue0(newValue);
setSearchword('');
setPage(0);
@@ -228,6 +590,8 @@ const Clients1 = () => {
const [openRowIndex1, setOpenRowIndex1] = useState(null); // Initially no row is open for collapsible section 1
const [openRowIndex2, setOpenRowIndex2] = useState(null); // Initially no row is open for collapsible section 2
const [locationsDialogTenant, setLocationsDialogTenant] = useState(null);
const [customersDialogTenant, setCustomersDialogTenant] = useState(null);
// Handle toggling for collapsible section 1
const handleCollapseToggle1 = (rowIndex) => {
setOpenRowIndex1((prevIndex) => (prevIndex === rowIndex ? null : rowIndex));
@@ -348,18 +712,14 @@ const Clients1 = () => {
/* ============================================= || fetchclientpricelist || ============================================= */
// /admin/pricing has no tenantid query param documented — filter client-side.
const fetchclientpricelist = async () => {
await axios
.get(`${process.env.REACT_APP_URL}/tenants/getpricinglist/?tenantid=${selectedTenid}`)
.then((res) => {
console.log('getpricinglist', res);
if (res.data.status) {
setClientpricelist(res.data.details);
}
})
.catch((err) => {
try {
const pricing = await getAdminPricing();
setClientpricelist((pricing || []).filter((p) => p.tenantid === selectedTenid));
} catch (err) {
console.log(err);
});
}
};
useEffect(() => {
selectedTenid && fetchclientpricelist();
@@ -368,9 +728,8 @@ const Clients1 = () => {
/* ============================================= || fetchTenanatPricing || ============================================= */
const fetchTenanatPricing = async (id) => {
try {
let tenantPricing = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantpricing/?tenantid=${id}`);
console.log('tenantPricing', tenantPricing.data.details);
setTenanatPricing(tenantPricing.data.details);
const pricing = await getAdminPricing();
setTenanatPricing((pricing || []).filter((p) => p.tenantid === id));
} catch (error) {
console.log('fetchTenanatPricing', error);
}
@@ -382,20 +741,19 @@ const Clients1 = () => {
const tenantupdate = async (tenid) => {
setisloader(true);
let targetId;
let updateData;
if (tenid == -1) {
updateData = {
tenantid: selectedTenid,
approved: 1
};
targetId = selectedTenid;
updateData = { approved: 1 };
} else {
updateData = { tenantid: tenid, status: status === 'active' ? 'InActive' : 'Active' };
targetId = tenid;
updateData = { status: status === 'active' ? 'InActive' : 'Active' };
}
await axios
.put(`${process.env.REACT_APP_URL}/tenants/update`, updateData)
.then((res) => {
if (res.data.status) {
try {
const res = await updateAdminTenant(targetId, updateData);
if (res.success) {
opentoast(
value0 == 0 ? 'Inactivated Successfully' : value0 == 1 ? 'Approved Successfully' : 'Activate Successfully',
'success',
@@ -403,23 +761,20 @@ const Clients1 = () => {
);
getalltenantsRefetch();
summaryDataRefetch();
setisloader(false);
}
})
.catch((err) => {
} catch (err) {
console.log(err);
opentoast(err.message, 'error', 1500);
} finally {
setisloader(false);
});
// }
}
};
/* ============================================= || getAppPricing || ============================================= */
// /admin/doormile-pricing has no zone query param documented — filter client-side.
const getAppPricing = async (id) => {
console.log('id', id);
try {
let appPricingRes = await axios.get(`${process.env.REACT_APP_URL}/utils/getapppricing/?applocationid=${id}`);
console.log('appPricingRes', appPricingRes.data.details);
setAppPricing(appPricingRes.data.details);
const pricing = await getDoormilePricing();
setAppPricing((pricing || []).filter((p) => !id || p.applocationid === id));
} catch (error) {
console.log('appPricingRes', error);
}
@@ -427,25 +782,23 @@ const Clients1 = () => {
/* ============================================= || createpricing || ============================================= */
const createpricing = async () => {
setisloader(true);
await axios
.post(`${process.env.REACT_APP_URL}/tenants/createpricing`, {
tenantpricingid: 0,
try {
const res = await createAdminPricing({
applocationid: appId,
pricingid: selectedPricing.pricingid,
tenantid: selectedTenid,
pricingdate: dayjs().format('YYYY-MM-DD HH:mm:ss'),
configid: selectedPricing.configid,
pricingtypeid: selectedPricing.pricingtypeid,
slab: selectedPricing.slab,
baseprice: +selectedPricing.baseprice,
priceperkm: +selectedPricing.priceperkm,
minkm: +selectedPricing.minkm,
maxkm: +selectedPricing.maxkm,
orders: +selectedPricing.minorder,
othercharges: 0
})
.then((res) => {
if (res.data.status) {
vehicletype: selectedPricing.vehicletype || 'Bike',
baseprice: +selectedPricing.baseprice || 0,
baseweight: +selectedPricing.baseweight || 0,
priceperkm: +selectedPricing.priceperkm || 0,
priceperkg: +selectedPricing.priceperkg || 0,
basedistance: +selectedPricing.basedistance || 0,
handlingcharges: +selectedPricing.handlingcharges || 0,
effectivefrom: dayjs().format('YYYY-MM-DD'),
currency: 'INR',
priority: 1,
status: 'active'
});
if (res.success) {
enqueueSnackbar('Price Created Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
@@ -455,27 +808,28 @@ const Clients1 = () => {
setSelectedPricing({});
tenantupdate(-1);
fetchclientpricelist();
} else {
opentoast(res.message || 'Failed to save pricing', 'error', 2000);
}
})
.catch((err) => {
} catch (err) {
console.log(err);
});
opentoast(err.response?.data?.message || err.message || 'Failed to save pricing', 'error', 2000);
}
};
/* ============================================= || updateClient || ============================================= */
const updateClient = async () => {
try {
const arr = { ...editClient, tenantid: selectedTenid };
console.log('updateClient', arr);
const updateRes = await axios.put(`${process.env.REACT_APP_URL}/tenants/update`, arr);
console.log('updateClient', updateRes.data.message);
if (updateRes.data.status) {
opentoast(updateRes.data.message, 'success', 1500);
const updateRes = await updateAdminTenant(selectedTenid, editClient);
if (updateRes.success) {
opentoast(updateRes.message || 'Updated Successfully', 'success', 1500);
} else {
opentoast(updateRes.message || 'Update Failed', 'error', 1500);
}
getalltenantsRefetch();
summaryDataRefetch();
} catch (err) {
console.log('updateClient', err);
opentoast(err.response?.data?.message || err.message || 'Update Failed', 'error', 1500);
}
};
@@ -912,6 +1266,34 @@ const Clients1 = () => {
{openRowIndex1 === index ? <EyeInvisibleOutlined style={{ fontSize: 14 }} /> : <EyeOutlined style={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
<Tooltip title="Locations" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#0ea5e9'),
color: '#0ea5e9',
border: `1px solid ${edge('#0ea5e9')}`,
'&:hover': { bgcolor: '#0ea5e9', color: '#fff' }
}}
onClick={() => setLocationsDialogTenant(row)}
>
<MdPlace size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Customers" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#14b8a6'),
color: '#14b8a6',
border: `1px solid ${edge('#14b8a6')}`,
'&:hover': { bgcolor: '#14b8a6', color: '#fff' }
}}
onClick={() => setCustomersDialogTenant(row)}
>
<MdPeopleAlt size={14} />
</IconButton>
</Tooltip>
{value0 !== 1 && (
<Tooltip title={openRowIndex2 === index ? 'Close' : 'Edit'} placement="top">
<IconButton
@@ -1208,12 +1590,12 @@ const Clients1 = () => {
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>Date</TableCell>
<TableCell>Slab</TableCell>
<TableCell>Vehicle</TableCell>
<TableCell>Base Price</TableCell>
<TableCell>Min Kms</TableCell>
<TableCell>Price/Km</TableCell>
<TableCell>Other Charges</TableCell>
<TableCell>Price/Kg</TableCell>
<TableCell>Handling</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -1225,14 +1607,14 @@ const Clients1 = () => {
</TableRow>
) : (
clientpricelist.map((val, i) => (
<TableRow key={val.id || i} sx={{ bgcolor: 'white' }}>
<TableRow key={val.pricingid || i} sx={{ bgcolor: 'white' }}>
<TableCell>{i + 1}</TableCell>
<TableCell>{dayjs(val.pricingdate).format('DD-MM-YYYY')}</TableCell>
<TableCell>{val.slab}</TableCell>
<TableCell>{val.baseprice}</TableCell>
<TableCell>{val.minkm}</TableCell>
<TableCell>{val.priceperkm}</TableCell>
<TableCell>{val.othercharges}</TableCell>
<TableCell>{val.vehicletype || '—'}</TableCell>
<TableCell>{val.baseprice ?? '—'}</TableCell>
<TableCell>{val.priceperkm ?? '—'}</TableCell>
<TableCell>{val.priceperkg ?? '—'}</TableCell>
<TableCell>{val.handlingcharges ?? '—'}</TableCell>
<TableCell>{val.status || '—'}</TableCell>
</TableRow>
))
)}
@@ -1667,12 +2049,12 @@ const Clients1 = () => {
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>Date</TableCell>
<TableCell>Slab</TableCell>
<TableCell>Vehicle</TableCell>
<TableCell>Base Price</TableCell>
<TableCell>Min Kms</TableCell>
<TableCell>Price/Km</TableCell>
<TableCell>Other Charges</TableCell>
<TableCell>Price/Kg</TableCell>
<TableCell>Handling</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -1684,14 +2066,14 @@ const Clients1 = () => {
</TableRow>
) : (
clientpricelist.map((val, i) => (
<TableRow key={val.id || i} sx={{ bgcolor: 'white' }}>
<TableRow key={val.pricingid || i} sx={{ bgcolor: 'white' }}>
<TableCell>{i + 1}</TableCell>
<TableCell>{dayjs(val.pricingdate).format('DD-MM-YYYY')}</TableCell>
<TableCell>{val.slab}</TableCell>
<TableCell>{val.baseprice}</TableCell>
<TableCell>{val.minkm}</TableCell>
<TableCell>{val.priceperkm}</TableCell>
<TableCell>{val.othercharges}</TableCell>
<TableCell>{val.vehicletype || '—'}</TableCell>
<TableCell>{val.baseprice ?? '—'}</TableCell>
<TableCell>{val.priceperkm ?? '—'}</TableCell>
<TableCell>{val.priceperkg ?? '—'}</TableCell>
<TableCell>{val.handlingcharges ?? '—'}</TableCell>
<TableCell>{val.status || '—'}</TableCell>
</TableRow>
))
)}
@@ -1861,6 +2243,30 @@ const Clients1 = () => {
>
{expanded ? <EyeInvisibleOutlined style={{ fontSize: 14 }} /> : <EyeOutlined style={{ fontSize: 14 }} />}
</IconButton>
<IconButton
size="small"
sx={{
bgcolor: soft('#0ea5e9'),
color: '#0ea5e9',
border: `1px solid ${edge('#0ea5e9')}`,
'&:hover': { bgcolor: '#0ea5e9', color: '#fff' }
}}
onClick={() => setLocationsDialogTenant(row)}
>
<MdPlace size={14} />
</IconButton>
<IconButton
size="small"
sx={{
bgcolor: soft('#14b8a6'),
color: '#14b8a6',
border: `1px solid ${edge('#14b8a6')}`,
'&:hover': { bgcolor: '#14b8a6', color: '#fff' }
}}
onClick={() => setCustomersDialogTenant(row)}
>
<MdPeopleAlt size={14} />
</IconButton>
{value0 !== 1 && (
<IconButton
size="small"
@@ -1955,24 +2361,24 @@ const Clients1 = () => {
<Grid container spacing={2.5} alignItems={'center'}>
<Grid item xs={12} sm={6}>
<FormLabel>Select Slab</FormLabel>
<Autocomplete
disablePortal
id="combo-box-demo"
options={appPricing}
getOptionLabel={(option) => `${option.slab}`}
<Select
fullWidth
selectOnFocus
renderInput={(params) => <TextField {...params} label="Slab" />}
onChange={(event, value, reason) => {
setSelectedPricing(value);
console.log('pricing', value);
displayEmpty
value={selectedPricing.vehicletype || ''}
onChange={(e) => {
setSelectedPricing({ ...selectedPricing, vehicletype: e.target.value });
setIsprice(false);
if (reason === 'clear') {
setIsprice(true);
}
}}
/>
>
<MenuItem value="" disabled>
Choose vehicle type
</MenuItem>
{['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</Grid>
<Grid item xs={12} sm={6}>
@@ -2005,43 +2411,57 @@ const Clients1 = () => {
</Grid>
<Grid item xs={12} sm={6}>
<FormLabel>Min Kms</FormLabel>
<FormLabel>Base Weight (kg)</FormLabel>
<TextField
type="number"
fullWidth
value={!isPrice ? selectedPricing.minkm : 0}
value={!isPrice ? selectedPricing.baseweight : 0}
onChange={(e) => {
setSelectedPricing({
...selectedPricing,
minkm: e.target.value
baseweight: e.target.value
});
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormLabel>Max Kms</FormLabel>
<FormLabel>Price/Kg</FormLabel>
<TextField
type="number"
fullWidth
value={!isPrice ? selectedPricing.maxkm : 0}
value={!isPrice ? selectedPricing.priceperkg : 0}
onChange={(e) => {
setSelectedPricing({
...selectedPricing,
maxkm: e.target.value
priceperkg: e.target.value
});
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormLabel>Min Orders</FormLabel>
<FormLabel>Base Distance (km)</FormLabel>
<TextField
type="number"
fullWidth
value={!isPrice ? selectedPricing.minorder : 0}
value={!isPrice ? selectedPricing.basedistance : 0}
onChange={(e) => {
setSelectedPricing({
...selectedPricing,
minorder: e.target.value
basedistance: e.target.value
});
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormLabel>Handling Charges</FormLabel>
<TextField
type="number"
fullWidth
value={!isPrice ? selectedPricing.handlingcharges : 0}
onChange={(e) => {
setSelectedPricing({
...selectedPricing,
handlingcharges: e.target.value
});
}}
/>
@@ -2093,6 +2513,18 @@ const Clients1 = () => {
</Grid>
</DialogActions>
</Dialog>
<TenantLocationsDialog
tenant={locationsDialogTenant}
open={!!locationsDialogTenant}
onClose={() => setLocationsDialogTenant(null)}
/>
<TenantCustomersDialog
tenant={customersDialogTenant}
open={!!customersDialogTenant}
onClose={() => setCustomersDialogTenant(null)}
/>
</>
);
};

View File

@@ -1,75 +1,43 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
// material-ui
import { useTheme } from '@mui/material/styles';
import { Avatar, Box, Button, FormLabel, Grid, InputLabel, MenuItem, Paper, Select, Stack, TextField, Typography, useMediaQuery } from '@mui/material';
import {
Avatar,
Button,
FormControlLabel,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Switch,
TextField,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdPersonAddAlt1 } from 'react-icons/md';
// third-party
// import { PatternFormat } from 'react-number-format';
import { useMutation } from '@tanstack/react-query';
// project import
import MainCard from 'components/MainCard';
import axios from 'axios';
import AddressAutocomplete, { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { createAdminTenant } from 'pages/api/doormileApi';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
import { useNavigate } from 'react-router';
import { DT, tint } from 'themes/dt/tokens';
// const avatarImage = require.context('assets/images/users', true);
// styles & constant
// const ITEM_HEIGHT = 48;
// const ITEM_PADDING_TOP = 8;
// const MenuProps = {
// PaperProps: {
// style: {
// maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP
// }
// }
// };
const Createclient = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// const [role, setRole] = useState('');
const [mobilenumber, setMobilenumber] = useState('');
const [emailaddress, setEmailaddress] = useState('');
const [city, setCity] = useState('');
const [zipcode, setZipcode] = useState('');
const [address, setAddress] = useState('');
const [state, setState] = useState('');
const [suburb, setSuburb] = useState('');
const [latlong, setLatlong] = useState({});
const [firstname, setFirstname] = useState('');
const [doorno, setDoorno] = useState('');
const [landmark, setLandmark] = useState('');
const [tenantinfo, setTenantinfo] = useState({});
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
useEffect(() => {
// fetchprofiledetails(localStorage.getItem('appuserid'));
// fetchprofiledetails(181);
if (localStorage.getItem('tenantid')) {
fetchtenantinfo(localStorage.getItem('tenantid'));
}
}, []);
useEffect(() => {
let active = true;
geocodeAddress(address).then((place) => {
if (active && place) {
setLatlong({ lat: place.geometry.location.lat(), lng: place.geometry.location.lng() });
}
});
return () => {
active = false;
};
}, [address]);
const [tenantname, setTenantname] = useState('');
const [primaryemail, setPrimaryemail] = useState('');
const [primarycontact, setPrimarycontact] = useState('');
const [status, setStatus] = useState('active');
const [requiredeliveryotp, setRequiredeliveryotp] = useState(false);
const opentoast = (message) => {
enqueueSnackbar(message, {
@@ -77,192 +45,53 @@ const Createclient = () => {
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
// console.log(alertmessage)
};
const fetchprofiledetails = async (userid) => {
if (userid) {
setLoading(true);
try {
await axios
.get(`${process.env.REACT_APP_URL2}/tenants/getclient?id=${userid}`)
.then((res) => {
console.log(res);
if (res.data.message === 'Successful') {
let res1 = res.data.details;
setMobilenumber(res1.contactno);
setEmailaddress(res1.primaryemail);
setAddress(res1.address);
setCity(res1.city);
setZipcode(res1.postcode);
setState(res1.state);
setSuburb(res1.suburb);
setLatlong({
lat: res1.latitude,
lng: res1.longitude
});
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
} catch (err) {
console.log(err);
setLoading(false);
}
}
};
const fetchtenantinfo = async (tid) => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/tenants/gettenantinfo/?tenantid=${tid}`)
.then((res) => {
console.log(res);
if (res.data.status) {
setTenantinfo(res.data.details);
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
const handleAddressPlaceSelected = (place) => {
setAddress(place.formatted_address);
let city1, zipcode1, state1, suburb1;
for (let i = 0; i < place.address_components.length; i++) {
for (let j = 0; j < place.address_components[i].types.length; j++) {
switch (place.address_components[i].types[j]) {
case 'locality':
city1 = place.address_components[i].long_name;
break;
case 'administrative_area_level_1':
state1 = place.address_components[i].long_name;
break;
case 'postal_code':
zipcode1 = place.address_components[i].long_name;
break;
case 'sublocality':
case 'sublocality_level_1':
suburb1 = place.address_components[i].long_name;
break;
}
}
}
setCity(city1 || '');
setState(state1 || '');
setZipcode(zipcode1 || '');
setSuburb(suburb1 || '');
};
const createprofile = async () => {
console.log('res', businessname, businessno, mobilenumber, emailaddress, address, city, zipcode);
// if (!businessname) {
// opentoast('Fill Business name')
// } else if (!businessno) {
// opentoast('Fill Registration No')
// }
// else
if (!firstname) {
opentoast('Fill Full name');
} else if (!mobilenumber) {
opentoast('Fill Mobile Number');
} else if (!emailaddress) {
opentoast('Fill emailaddress');
} else if (!address) {
opentoast('Fill Address');
} else if (!city) {
opentoast('Fill City');
} else if (!zipcode) {
opentoast('Fill post code');
} else if (!suburb) {
opentoast('Fill suburb');
} else if (!latlong.lat || !latlong.lng) {
opentoast('Choose valid address');
} else {
let obj = {
customerid: 0,
configid: 1,
firstname: firstname,
applocationid: tenantinfo.applolcationid,
profileimage: '',
dialcode: '+91',
contactno: mobilenumber,
devicetype: '',
deviceid: '',
customertoken: '',
address: address,
suburb: suburb,
city: city,
state: state,
postcode: zipcode,
landmark: landmark,
doorno: doorno,
latitude: latlong.lat.toString(),
longitude: latlong.lng.toString(),
tenantid: parseInt(localStorage.getItem('tenantid')),
email: emailaddress
};
console.log(obj);
setLoading(true);
try {
await axios
.post(`${process.env.REACT_APP_URL}/customers/create`, obj)
.then((res) => {
console.log(res);
if (res.data.status) {
enqueueSnackbar(' Created Successfully ', {
const createTenantMutation = useMutation({
mutationFn: (payload) => createAdminTenant(payload),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar('Client created successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
navigate('/clients');
// setTimeout(()=>{
// fetchprofiledetails(localStorage.getItem('appuserid'));
// },2000)
} else if (res.data.message == 'Customer Already available') {
enqueueSnackbar('Customer Already available', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
navigate('/doormile/tenants');
} else {
opentoast(res.message || 'Client already exists');
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
enqueueSnackbar(err.message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
});
} catch (err) {
console.log(err);
setLoading(false);
},
onError: (err) => {
opentoast(err.response?.data?.message || err.message || 'Failed to create client');
}
});
const createprofile = () => {
if (!tenantname) {
opentoast('Fill Client / Business Name');
} else if (!primarycontact || primarycontact.length !== 10) {
opentoast('Fill a valid 10-digit Contact Number');
} else if (!primaryemail) {
opentoast('Fill Primary Email');
} else {
// POST /admin/tenants — requiredeliveryotp is a pointer server-side;
// it's opt-in and off by default, so we only send it when the operator
// explicitly turns it on rather than always sending false.
const obj = {
tenantname,
primaryemail,
primarycontact,
status,
...(requiredeliveryotp ? { requiredeliveryotp: true } : {})
};
createTenantMutation.mutate(obj);
}
};
// const [experience, setExperience] = useState('0');
// const handleChange = (event) => {
// setExperience(event.target.value);
// };
return (
<>
{loading && <Loader />}
{createTenantMutation.isPending && <Loader />}
<Grid item xs={12} sx={{ mb: 2 }}>
<Paper
@@ -288,289 +117,73 @@ const Createclient = () => {
sx={{ borderRadius: DT.radiusCard + 'px', boxShadow: DT.shadowSoft, borderColor: DT.borderSubtle }}
>
<Grid container spacing={isMobile ? 2 : 3}>
{/* <Grid item xs={12} sm={4} >
<MainCard title="Personal Information" sx={{ height: '100%' }}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Stack spacing={2.5} alignItems="center" sx={{ m: 3 }}>
<FormLabel
htmlFor="change-avtar"
sx={{
position: 'relative',
borderRadius: '50%',
overflow: 'hidden',
'&:hover .MuiBox-root': { opacity: 1 },
cursor: 'pointer'
}}
>
<Avatar alt="Avatar 1"
src={avatar}
sx={{ width: 76, height: 76 }} />
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
backgroundColor: theme.palette.mode === ThemeMode.DARK ? 'rgba(255, 255, 255, .75)' : 'rgba(0,0,0,.65)',
width: '100%',
height: '100%',
opacity: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Stack spacing={0.5} alignItems="center">
<CameraOutlined style={{ color: theme.palette.secondary.lighter, fontSize: '1.5rem' }} />
<Typography sx={{ color: 'secondary.lighter' }} variant="caption">
Upload
</Typography>
</Stack>
</Box>
</FormLabel>
<TextField
type="file"
accept="image/*"
id="change-avtar"
placeholder="Outlined"
variant="outlined"
sx={{ display: 'none' }}
onChange={(e) => setSelectedImage(e.target.files?.[0])}
/>
</Stack>
</Grid>
<Grid item xs={12}
>
</Grid>
<Grid item xs={12}
>
</Grid>
<Grid item xs={12}
>
</Grid>
<Grid item xs={12}
>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-role-name">Role</InputLabel>
<TextField fullWidth
id="personal-role-name" placeholder="Role Name" autoFocus
onChange={(e) => setRole(e.target.value)}
value={role}
autoComplete='off'
/>
</Stack>
</Grid>
</Grid>
</MainCard>
</Grid> */}
<Grid
item
xs={12}
// sm={8}
>
<MainCard
// title="Contact Information"
sx={{ height: '100%' }}
contentSX={{ p: { xs: 1.5, md: 2.5 } }}
>
<MainCard sx={{ height: '100%' }} contentSX={{ p: { xs: 1.5, md: 2.5 } }}>
<Grid container spacing={isMobile ? 2 : 3}>
{/* <Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-first-name">Business Name</InputLabel>
<TextField fullWidth
id="personal-first-name" placeholder="Business Name" autoFocus
onChange={(e) => setBusinessname(e.target.value)}
value={businessname}
autoComplete='off'
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-last-name">Registration No</InputLabel>
<TextField fullWidth
id="personal-last-name" placeholder="Registration No"
onChange={(e) => setBusinessno(e.target.value)}
value={businessno}
autoComplete='off'
/>
</Stack>
</Grid> */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-last-name">Admin Name</InputLabel>
<InputLabel htmlFor="tenant-name">Client / Business Name</InputLabel>
<TextField
fullWidth
id="personal-last-name"
placeholder="Name"
onChange={(e) => setFirstname(e.target.value)}
value={firstname}
id="tenant-name"
placeholder="e.g. DailyGrubs"
onChange={(e) => setTenantname(e.target.value)}
value={tenantname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}></Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-phone">Phone Number</InputLabel>
<InputLabel htmlFor="tenant-status">Status</InputLabel>
<Select id="tenant-status" fullWidth value={status} onChange={(e) => setStatus(e.target.value)}>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="tenant-contact">Primary Contact Number</InputLabel>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Select defaultValue="+1" disabled sx={{ cursor: 'not-allowed' }}>
<MenuItem value="+1">+91</MenuItem>
</Select>
<TextField
type="number"
id="personal-phone"
// format="##########"
// mask="_"
id="tenant-contact"
fullWidth
// customInput={TextField}
placeholder="Phone Number"
// defaultValue="8654239581"
// onBlur={() => { }}
placeholder="Contact Number"
onChange={(e) => {
if (e.target.value.toString().length <= 10) {
setMobilenumber(e.target.value);
setPrimarycontact(e.target.value);
}
}}
value={mobilenumber}
value={primarycontact}
autoComplete="off"
// disabled
sx={{ cursor: 'not-allowed' }}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-email">Email Address</InputLabel>
<InputLabel htmlFor="tenant-email">Primary Email</InputLabel>
<TextField
type="email"
fullWidth
// defaultValue="stebin.ben@gmail.com"
id="personal-email"
placeholder="Email Address"
onChange={(e) => setEmailaddress(e.target.value)}
value={emailaddress}
id="tenant-email"
placeholder="Primary Email Address"
onChange={(e) => setPrimaryemail(e.target.value)}
value={primaryemail}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-address">Address</InputLabel>
<AddressAutocomplete
id="personal-address"
fullWidth
placeholder="Address"
value={address}
onChange={setAddress}
onPlaceSelected={handleAddressPlaceSelected}
<FormControlLabel
control={<Switch checked={requiredeliveryotp} onChange={(e) => setRequiredeliveryotp(e.target.checked)} />}
label="Require delivery OTP for this client (off by default)"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">Suburb</InputLabel>
<TextField
fullWidth
// defaultValue="New York"
id="personal-location"
placeholder="Location"
onChange={(e) => setSuburb(e.target.value)}
value={suburb}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-zipcode">City</InputLabel>
<TextField
fullWidth
// defaultValue="956754"
// type='number'
id="personal-zipcode"
placeholder="City"
onChange={(e) => setCity(e.target.value)}
value={city}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">State</InputLabel>
<TextField
fullWidth
// defaultValue="New York"
id="personal-location"
placeholder="State"
onChange={(e) => setState(e.target.value)}
value={state}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-zipcode">Post Code</InputLabel>
<TextField
fullWidth
// defaultValue="956754"
type="number"
id="personal-zipcode"
placeholder="Zipcode"
onChange={(e) => setZipcode(e.target.value)}
value={zipcode}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">Door No</InputLabel>
<TextField
fullWidth
// defaultValue="New York"
id="personal-location"
placeholder="Door No"
onChange={(e) => setDoorno(e.target.value)}
value={doorno}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-email">Landmark</InputLabel>
<TextField
type="email"
fullWidth
// defaultValue="stebin.ben@gmail.com"
id="personal-email"
placeholder="Landmark"
onChange={(e) => setLandmark(e.target.value)}
value={landmark}
autoComplete="off"
/>
</Stack>
</Grid>
</Grid>
</MainCard>
@@ -582,7 +195,7 @@ const Createclient = () => {
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={2}
>
<Button variant="contained" onClick={() => createprofile()} fullWidth={isMobile}>
<Button variant="contained" onClick={createprofile} fullWidth={isMobile}>
Create
</Button>
</Stack>

View File

@@ -5,7 +5,7 @@ import { useState, useEffect, Fragment, useRef, useMemo } from 'react';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import axios from 'axios';
import { notifyMiler } from 'pages/api/doormileApi';
import { useTheme } from '@mui/material/styles';
import {
MdOutlineDateRange,
@@ -69,15 +69,7 @@ import {
} from '@mui/material';
import { PopupTransition } from 'components/@extended/Transitions';
import {
addDays,
addMonths,
addWeeks,
endOfMonth,
endOfWeek,
startOfMonth,
startOfWeek
} from 'date-fns';
import { addDays, addMonths, addWeeks, endOfMonth, endOfWeek, startOfMonth, startOfWeek } from 'date-fns';
import { DateRangePicker } from 'components/nearle_components/DateRangePicker';
import * as React from 'react';
import Loader from 'components/Loader';
@@ -152,7 +144,15 @@ const KPI_META = [
const BATCH_OPTIONS = [
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
{ id: 'afternoon', label: 'Afternoon Batch', range: '9 AM to 12:30 PM', color: '#f59e0b', iconKey: 'afternoon', startHour: 9, endHour: 12.5 },
{
id: 'afternoon',
label: 'Afternoon Batch',
range: '9 AM to 12:30 PM',
color: '#f59e0b',
iconKey: 'afternoon',
startHour: 9,
endHour: 12.5
},
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
];
@@ -359,19 +359,9 @@ const Deliveries = () => {
const cancelridernotification = async () => {
console.log('cancelridernotification', selectedRow);
try {
const response = await axios.post(`${process.env.REACT_APP_URL}/utils/notifyuser`, {
token: selectedRow.userfcmtoken,
notification: {
title: 'DoormileXpress',
body: `${selectedRow.orderid} have been Cancelled`,
sound: 'ring',
image: ''
},
data: {
type: 'cancel'
}
});
return response.data;
// /admin/milers/:id/notify keys off milerprofileid, not the userid
// stored on the booking (confirmed live) — see fetchDeliveries in api.js.
return await notifyMiler(selectedRow.milerprofileid, 'DoormileXpress', `${selectedRow.orderid} have been Cancelled`);
} catch (err) {
opentoast(err.message, 'error', 2000);
console.log(err);
@@ -428,14 +418,20 @@ const Deliveries = () => {
onSuccess: (res, { selectedRider, selectedRow }) => {
setLoading1(false);
setChangeDialogOpen(false);
if (res.data.message === 'Success') {
logger.info(`Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.firstname} ${selectedRider?.lastname}`);
if (res.success) {
logger.info(
`Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname}`
);
opentoast('Rider Changed Successfully', 'success');
}
fetchCountRefetch(); // Refresh count data
fetchDeliveriesRefetch(); // Refresh deliveries
countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips
notifyRiderMutation.mutate(selectedRider.userfcmtoken);
// notifyMiler wants a miler profile id, not an FCM token — the new API
// looks the device token up server-side. selectedRider comes straight
// from getMilers(), which has milerprofileid directly (confirmed live;
// .userid there is the underlying app-user id, a different resource).
notifyRiderMutation.mutate(selectedRider.milerprofileid);
},
onError: (err, { selectedRider, selectedRow }) => {
logger.error(`Failed to change rider for order ID ${selectedRow?.orderid}:`, err);
@@ -564,27 +560,12 @@ const Deliveries = () => {
isLoading: countSourceIsLoading,
refetch: countSourceRefetch
} = useInfiniteQuery({
queryKey: [
'fetchdeliveries-batchcounts',
appId,
userid,
'all',
startdate,
enddate,
200,
'',
tenantid,
locationid,
riderid
],
queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid],
queryFn: fetchDeliveries,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
});
const countSourceRows = useMemo(
() => (countSourceData?.pages || []).flatMap((p) => p.rows || []),
[countSourceData]
);
const countSourceRows = useMemo(() => (countSourceData?.pages || []).flatMap((p) => p.rows || []), [countSourceData]);
useEffect(() => {
if (countHasNext && !countIsFetchingNext) countFetchNext();
@@ -606,7 +587,9 @@ const Deliveries = () => {
// fields. Server-side search isn't needed since every row is loaded.
const filteredRows = useMemo(() => {
const wantStatus = String(currentStatus || '').toLowerCase();
const q = String(debouncedSearch || '').trim().toLowerCase();
const q = String(debouncedSearch || '')
.trim()
.toLowerCase();
return countSourceRows.filter((r) => {
if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false;
const s = String(r.orderstatus || '').toLowerCase();
@@ -785,7 +768,7 @@ const Deliveries = () => {
mutationFn: (orderData) => updateDeliveryAPI(orderData),
onSuccess: (res) => {
console.log(res);
if (res.data.status) {
if (res.success) {
opentoast('Updated Successfully', 'success');
setDeliveryamount('');
setNotes('');
@@ -821,11 +804,7 @@ const Deliveries = () => {
return (
<>
{(fetchCountIsLoading ||
countSourceIsLoading ||
fetchtenantsIsLoading ||
fetchlocationsIsLoading ||
riderListIsLoading) && (
{(fetchCountIsLoading || countSourceIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading) && (
<>
<Loader />
{/* <CircularLoader /> */}
@@ -837,13 +816,7 @@ const Deliveries = () => {
color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1
}}
open={
fetchCountIsLoading ||
fetchDeliveriesIsLoading ||
fetchtenantsIsLoading ||
fetchlocationsIsLoading ||
riderListIsLoading
}
open={fetchCountIsLoading || fetchDeliveriesIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading}
>
{/* <CircularLoader color="inherit" /> */}
</Backdrop>
@@ -874,9 +847,21 @@ const Deliveries = () => {
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
{[
{ ...KPI_META[0], value: batchTotals.all, percentage: null },
{ ...KPI_META[1], value: countData?.uncoveredLength, percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0 },
{ ...KPI_META[2], value: countData?.coveredLength, percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0 },
{ ...KPI_META[3], value: countData?.cancelLength, percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0 }
{
...KPI_META[1],
value: countData?.uncoveredLength,
percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0
},
{
...KPI_META[2],
value: countData?.coveredLength,
percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0
},
{
...KPI_META[3],
value: countData?.cancelLength,
percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0
}
].map((item) => {
const Icon = item.icon;
const pct = typeof item.percentage === 'number' ? item.percentage : Number(item.percentage);
@@ -919,12 +904,7 @@ const Deliveries = () => {
</Typography>
</Stack>
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1.25} sx={{ flexWrap: 'wrap' }}>
<Stack
direction="row"
flexWrap="wrap"
gap={1.5}
alignItems="center"
>
<Stack direction="row" flexWrap="wrap" gap={1.5} alignItems="center">
{/* Batch dropdown — replaces the legacy "Deliveries-{datestatus}" and
"Orders-All" pills. Filters the loaded rows by the slot the
operator picks; defaults to 'all'. Mirrors the dispatch page's
@@ -982,12 +962,7 @@ const Deliveries = () => {
<Icon size={20} />
</Avatar>
<Stack direction="column" spacing={0} flex={1} minWidth={0}>
<Typography
variant="body2"
fontWeight={700}
color="#0f172a"
noWrap
>
<Typography variant="body2" fontWeight={700} color="#0f172a" noWrap>
{option.label}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
@@ -1042,12 +1017,7 @@ const Deliveries = () => {
InputProps={{
...params.InputProps,
startAdornment: (
<Stack
direction="row"
alignItems="center"
spacing={0.75}
sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}
>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}>
<Avatar
sx={{
width: 24,
@@ -1113,7 +1083,6 @@ const Deliveries = () => {
</Typography>
</Stack>
)}
</Stack>
{/* Tenant */}
@@ -1152,7 +1121,9 @@ const Deliveries = () => {
...params.InputProps,
startAdornment: (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}>
<AccentAvatar color="#94a3b8"><MdStorefront size={14} /></AccentAvatar>
<AccentAvatar color="#94a3b8">
<MdStorefront size={14} />
</AccentAvatar>
</Stack>
)
}}
@@ -1196,7 +1167,9 @@ const Deliveries = () => {
...params.InputProps,
startAdornment: (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}>
<AccentAvatar color="#94a3b8"><MdLocationOn size={14} /></AccentAvatar>
<AccentAvatar color="#94a3b8">
<MdLocationOn size={14} />
</AccentAvatar>
</Stack>
)
}}
@@ -1209,7 +1182,7 @@ const Deliveries = () => {
disabled={riderListIsLoading}
options={ridersList}
PaperComponent={SoftPaper}
getOptionLabel={(option) => `${option.firstname} ${option.lastname} (${option.contactno})`}
getOptionLabel={(option) => `${option.displayname || option.authname || ''} (${option.contactno || ''})`}
sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }}
onChange={(e, value, reason) => {
if (reason === 'clear') setRiderid(0);
@@ -1230,7 +1203,9 @@ const Deliveries = () => {
...params.InputProps,
startAdornment: (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}>
<AccentAvatar color="#94a3b8"><MdDirectionsBike size={14} /></AccentAvatar>
<AccentAvatar color="#94a3b8">
<MdDirectionsBike size={14} />
</AccentAvatar>
</Stack>
)
}}
@@ -1255,13 +1230,7 @@ const Deliveries = () => {
background: '#fff'
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={1.5}
sx={{ flexWrap: 'wrap-reverse' }}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1.5} sx={{ flexWrap: 'wrap-reverse' }}>
<Stack
direction="row"
spacing={0.75}
@@ -1417,7 +1386,9 @@ const Deliveries = () => {
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center', px: 2 }}>
{selectedBatch === 'all'
? `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders for this filter.`
: `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'}.`}
: `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders in ${
BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'
}.`}
</Typography>
</Stack>
)}
@@ -1438,7 +1409,15 @@ const Deliveries = () => {
const RowStatusIcon = rowStatusMeta.icon;
const isSelected = !!deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid);
const isOpen = productCollapse?.orderid === row?.orderid;
const chipSx = (c) => ({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: c, fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap' });
const chipSx = (c) => ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: c,
fontWeight: 700,
fontSize: 12,
whiteSpace: 'nowrap'
});
return (
<MobileCard
key={row.orderheaderid ?? `${row.tenantname}-${index}`}
@@ -1479,7 +1458,16 @@ const Deliveries = () => {
direction="row"
alignItems="center"
spacing={0.5}
sx={{ display: 'inline-flex', pl: 0.5, pr: 1, py: 0.25, borderRadius: 999, bgcolor: tint(rowStatusMeta.color), border: `1px solid ${edge(rowStatusMeta.color)}`, color: rowStatusMeta.color }}
sx={{
display: 'inline-flex',
pl: 0.5,
pr: 1,
py: 0.25,
borderRadius: 999,
bgcolor: tint(rowStatusMeta.color),
border: `1px solid ${edge(rowStatusMeta.color)}`,
color: rowStatusMeta.color
}}
>
<AccentAvatar color={rowStatusMeta.color} size={18}>
<RowStatusIcon size={11} />
@@ -1502,7 +1490,13 @@ const Deliveries = () => {
setOrderHeaderId(row.orderheaderid);
}
}}
sx={{ borderRadius: 999, bgcolor: tint('#06b6d4'), color: '#06b6d4', border: `1px solid ${edge('#06b6d4')}`, '&:hover': { bgcolor: soft('#06b6d4') } }}
sx={{
borderRadius: 999,
bgcolor: tint('#06b6d4'),
color: '#06b6d4',
border: `1px solid ${edge('#06b6d4')}`,
'&:hover': { bgcolor: soft('#06b6d4') }
}}
>
{isOpen ? <KeyboardArrowUpOutlined fontSize="small" /> : <KeyboardArrowDownOutlined fontSize="small" />}
</IconButton>
@@ -1511,7 +1505,13 @@ const Deliveries = () => {
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, row)}
sx={{ borderRadius: 999, bgcolor: tint('#C01227'), color: '#C01227', border: `1px solid ${edge('#C01227')}`, '&:hover': { bgcolor: soft('#C01227') } }}
sx={{
borderRadius: 999,
bgcolor: tint('#C01227'),
color: '#C01227',
border: `1px solid ${edge('#C01227')}`,
'&:hover': { bgcolor: soft('#C01227') }
}}
>
<EditOutlined />
</IconButton>
@@ -1575,7 +1575,10 @@ const Deliveries = () => {
</Typography>
)}
</MobileField>
<MobileField label="ETA" value={row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : '—'} />
<MobileField
label="ETA"
value={row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : '—'}
/>
<MobileField label="Transit">
<Box sx={chipSx('#06b6d4')}>{row.transitminutes || 0}m</Box>
</MobileField>
@@ -1601,44 +1604,66 @@ const Deliveries = () => {
{row.step ? (
<Box sx={{ ...chipSx('#C01227'), minWidth: 30, fontWeight: 800 }}>{row.step}</Box>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
</Typography>
)}
</MobileField>
{row.notes && (
<MobileField label="Notes" full>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>{row.notes}</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.notes}
</Typography>
</MobileField>
)}
</MobileFieldGrid>
{isOpen && (
<Box sx={{ mt: 1.5, p: 1.25, borderRadius: 2, bgcolor: DT.surfaceAlt, border: `1px solid ${DT.divider}` }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<AccentAvatar color="#C01227" size={22}><MdInventory2 size={12} /></AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: 0.5, textTransform: 'uppercase', color: '#C01227' }}>
<AccentAvatar color="#C01227" size={22}>
<MdInventory2 size={12} />
</AccentAvatar>
<Typography
variant="caption"
sx={{ fontWeight: 800, letterSpacing: 0.5, textTransform: 'uppercase', color: '#C01227' }}
>
Product Details
</Typography>
</Stack>
<Stack spacing={1}>
{orderdetails?.details?.map((product, idx2) => (
<Stack key={idx2} direction="row" alignItems="center" spacing={1.25} sx={{ p: 1, borderRadius: 1.5, bgcolor: '#fff', border: `1px solid ${DT.divider}` }}>
<Box component="img" src={product?.productimage || 'https://via.placeholder.com/40'} alt={product?.productname} sx={{ width: 36, height: 36, objectFit: 'cover', borderRadius: 1.5, border: `1px solid ${DT.divider}`, flexShrink: 0 }} />
{/* GET /admin/bookings has no product image/qty/tax breakdown — this
renders the real "parcels" array (itemcategory/itemdescription/
declaredvalue) instead of the old product-catalog shape. */}
{orderdetails?.parcels?.map((parcel, idx2) => (
<Stack
key={idx2}
direction="row"
alignItems="center"
spacing={1.25}
sx={{ p: 1, borderRadius: 1.5, bgcolor: '#fff', border: `1px solid ${DT.divider}` }}
>
<AccentAvatar color="#C01227" size={36}>
<MdInventory2 size={16} />
</AccentAvatar>
<Stack sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DT.textPrimary }} noWrap>
{product?.productname || 'Unnamed'}
{parcel?.itemdescription || parcel?.itemcategory || 'Item'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
Qty {product?.orderqty || 0} · {product?.price || 0}
{parcel?.itemcategory || ''} {parcel?.weight ? `· ${parcel.weight}kg` : ''}
</Typography>
</Stack>
<Typography variant="body2" sx={{ fontWeight: 800, color: DT.textPrimary, flexShrink: 0 }}>
{(product?.productsumprice + product?.taxamount).toFixed(2) || 0}
{(parcel?.declaredvalue || 0).toFixed(2)}
</Typography>
</Stack>
))}
<Stack direction="row" justifyContent="space-between" sx={{ pt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textSecondary }}>Total Amount</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textSecondary }}>
Delivery Charge
</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#10b981' }}>
{orderdetails?.pricedetails?.orderamount?.toFixed(2)}
{(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)}
</Typography>
</Stack>
</Stack>
@@ -1726,7 +1751,9 @@ const Deliveries = () => {
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{selectedBatch === 'all'
? `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders for this filter.`
: `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'}.`}
: `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders in ${
BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'
}.`}
</Typography>
</Stack>
</TableCell>
@@ -1890,7 +1917,9 @@ const Deliveries = () => {
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.pickupcustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>{row.pickupcontactno}</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.Pickupaddress} sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
{row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')}
@@ -1904,7 +1933,9 @@ const Deliveries = () => {
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.deliverycustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>{row.deliverycontactno}</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.deliverycontactno}
</Typography>
<Tooltip title={row.deliveryaddress}>
<Typography variant="caption" sx={{ color: DT.textMuted, whiteSpace: 'nowrap' }}>
{row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')}
@@ -1964,12 +1995,44 @@ const Deliveries = () => {
<TableCell>
<Stack direction="column" spacing={0.5} alignItems="flex-start">
<Tooltip title="Planned KMS" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#ef4444'), color: '#ef4444', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#ef4444')}`, whiteSpace: 'nowrap', minWidth: 75 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 1.25,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#ef4444'),
color: '#ef4444',
fontWeight: 700,
fontSize: 11,
border: `1px solid ${edge('#ef4444')}`,
whiteSpace: 'nowrap',
minWidth: 75
}}
>
{row.kms || 0} km
</Box>
</Tooltip>
<Tooltip title="Actual KMS" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#10b981'), color: '#10b981', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#10b981')}`, whiteSpace: 'nowrap', minWidth: 75 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 1.25,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#10b981'),
color: '#10b981',
fontWeight: 700,
fontSize: 11,
border: `1px solid ${edge('#10b981')}`,
whiteSpace: 'nowrap',
minWidth: 75
}}
>
{row.cumulativekms || 0} km
</Box>
</Tooltip>
@@ -1979,12 +2042,44 @@ const Deliveries = () => {
<TableCell align="left">
<Stack direction="column" spacing={0.5} alignItems="flex-start">
<Tooltip title="Delivery Charge" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#ef4444'), color: '#ef4444', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#ef4444')}`, whiteSpace: 'nowrap', minWidth: 85 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 1.25,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#ef4444'),
color: '#ef4444',
fontWeight: 700,
fontSize: 11,
border: `1px solid ${edge('#ef4444')}`,
whiteSpace: 'nowrap',
minWidth: 85
}}
>
{row.deliverycharges?.toFixed(2) ?? '0.00'}
</Box>
</Tooltip>
<Tooltip title="Delivery Amount" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#10b981'), color: '#10b981', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#10b981')}`, whiteSpace: 'nowrap', minWidth: 85 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 1.25,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#10b981'),
color: '#10b981',
fontWeight: 700,
fontSize: 11,
border: `1px solid ${edge('#10b981')}`,
whiteSpace: 'nowrap',
minWidth: 85
}}
>
{row.deliveryamt?.toFixed(2) ?? '0.00'}
</Box>
</Tooltip>
@@ -1994,12 +2089,24 @@ const Deliveries = () => {
<TableCell>
{row.notes ? (
<Tooltip title={row.notes}>
<Typography variant="caption" sx={{ color: DT.textSecondary, maxWidth: 160, display: 'inline-block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography
variant="caption"
sx={{
color: DT.textSecondary,
maxWidth: 160,
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{row.notes}
</Typography>
</Tooltip>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
</Typography>
)}
</TableCell>
{/* step */}
@@ -2024,12 +2131,17 @@ const Deliveries = () => {
{row.step}
</Box>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
</Typography>
)}
</TableCell>
{/* qty */}
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: row.Quantity ? DT.textPrimary : DT.textMuted, whiteSpace: 'nowrap' }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: row.Quantity ? DT.textPrimary : DT.textMuted, whiteSpace: 'nowrap' }}
>
{row.Quantity || '—'}
</Typography>
</TableCell>
@@ -2071,7 +2183,11 @@ const Deliveries = () => {
'&:hover': { bgcolor: soft('#06b6d4') }
}}
>
{productCollapse?.orderid === row.orderid ? <KeyboardArrowUpOutlined fontSize="small" /> : <KeyboardArrowDownOutlined fontSize="small" />}
{productCollapse?.orderid === row.orderid ? (
<KeyboardArrowUpOutlined fontSize="small" />
) : (
<KeyboardArrowDownOutlined fontSize="small" />
)}
</IconButton>
</Tooltip>
)}
@@ -2117,9 +2233,19 @@ const Deliveries = () => {
background: '#fff'
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 2, py: 1.25, borderBottom: `1px solid ${DT.divider}`, bgcolor: tint('#C01227') }}>
<AccentAvatar color="#C01227"><MdInventory2 size={14} /></AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: 0.5, textTransform: 'uppercase', color: '#C01227' }}>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: `1px solid ${DT.divider}`, bgcolor: tint('#C01227') }}
>
<AccentAvatar color="#C01227">
<MdInventory2 size={14} />
</AccentAvatar>
<Typography
variant="caption"
sx={{ fontWeight: 800, letterSpacing: 0.5, textTransform: 'uppercase', color: '#C01227' }}
>
Product Details
</Typography>
</Stack>
@@ -2139,51 +2265,53 @@ const Deliveries = () => {
}}
>
<TableCell>#</TableCell>
<TableCell>Product</TableCell>
<TableCell>Description</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="right">Cost</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Tax</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell>Item</TableCell>
<TableCell>Category</TableCell>
<TableCell align="right">Weight</TableCell>
<TableCell align="right">Declared Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orderdetails?.details?.map((product, idx2) => (
<TableRow
key={idx2}
sx={{ '&:hover': { backgroundColor: DT.surfaceAlt + ' !important' } }}
>
<TableCell sx={{ color: DT.textMuted, fontWeight: 700 }}>{String(idx2 + 1).padStart(2, '0')}</TableCell>
{/* GET /admin/bookings has no product image/qty/tax breakdown —
this renders the real "parcels" array instead of the old
product-catalog shape. */}
{orderdetails?.parcels?.map((parcel, idx2) => (
<TableRow key={idx2} sx={{ '&:hover': { backgroundColor: DT.surfaceAlt + ' !important' } }}>
<TableCell sx={{ color: DT.textMuted, fontWeight: 700 }}>
{String(idx2 + 1).padStart(2, '0')}
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.25} alignItems="center">
<Box
component="img"
src={product?.productimage || 'https://via.placeholder.com/40'}
alt={product?.productname}
sx={{ width: 36, height: 36, objectFit: 'cover', borderRadius: 1.5, border: `1px solid ${DT.divider}` }}
/>
<AccentAvatar color="#C01227" size={28}>
<MdInventory2 size={13} />
</AccentAvatar>
<Typography variant="body2" sx={{ fontWeight: 600, color: DT.textPrimary }} noWrap>
{product?.productname || 'Unnamed'}
{parcel?.itemdescription || 'Item'}
</Typography>
</Stack>
</TableCell>
<TableCell sx={{ color: DT.textSecondary }}>{product?.productdescription || '-'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700 }}>{product?.orderqty || 0}</TableCell>
<TableCell align="right"> {product?.price || 0}</TableCell>
<TableCell align="right"> {(product?.productsumprice ?? 0).toFixed(2)}</TableCell>
<TableCell align="right"> {(product?.taxamount ?? 0).toFixed(2)}</TableCell>
<TableCell sx={{ color: DT.textSecondary }}>{parcel?.itemcategory || '-'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700 }}>
{parcel?.weight ? `${parcel.weight}kg` : '-'}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, color: DT.textPrimary }}>
{(product?.productsumprice + product?.taxamount).toFixed(2) || 0}
{(parcel?.declaredvalue || 0).toFixed(2)}
</TableCell>
</TableRow>
))}
<TableRow sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}>
<TableCell align="right" colSpan={7} sx={{ fontWeight: 700, color: DT.textSecondary, borderTop: `1px solid ${DT.divider}` }}>
Total Amount
<TableCell
align="right"
colSpan={4}
sx={{ fontWeight: 700, color: DT.textSecondary, borderTop: `1px solid ${DT.divider}` }}
>
Delivery Charge
</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, color: '#10b981', borderTop: `1px solid ${DT.divider}` }}>
{orderdetails?.pricedetails?.orderamount?.toFixed(2)}
<TableCell
align="right"
sx={{ fontWeight: 800, color: '#10b981', borderTop: `1px solid ${DT.divider}` }}
>
{(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)}
</TableCell>
</TableRow>
</TableBody>
@@ -2201,7 +2329,11 @@ const Deliveries = () => {
<TableRow>
<TableCell colSpan={totalCols} rowSpan={3}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{countIsFetchingNext ? <LoaderWithImage /> : countHasNext ? <LoaderWithImage /> : (
{countIsFetchingNext ? (
<LoaderWithImage />
) : countHasNext ? (
<LoaderWithImage />
) : (
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 600 }}>
· End of list ·
</Typography>
@@ -2250,11 +2382,13 @@ const Deliveries = () => {
{selectedRow?.orderstatus !== 'delivered' && (
<MenuItem
onClick={() => {
notifyRiderMutation.mutate(selectedRow.userfcmtoken);
notifyRiderMutation.mutate(selectedRow.milerprofileid);
handleMenuClose();
}}
>
<AccentAvatar color="#0ea5e9" size={22}><MdNotificationsActive size={13} /></AccentAvatar>
<AccentAvatar color="#0ea5e9" size={22}>
<MdNotificationsActive size={13} />
</AccentAvatar>
Notify Rider
</MenuItem>
)}
@@ -2270,7 +2404,9 @@ const Deliveries = () => {
handleMenuClose();
}}
>
<AccentAvatar color="#8b5cf6" size={22}><MdDirectionsBike size={13} /></AccentAvatar>
<AccentAvatar color="#8b5cf6" size={22}>
<MdDirectionsBike size={13} />
</AccentAvatar>
Change Rider
</MenuItem>
)}
@@ -2282,14 +2418,16 @@ const Deliveries = () => {
setDeliverylat(selectedRow.droplat);
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamount);
setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
}}
>
<AccentAvatar color="#10b981" size={22}><MdCheckCircle size={13} /></AccentAvatar>
<AccentAvatar color="#10b981" size={22}>
<MdCheckCircle size={13} />
</AccentAvatar>
Update Status
</MenuItem>
)}
@@ -2301,7 +2439,9 @@ const Deliveries = () => {
handleMenuClose();
}}
>
<AccentAvatar color="#ef4444" size={22}><MdCancel size={13} /></AccentAvatar>
<AccentAvatar color="#ef4444" size={22}>
<MdCancel size={13} />
</AccentAvatar>
Cancel Delivery
</MenuItem>
)}
@@ -2438,7 +2578,7 @@ const Deliveries = () => {
fullWidth
options={ridersList}
PaperComponent={SoftPaper}
getOptionLabel={(option) => `${option.firstname} ${option.lastname} (${option.contactno})`}
getOptionLabel={(option) => `${option.displayname || option.authname || ''} (${option.contactno || ''})`}
onChange={(e, value) => {
setSelectedRider(value);
logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None');
@@ -2451,7 +2591,9 @@ const Deliveries = () => {
...params.InputProps,
startAdornment: (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}>
<AccentAvatar color="#8b5cf6"><MdDirectionsBike size={14} /></AccentAvatar>
<AccentAvatar color="#8b5cf6">
<MdDirectionsBike size={14} />
</AccentAvatar>
</Stack>
)
}}
@@ -2482,7 +2624,9 @@ const Deliveries = () => {
variant="contained"
onClick={() => {
setLoading1(true);
logger.info(`Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${selectedRider?.firstname} ${selectedRider?.lastname}`);
logger.info(
`Initiating rider assignment change for order ID ${selectedRow?.orderid} to rider: ${selectedRider?.displayname || selectedRider?.authname}`
);
changeRiderMutation.mutate({ selectedRider, selectedRow });
}}
sx={{
@@ -2667,7 +2811,10 @@ const Deliveries = () => {
{ label: 'Delivery Longitude', value: deliverylong, set: (v) => setDeliverylong(v), color: '#06b6d4' }
].map((f) => (
<Grid item xs={12} sm={6} key={f.label}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}
>
{f.label}
</Typography>
<TextField
@@ -2690,7 +2837,10 @@ const Deliveries = () => {
</Grid>
))}
<Grid item xs={12}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}
>
Amount
</Typography>
<TextField
@@ -2712,7 +2862,10 @@ const Deliveries = () => {
/>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}
>
Status
</Typography>
<TextField
@@ -2738,7 +2891,9 @@ const Deliveries = () => {
const Ic = m.icon;
return (
<MenuItem key={s} value={s} sx={{ gap: 1 }}>
<AccentAvatar color={m.color} size={22}><Ic size={13} /></AccentAvatar>
<AccentAvatar color={m.color} size={22}>
<Ic size={13} />
</AccentAvatar>
{m.label}
</MenuItem>
);
@@ -2746,7 +2901,10 @@ const Deliveries = () => {
</TextField>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: DT.textSecondary, letterSpacing: 0.5, textTransform: 'uppercase' }}
>
Notes
</Typography>
<TextField

View File

@@ -9,7 +9,6 @@ import 'leaflet/dist/leaflet.css';
import '../../../utils/leafletPolylineOffset';
import dayjs from 'dayjs';
import { useInfiniteQuery, useQueries, useQuery, useMutation } from '@tanstack/react-query';
import axios from 'axios';
import {
MdMap,
MdDirectionsBike,
@@ -58,6 +57,7 @@ import {
import ProfitabilitySection from './ProfitabilitySection';
import ActiveSection from './ActiveSection';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
import { getConsignmentLogs } from 'pages/api/doormileApi';
import {
STATUS_STYLES,
getStatusStyle,
@@ -1071,9 +1071,11 @@ const Dispatch = ({
return () => window.removeEventListener('keydown', onKey);
}, [riderPositionModal]);
// TODO: wire to real tenant context once the standalone Dispatch screen
// surfaces it. 916 matches the example tenant in the API spec.
const ANALYSIS_TENANT_ID = 916;
// Was hardcoded to 916 (the example tenant in the API spec) — every
// operator's batch-efficiency analytics queried the same tenant regardless
// of who was actually logged in. Reads the real session tenant now, same
// as the rest of the app (login.js sets this on sign-in).
const ANALYSIS_TENANT_ID = localStorage.getItem('tenantid') || undefined;
const batchEfficiencyMutation = useMutation({
mutationFn: fetchBatchEfficiency,
@@ -1517,11 +1519,12 @@ const Dispatch = ({
plannedMapRendererRef.current = L.canvas({ padding: 1.5, tolerance: 5 });
}
// Pull the partners/getriderlogs feed for the currently selected hub + date.
// This endpoint returns the exact live GPS position for every rider at the
// hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page.
// Live rider position feed. Was dead for a while during the backend
// migration (fetchRidersLogs was a permanent [] stub) — now sourced from
// GET /admin/milers/summary (confirmed in jupiter2doormile.md), which
// carries each rider's currentlatitude/currentlongitude/lastpingat. We
// render those positions as markers on the main dispatch map so the
// operator sees where each rider actually is.
const RIDER_LOG_POLL_MS = 1000;
const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''],
@@ -2197,17 +2200,20 @@ const Dispatch = ({
queries: focusedRiderDeliveryIds.map((deliveryid) => ({
queryKey: ['deliveryLogs', deliveryid],
queryFn: async () => {
const res = await axios.get(
`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${deliveryid}`
);
// Accept several possible response shapes — the live API has shipped
// {details:[…]}, plain arrays, and {data:[…]} variants over time, and
// any of those should produce a polyline. Pick the first non-empty
// array-like found.
const candidates = [res?.data?.details, res?.data?.data, res?.data, res];
const rows = candidates.find((c) => Array.isArray(c)) || [];
// Also accept lat/lng (alternate naming) in case the endpoint ever
// returns the same shape the front-end uses internally.
// GET /admin/consignments/:id/logs is confirmed (jupiter2doormile.md,
// Status: Done) as the GPS/status trail for one consignment — but
// this file's own `deliveryid` here has never been confirmed to
// actually BE a consignmentid (Dispatch.js's data source hasn't been
// audited against the new API the way orders.js/deliveries.js have).
// If it's really a bookingid, this 404s and rows stays empty — same
// degraded-but-not-crashing behaviour as before.
let rows = [];
try {
rows = (await getConsignmentLogs(deliveryid)) || [];
if (!Array.isArray(rows)) rows = [];
} catch (err) {
rows = [];
}
// Sort by logdate ascending so the polyline follows the rider's
// chronological path. The endpoint isn't guaranteed to return rows
// in order — without this, consecutive points can be out of sequence
@@ -5121,14 +5127,14 @@ const Dispatch = ({
{renderMarkers()}
{renderRoutes()}
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
Reports → Riders Logs map: green pin when the rider's last log
row is `active`, red otherwise, with the rider's username as a
{/* Live rider GPS markers from GET /admin/milers/summary (see
fetchRidersLogs in api.js). Green pin when the rider's status
is `active`, red otherwise, with the rider's username as a
label. Scoped to riders who actually have orders in the
currently selected slot — `riders` is derived from
filteredLiveRows so it already reflects the slot filter. A
rider with zero orders in the current slot is hidden, even if
getriderlogs still returns their GPS row. When a specific
the summary still returns their GPS row. When a specific
rider is focused, only that one is shown. */}
{liveRiderLocations
.filter((r) =>

View File

@@ -1,15 +1,13 @@
import { useState, useEffect } from 'react';
import { enqueueSnackbar } from 'notistack';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import Loader from 'components/Loader';
import logo from 'assets/images/doormile-logo.png';
import { useSelector, useDispatch } from 'react-redux';
import { useDispatch } from 'react-redux';
import { OpenToast } from 'components/third-party/OpenToast';
import { closeGlobalToast, GlobalToast } from 'components/nearle_components/GlobalToast';
import { setLoginUser } from 'store/reducers/loginUserSlice';
import { markSessionStart } from 'utils/session';
import { loginAdmin } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
// Astryx design system — see themes/astryx.js for the Doormile brand theme
@@ -71,150 +69,56 @@ const BULLETS = ['Real-time fleet visibility', 'AI-optimised dispatch routes', '
const Login = () => {
const dispatch = useDispatch();
const fcmtoken = useSelector((state) => state.fcm);
const [loading, setLoading] = useState(false);
let navigate = useNavigate();
const [otp, setOtp] = useState('');
const [userinfo, setUserinfo] = useState({});
const [username, setUsername] = useState('');
const [passwordStatus, setPasswordStatus] = useState(0);
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isPassword, setIspassword] = useState(false);
const [userid, setUserid] = useState(0);
useEffect(() => {
if (localStorage.getItem('firstname')) {
// 'authname' is the AUTH_PRESENCE_KEY (utils/session.js) — App.js and the
// inactivity/cross-tab logout machinery all key off it, unrelated to which
// backend actually authenticated the session.
if (localStorage.getItem('authname')) {
navigate('/doormile/dispatch');
}
}, []);
const loginsend = async () => {
setLoading(true);
const opentoast = (message, variant = 'error') => OpenToast(message, variant, 2000);
if (!username) {
opentoast('Fill All required fields');
setLoading(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!email || !password) {
opentoast('Enter your email and password');
return;
}
setLoading(true);
try {
const res = await axios.post(`https://jupiter.nearle.app/live/api/v1/users/console/login`, {
authname: username,
configid: 9, // 9 -> config id for nearle console admin
userfcmtoken: fcmtoken?.token,
password
});
// user not found
if (res.data.code == 409 && !res.data.status) {
OpenToast(res.data.message, 'error', 3000);
}
// user not activated
else if (res.data.code == 403) {
OpenToast(res.data.message, 'warning', 3000);
}
//user found, no password, setup password
else if (res.data.code == 409 && res.data.status) {
setPasswordStatus(1); // for password and confirm password ui
setUserid(res.data.details.userid);
OpenToast('User Found', 'success', 3000);
OpenToast(res.data.message, 'success', 3000);
}
//user found, incorrect password
else if (res.data.code == 401 && !res.data.status) {
OpenToast(res.data.message, 'error', 3000);
}
//user found, enter password
else if (res.data.code == 401 && res.data.status) {
OpenToast(res.data.message, 'success', 3000);
fetchAppLocations(res.data.userid);
setPasswordStatus(2);
}
// user found, correct password
else if (res.data.code == 200 && res.data.status) {
OpenToast(res.data.message, 'success', 1000);
setUserinfo(res.data.details);
const userinfo = res.data.details;
dispatch(setLoginUser(userinfo));
localStorage.setItem('firstname', userinfo.firstname);
localStorage.setItem('authname', userinfo.authname);
localStorage.setItem('roleid', userinfo.roleid);
localStorage.setItem('tenantid', userinfo.tenantid);
localStorage.setItem('partnerid', userinfo.partnerid);
localStorage.setItem('applocationid', userinfo.applocationid);
localStorage.setItem('userid', userinfo.userid);
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
const res = await loginAdmin(email, password);
if (res.success) {
const user = res.user || {};
// Compatibility keys: existing pages/api.js read userid/roleid/tenantid
// straight from localStorage rather than through a context. 'authname'
// stays the AUTH_PRESENCE_KEY so the rest of the session contract
// (useInactivityLogout, cross-tab logout, App.js gate) needs no changes.
localStorage.setItem('authname', user.email || user.name || email);
localStorage.setItem('firstname', user.name || '');
localStorage.setItem('userid', user.id ?? '');
localStorage.setItem('roleid', user.role ?? '');
localStorage.setItem('tenantid', user.tenantid ?? '');
dispatch(setLoginUser(user));
markSessionStart();
fetchAppLocations(userinfo.userid);
opentoast('Login successful', 'success');
navigate('/doormile/dispatch');
} else {
OpenToast(res.data.message, 'error', 3000);
opentoast(res.message || 'Login failed');
}
} catch (err) {
console.error(err);
OpenToast(err.message, 'error', 5000);
opentoast(err?.message || 'Login failed');
} finally {
setLoading(false);
}
};
const loginsuccessful = () => {
localStorage.setItem('firstname', userinfo.firstname);
localStorage.setItem('authname', userinfo.authname);
localStorage.setItem('roleid', userinfo.roleid);
localStorage.setItem('tenantid', userinfo.tenantid);
localStorage.setItem('partnerid', userinfo.partnerid);
localStorage.setItem('applocationid', userinfo.applocationid);
localStorage.setItem('userid', userinfo.userid);
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
markSessionStart();
closeGlobalToast(); // to close the pin snackbar
navigate('/doormile/dispatch');
};
const opentoast = (message) => {
enqueueSnackbar(message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1500
});
};
const fetchAppLocations = async (id) => {
const response = await axios.get(`${process.env.REACT_APP_URL}/partners/getlocations/?userid=${id}`);
const updatedLocations = [...response.data.details, { locationname: 'All', applocationid: 0 }];
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
};
const updateUser = async () => {
const response = await axios.put(`${process.env.REACT_APP_URL2}/users/update`, {
userid,
password
});
if (response.data.status) {
OpenToast(response.data.message, 'success', 3000);
OpenToast('Enter Password to Login', 'success', 3000);
setPasswordStatus(2);
setPassword('');
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (passwordStatus == 0) {
loginsend();
} else if (passwordStatus == 1) {
if (!password || !confirmPassword || password != confirmPassword) {
OpenToast('Check Password', 'warning', 3000);
} else {
updateUser();
}
} else if (passwordStatus == 2) {
if (!password) {
OpenToast('Invalid Password', 'warning', 3000);
}
loginsend();
}
};
return (
<Theme theme={doormileTheme} mode="light">
@@ -266,75 +170,18 @@ const Login = () => {
type="email"
size="lg"
isRequired
value={username}
onChange={(value) => setUsername(value.toLocaleLowerCase())}
isDisabled={!!passwordStatus}
value={email}
onChange={(value) => setEmail(value.toLocaleLowerCase())}
/>
{/* Setup Password */}
{passwordStatus == 1 && (
<VStack gap={4} padding={0}>
<Divider label="Setup Password" />
<TextInput
hasAutoFocus
label="Enter New Password"
label="Password"
type="password"
size="lg"
isRequired
value={password}
onChange={(value) => setPassword(value)}
/>
<TextInput
label="Re-Enter Password"
type="password"
size="lg"
isRequired
value={confirmPassword}
onChange={(value) => setConfirmPassword(value)}
status={
confirmPassword !== '' && password !== confirmPassword
? { type: 'error', message: 'Passwords do not match' }
: undefined
}
/>
</VStack>
)}
{/* Enter Password */}
{passwordStatus == 2 && (
<VStack gap={4} padding={0}>
<Divider label="Enter Password" />
<TextInput
hasAutoFocus
label="Enter Password"
type="password"
size="lg"
isRequired
value={password}
onChange={(value) => setPassword(value)}
/>
</VStack>
)}
{/* OTP / retry */}
{isPassword && (
<VStack gap={1.5} padding={0}>
<HStack justify="between">
<Text type="label">Enter Password</Text>
<Link
onClick={() => {
setOtp('');
loginsend();
}}
>
Retry
</Link>
</HStack>
<TextInput label="Password" type="password" value={password} onChange={(value) => setPassword(value)} isLabelHidden />
</VStack>
)}
<Button label="Continue" type="submit" variant="primary" size="lg" width="100%" />
<Button label="Sign in" type="submit" variant="primary" size="lg" width="100%" />
</VStack>
</form>
</VStack>

View File

@@ -1,520 +0,0 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
// import AuthWrapper from 'sections/auth/AuthWrapper';
import {
Box,
Grid,
Card,
CardContent,
// CardHeader,
Stack,
// Divider,
// InputLabel,
// OutlinedInput,
TextField,
Button,
Typography,
CardHeader,
Container,
Link
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import AnimateButton from 'components/@extended/AnimateButton';
import logo from 'assets/images/doormile-logo.png';
// doormile-logo.png is a white asset; recolour it to brand red for this page's light background.
const DOORMILE_RED_FILTER =
'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
// import { openSnackbar } from 'store/reducers/snackbar';
// import { useDispatch } from 'react-redux';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
const Login = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [alertmessage, setAlertmessage] = useState('');
const [checkusername, setCheckusername] = useState(false);
// const [toast, setToast] = useState(false);
const [loading, setLoading] = useState(false);
let navigate = useNavigate();
// let dispatch = useDispatch();
const [submitting, setSubmitting] = useState(false);
// let loginuserid = useSelector((state)=>state.logininfo);
// useEffect(() => {
// if (alertmessage) {
// dispatch(
// openSnackbar({
// open: true,
// message: alertmessage,
// variant: 'alert',
// anchorOrigin: { vertical: 'top', horizontal: 'right' },
// alert: {
// // variant:'info',
// color: 'error',
// }
// })
// )
// }
// }, [toast])
useEffect(() => {
if (
localStorage.getItem('authname')
// || localStorage.getItem("appuserid")
) {
navigate('/deliveries');
}
// console.log(alertmessage)
}, []);
const usernamecheck = async (e) => {
e.preventDefault();
setUsername(e.target.value);
if (e.target.value) {
try {
// await axios.post(`${process.env.REACT_APP_URL}/auth/login`, {
// "authname": e.target.value
// })
await axios
.post(`${process.env.REACT_APP_URL}/users/login`, {
authname: e.target.value,
configid: 1
// "contactno": e.target.value,
// "password": 'admin'
})
.then((res) => {
console.log(res.data);
if (res.data.details.authname === e.target.value) {
setUsername(e.target.value);
setCheckusername(false);
} else {
setCheckusername(true);
}
// if (res.data.authname === e.target.value) {
// setUsername(e.target.value);
// setCheckusername(false);
// }
})
.catch((err) => {
// if (err.response.data.message === 'No user found') {
setCheckusername(true);
// }
});
} catch (err) {
console.log(err);
}
}
};
const loginsend = async () => {
// e.preventDefault();
setLoading(true);
if (password && username) {
if (password == 'admin') {
setSubmitting(true);
try {
await axios
.post(`${process.env.REACT_APP_URL}/users/partner/login`, {
// "authname": username,
configid: 1,
contactno: username
// "password": password
})
.then((res) => {
console.log(res.data);
if (res.data.status) {
if (res.data.details.contactno === username) {
enqueueSnackbar('login Successfull', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 3000
});
setUsername('');
setPassword('');
localStorage.setItem('firstname', res.data.details.tenantname);
localStorage.setItem('authname', res.data.details.authname);
// localStorage.setItem("appuserid", res.data.details.userid);
localStorage.setItem('roleid', res.data.details.roleid);
localStorage.setItem('tenantid', res.data.details.tenantid);
localStorage.setItem('partnerid', res.data.details.partnerid);
navigate('/orders');
setSubmitting(false);
}
}
console.log(res.data.message);
setLoading(false);
})
.catch((err) => {
console.log(err);
// setAlertmessage('Invalid Data');
// if(err.message == 'Network Error'){
opentoast(err.message);
// }else{
// opentoast('Invalid Data');
// }
setLoading(false);
setSubmitting(false);
console.log(err.message);
});
} catch (err) {
console.log(err);
setLoading(false);
setSubmitting(false);
}
} else {
opentoast('Password is Incorrect');
setLoading(false);
}
} else {
// let el2 = document.getElementById('toastid');
// el2.classList.add('d-block');
// el2.classList.remove('d-none');
setAlertmessage('Fill All required fields');
opentoast('Fill All required fields');
setLoading(false);
}
};
// const handleClose = () => {
// setToast(false)
// }
const opentoast = (message) => {
// setToast(true)
// setTimeout(() => {
// // handleClose();
// setToast(false)
// }, 2000);
enqueueSnackbar(message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
return (
<>
{/* <AuthWrapper> */}
<Box sx={{ minHeight: '100vh' }}>
{loading && <Loader />}
{/* <AuthBackground /> */}
<Grid
container
direction="column"
justifyContent="flex-start"
sx={{
minHeight: '100vh'
}}
>
<Grid
item
xs={12}
// sx={{ ml: 3, mt: 3 }}
sx={{ ml: { xs: 0, md: 3 }, mt: { xs: 3, md: 1 }, textAlign: { xs: 'center', md: 'left' } }}
>
<img src={logo} alt="Doormile" width={isMobile ? '160px' : '200px'} style={{ height: 'auto', filter: DOORMILE_RED_FILTER }} />
</Grid>
<Grid item xs={12}>
<Grid
item
xs={12}
container
justifyContent="center"
alignItems="center"
// sx={{ minHeight: { xs: 'calc(100vh - 210px)', sm: 'calc(100vh - 134px)', md: 'calc(100vh - 112px)' } }}
sx={{ minHeight: { xs: 'calc(100vh - 210px)', sm: 'calc(100vh - 134px)', md: 'calc(100vh - 140px)' } }}
>
<Grid item>
{/* <AuthCard>{children}</AuthCard> */}
<Box
sx={{
width: { xs: '100%', sm: 'auto' },
maxWidth: { xs: 400, lg: 475 },
margin: { xs: 2, sm: 2.5, md: 3 },
'& > *': {
flexGrow: 1,
flexBasis: '50%'
}
}}
>
<Card
sx={{
position: 'relative',
border: '1px solid',
borderRadius: { xs: 2, md: 1 },
borderColor: theme.palette.divider,
boxShadow: { xs: '0 14px 40px rgba(15, 23, 42, 0.10)', md: 'inherit' },
p: { xs: 2, md: 2 },
width: '100%'
}}
>
{/* <CardHeader title={<Typography variant="h4">Login</Typography>} /> */}
{/* <Divider sx={{ borderStyle: 'dashed' }} /> */}
{/* <h1>eee</h1> */}
{/* <CardHeader> */}
<Grid container spacing={3}>
<Grid item xs={12}>
<Stack direction="row" justifyContent="flex-start" alignItems="baseline" sx={{ mb: { xs: -0.5, sm: 0.5 } }}>
<CardHeader title={<Typography variant="h3">Login</Typography>} />
</Stack>
</Grid>
{/* <Grid item xs={12}>
<AuthLogin isDemo={isLoggedIn} />
</Grid> */}
</Grid>
<CardContent>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
}}
>
<Grid container spacing={2}>
<Grid item xs={12}>
{/* <Stack spacing={1}> */}
{/* <InputLabel htmlFor="email-login">Email Address</InputLabel>
<OutlinedInput
id="email-login"
type="email"
value={values.email}
name="email"
onBlur={handleBlur}
onChange={handleChange}
placeholder="Enter email address"
fullWidth
id="username1"
label="E-mail Address"
variant="outlined"
autoComplete='email'
required
onChange={usernamecheck}
error={checkusername}
error={Boolean(touched.email && errors.email)}
/>
{touched.email && errors.email && (
<FormHelperText error id="standard-weight-helper-text-email-login">
{errors.email}
</FormHelperText>
)} */}
<TextField
margin="normal"
fullWidth
id="username1"
label="E-mail Address"
variant="outlined"
autoComplete="email"
required
onChange={usernamecheck}
error={checkusername}
/>
<TextField
margin="normal"
fullWidth
required
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
type="password"
id="password1"
label="Password"
variant="outlined"
/>
{/* </Stack> */}
</Grid>
<Grid item xs={12}>
{/* <Stack spacing={0}> */}
{/* <InputLabel htmlFor="password-login">Password</InputLabel> */}
{/* <OutlinedInput
fullWidth
// error={Boolean(touched.password && errors.password)}
// id="-password-login"
// type={showPassword ? 'text' : 'password'}
// value={values.password}
// name="password"
// onBlur={handleBlur}
// onChange={handleChange}
// endAdornment={
// <InputAdornment position="end">
// <IconButton
// aria-label="toggle password visibility"
// onClick={handleClickShowPassword}
// onMouseDown={handleMouseDownPassword}
// edge="end"
// color="secondary"
// >
// {showPassword ? <EyeOutlined /> : <EyeInvisibleOutlined />}
// </IconButton>
// </InputAdornment>
// }
placeholder="Enter password"
// margin="normal"
// fullWidth
required
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
type='password' id="password1"
/> */}
{/* {touched.password && errors.password && (
<FormHelperText error id="standard-weight-helper-text-password-login">
{errors.password}
</FormHelperText>
)} */}
{/* </Stack> */}
<Link href="#" variant="h6">
Forgot password?
</Link>
</Grid>
{/* <Grid item xs={12} sx={{ mt: -1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<FormControlLabel
control={
<Checkbox
checked={checked}
onChange={(event) => setChecked(event.target.checked)}
name="checked"
color="primary"
size="small"
/>
}
label={<Typography variant="h6">Keep me sign in</Typography>}
/>
<Link variant="h6" component={RouterLink} to={isDemo ? '/auth/forgot-password' : '/forgot-password'} color="text.primary">
Forgot Password?
</Link>
</Stack>
</Grid> */}
{/* {errors.submit && (
<Grid item xs={12}>
<FormHelperText error>{errors.submit}</FormHelperText>
</Grid>
)} */}
<Grid item xs={12}>
{/* <AnimateButton> */}
<AnimateButton>
<Button
disabled={submitting}
onClick={() => {
loginsend();
// navigate('/dashboard')
}}
fullWidth
size="large"
type="submit"
variant="contained"
color="primary"
>
Login
</Button>
</AnimateButton>
{/* </AnimateButton> */}
</Grid>
</Grid>
{/* </Grid> */}
</form>
</CardContent>
{/* </CardHeader> */}
</Card>
</Box>
</Grid>
</Grid>
</Grid>
<Grid
item
xs={12}
// sx={{ m: 3, mt: 1 }}
sx={{ mb: 1 }}
>
{/* <AuthFooter /> */}
<Container maxWidth="xl">
<Stack
direction={{ sx: 'column', md: 'row' }}
justifyContent={{ sx: 'center', md: 'space-between' }}
spacing={2}
// textAlign={{ sx: 'center', md: 'inherit' }}
alignItems={{ sx: 'center', md: 'inherit' }}
width="100%"
>
<Stack direction="row" justifyContent="center" spacing={1}>
<Typography variant="subtitle2" color="secondary" component="span" sx={{ display: 'flex' }}>
&copy; All rights reserved
{/* <Typography variant="subtitle2" href="#mantis-privacy" target="_blank" underline="hover" sx={{ml:1}}>Privacy Policy</Typography> */}
</Typography>
</Stack>
<Stack
direction={{ sx: 'column', md: 'row' }}
spacing={{ sx: 1, md: 3 }}
textAlign={{ sx: 'center', md: 'inherit' }}
alignItems={{ sx: 'center', md: 'inherit' }}
// width='100%'
>
<Typography
variant="subtitle2"
color="secondary"
component={Link}
href="#"
// target="_blank"
underline="hover"
textAlign="center"
>
Terms and Conditions
</Typography>
<Typography
variant="subtitle2"
color="secondary"
component={Link}
href="#"
// target="_blank"
underline="hover"
textAlign="center"
>
Privacy Policy
</Typography>
{/* <Typography
variant="subtitle2"
color="secondary"
component={Link}
href="#"
// target="_blank"
underline="hover"
textAlign='center'
>
CA Privacy Notice
</Typography> */}
</Stack>
</Stack>
</Container>
</Grid>
</Grid>
</Box>
{/* </AuthWrapper> */}
</>
);
};
export default Login1;

View File

@@ -45,7 +45,7 @@ import { useNavigate } from 'react-router';
import { MdLocationCity } from 'react-icons/md';
import { TbMapPinCode } from 'react-icons/tb';
import { FaLocationDot } from 'react-icons/fa6';
import axios from 'axios';
import { getAdminPricing, getAdminTenant, getTenantCustomers, getTenantLocations, createExpressBooking } from 'pages/api/doormileApi';
import { useTheme } from '@mui/material/styles';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import Loader from 'components/Loader';
@@ -127,9 +127,7 @@ const SectionHeader = ({ color, icon, title, subtitle, action }) => (
}}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Avatar sx={{ width: 32, height: 32, bgcolor: color, color: '#fff', boxShadow: `0 6px 18px ${ring(color)}` }}>
{icon}
</Avatar>
<Avatar sx={{ width: 32, height: 32, bgcolor: color, color: '#fff', boxShadow: `0 6px 18px ${ring(color)}` }}>{icon}</Avatar>
<Stack>
<Typography sx={{ fontWeight: 800, color: DT.textPrimary, fontSize: 14, lineHeight: 1.1 }}>{title}</Typography>
{subtitle && (
@@ -234,7 +232,6 @@ const Createorder1 = () => {
{ label: '12 Angry Men', year: 1957 }
];
const appId = localStorage.getItem('applocationid');
const navigate = useNavigate();
const [open, setOpen] = useState({});
@@ -340,11 +337,13 @@ const Createorder1 = () => {
const fetchTenantPricing = async () => {
try {
const pricingResponse = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantpricing/?tenantid=${tid}`);
console.log('pricingResponse', pricingResponse.data.details);
setBasePrice(pricingResponse.data.details.baseprice);
setPricePerKm(pricingResponse.data.details.priceperkm);
setMinKm(pricingResponse.data.details.minkm);
const pricing = (await getAdminPricing()) || [];
const match = pricing.find((p) => p.tenantid === tid);
if (match) {
setBasePrice(match.baseprice);
setPricePerKm(match.priceperkm);
setMinKm(match.basedistance);
}
} catch (error) {
console.log('fetchTenantPricing error', error);
}
@@ -450,109 +449,65 @@ const Createorder1 = () => {
}
}, [searchword]);
// ==================================================== || fetchtenantinfo || ====================================================
const fetchtenantinfo = async () => {
setLoading(true);
console.log('tid', tid);
await axios
.get(`${process.env.REACT_APP_URL}/tenants/gettenantinfo/?tenantid=${tid}`)
.then((res) => {
console.log('fetchtenantinfo', res);
if (res.data.status) {
setTenant(res.data.details);
setTenantid(res.data.details.tenantid);
try {
const info = await getAdminTenant(tid);
if (info) {
setTenant(info);
setTenantid(info.tenantid);
fetchAppAdminTokens();
setSubCatId(res.data.details.subcategoryid);
setSubCatId(info.subcategoryid);
}
setLoading(false);
})
.catch((err) => {
} catch (err) {
console.log(err);
} finally {
setLoading(false);
});
}
};
useEffect(() => {
fetchtenantinfo();
}, []);
const getsubcategories = async () => {
await axios
.get(`${process.env.REACT_APP_URL}/utils/getsubcategories/?moduleid=6`)
.then((res) => {
console.log('subcateRes', res.data.details);
if (res.data.status && res.data.details) {
setSubCat(res.data.details);
} else {
setSubCat([]);
}
})
.catch((err) => {
console.log(err);
setSubCat([]);
});
};
// No item-subcategory lookup endpoint in the new API.
const getsubcategories = async () => setSubCat([]);
useEffect(() => {
getsubcategories();
}, []);
// ==================================================== || fetchTiming || ====================================================
// No per-zone open/close-hours or geofence-radius endpoint in the new API
// (applocationid/"zones" aren't a resource there at all — see fetchAppLocations
// in pages/api/api.js). Falls back to a fixed 09:0021:00 delivery window so
// the order-row / time-slot picker below still has slots to initialise from,
// and a generous default radius so the "too far" warning doesn't misfire.
const fetchTiming = async () => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/utils/getapplocations/?applocationid=${appId}`)
.then((res) => {
console.log('fetchTiming', res);
const { opentime, closetime, latitude, longitude, radius } = res.data.details[0];
if (res.data.status) {
setAppLocaLat(latitude);
setAppLocaLng(longitude);
setAppLocaRadius(radius);
const opentime = '09:00:00';
const closetime = '21:00:00';
setAppLocaLat(0);
setAppLocaLng(0);
setAppLocaRadius(100);
setStatrttime(`${dayjs().format('MM-DD-YYYY')} ${opentime}`);
setEndtime(`${dayjs().format('MM-DD-YYYY')} ${closetime}`);
console.log('starttime', `${dayjs().format('MM-DD-YYYY')} ${opentime}`);
console.log('endtime', `${dayjs().format('MM-DD-YYYY')} ${closetime} `);
let arr = [];
for (
let i = `${dayjs().format('MM-DD-YYYY')} ${opentime}`, j = 0;
let i = `${dayjs().format('MM-DD-YYYY')} ${opentime}`;
dayjs(`${dayjs().format('MM-DD-YYYY')} ${closetime} `).diff(i, 'm') >= 0;
j++, i = dayjs(i).add(30, 'm')
i = dayjs(i).add(30, 'm')
) {
arr.push(i);
}
console.log('setTimeslotarr', arr);
setTimeslotarr(arr);
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
useEffect(() => {
fetchTiming();
}, []);
// =============================================== || fetchAppAdminTokens (via appId) || ===============================================
const fetchAppAdminTokens = async () => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/utils/getapplocationconfig/?applocationid=${appId}`)
.then((res) => {
const userfcmtokemArray = res.data.details.applocationadmins.map((admin) => admin.userfcmtokem);
console.log('fetchAppAdminTokens', res);
console.log('userfcmtokemArray', userfcmtokemArray);
if (res.data.status) {
setAdmintoken(userfcmtokemArray);
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
// No zone-admin-token lookup endpoint in the new API.
const fetchAppAdminTokens = async () => setAdmintoken([]);
useEffect(() => {
if (starttime && endtime) {
@@ -582,116 +537,47 @@ const Createorder1 = () => {
// =============================================== || createsubmitobj1 (create orders) || ===============================================
const createsubmitobj2 = async () => {
let arr = {};
arr = {
orders: {
applocationid: tenant.applolcationid,
cancellled: '',
categoryid: +tenant.categoryid,
configid: 7,
customerid: isNumChange1 == 0 ? +pickCust.customerid || 0 : 0,
deliveryaddress: dropCust.address || '',
deliverycharge: +totalCharge.toFixed(2) || 0,
deliverycity: dropCust.city || '',
deliverycontactno: dropCust.contactno || '',
deliverycustomer: dropCust.firstname || '',
deliveryid: isNumChange2 == 0 ? +dropCust.customerid || 0 : 0,
deliverylandmark: dropCust.landmark || '',
deliverylat: dropCust.latitude.toString(),
deliverylocation: dropCust.suburb || '',
deliverylocationid: dropCust.deliverylocationid || 0,
deliverylong: dropCust.longitude.toString(),
deliverytime: `${dayjs(startdate).format('YYYY-MM-DD')} ${dayjs(selectedtime.$d).format('HH:mm:ss')}`,
deliverytype: pickCust.customerid !== 0 || dropCust.customerid !== 0 ? 'B' : 'C',
delivered: '',
itemcount: 1,
kms: distance.toString() || 0,
locationid: +tenanatLocoId, //main or branch
moduleid: +tenant.moduleid,
orderamount: +totalCharge.toFixed(2) || 0,
ordercharges: 0.0,
orderdate: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderheaderid: 0,
orderid: '', //
ordernotes: otherinstructions,
orderstatus: 'created',
ordervalue: +totalCharge.toFixed(2) || 0,
partnerid: tenant.partnerid,
paymentstatus: 1,
paymenttype: 42,
pending: '',
pickupaddress: pickCust.address || '',
pickupcity: pickCust.city || '',
pickupcontactno: pickCust.contactno || '',
pickupcustomer: pickCust.firstname || '',
pickuplandmark: pickCust.landmark || '',
pickuplat: pickCust.latitude.toString(),
pickuplocation: pickCust.suburb || '',
pickuplocationid: pickCust.deliverylocationid || 0,
pickuplong: pickCust.longitude.toString(),
processing: '',
ready: '',
remarks: '',
smsdelivery: isSms,
subcategoryid: +subCatId,
taxamount: 0.0,
// POST /admin/expressbooking's schema is a single flat booking object
// (tenantid, pickuplocationid, customer_phone/name, delivery* fields,
// service_option, finalprice, notes, parcels[]) — nothing like the old
// pickup/drop/orders triple. The drop contact becomes the booking's
// customer (new API models the delivery recipient as "the customer");
// the pickup side maps to pickuplocationid when a saved location was
// picked (tenanatLocoId/isLocation, set by the "Business Location"
// Autocomplete below — pickCust.deliverylocationid was always '' and
// never actually got wired to the picker, so this shortcut never fired),
// otherwise raw pickup* fields following the doc's own delivery*/pickup*
// naming symmetry (pickupaddress/pickuppincode/pickupcity/pickuplatitude/
// pickuplongitude — NOT shown in the doc's example, so unverified against
// the live API).
const arr = {
tenantid: tenant.tenantid,
tenantuserid: parseInt(localStorage.getItem('userid')),
collectionamt: +collectionamt || 0,
quantity: +quantity || 1
},
pickup: {
address: pickCust.address || '',
applocationid: tenant.applolcationid,
city: pickCust.city || '',
configid: 7,
contactno: pickCust.contactno || '',
customertoken: '',
customerid: isNumChange1 == 0 ? pickCust.customerid || 0 : 0,
devicetype: '',
deviceid: '',
dialcode: '+91',
doorno: pickCust.doorno || '',
email: pickCust.email || '',
firstname: pickCust.firstname || '',
landmark: pickCust.landmark || '',
latitude: pickCust.latitude.toString() || '',
longitude: pickCust.longitude.toString() || '',
locationid: pickCust.deliverylocationid || 0,
postcode: pickCust.postcode || '',
primaryaddress: 1,
profileimage: '',
state: pickCust.state || '',
suburb: pickCust.suburb || '',
tenantid: tenant.tenantid
},
drop: {
address: dropCust.address || '',
applocationid: tenant.applolcationid,
city: dropCust.city || '',
configid: 7,
contactno: dropCust.contactno || '',
customertoken: '',
customerid: isNumChange2 == 0 ? dropCust.customerid || 0 : 0,
devicetype: '',
deviceid: '',
dialcode: '+91',
doorno: dropCust.doorno || '',
email: dropCust.email || '',
firstname: dropCust.firstname || '',
landmark: dropCust.landmark || '',
latitude: dropCust.latitude.toString(),
longitude: dropCust.longitude.toString(),
locationid: dropCust.deliverylocationid || 0,
postcode: dropCust.postcode || '',
primaryaddress: 1,
profileimage: '',
state: dropCust.state || '',
suburb: dropCust.suburb || '',
tenantid: tenant.tenantid
...(isLocation && tenanatLocoId
? { pickuplocationid: tenanatLocoId }
: {
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.postcode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0
}),
customer_phone: dropCust.contactno || '',
customer_name: dropCust.firstname || '',
deliveryaddress: dropCust.address || '',
deliverypincode: dropCust.postcode || '',
deliverycity: dropCust.city || '',
deliverylatitude: Number(dropCust.latitude) || 0,
deliverylongitude: Number(dropCust.longitude) || 0,
service_option: 'Normal',
finalprice: +totalCharge.toFixed(2) || 0,
notes: otherinstructions || '',
parcels: [
{
itemcategory: subCat.find((c) => c.subcategoryid === subCatId)?.categoryname || 'General',
itemdescription: 'Order',
declaredvalue: +totalCharge.toFixed(2) || 0
}
]
};
console.log('createsubmitobj2', arr);
if (!pickCust.firstname) {
@@ -727,29 +613,28 @@ const Createorder1 = () => {
} else if (!setSubCatId) {
opentoast('Choose SubCategory ', 'warning', 2000);
} else {
setLoading(true);
try {
const createRes = await axios.post(`${process.env.REACT_APP_URL2}/orders/createorder`, arr);
// const createRes = await axios.post(`${process.env.REACT_APP_URL}/orders/createorder`, arr);
if (createRes.data.status) {
console.log('createRes', createRes);
const createRes = await createExpressBooking(arr);
if (createRes.success) {
enqueueSnackbar('Order Created Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
if (admintoken) {
notifyadmin(admintoken);
// sendnotifications();
}
navigate('/nearle/orders');
} else {
opentoast('Something went wrong, Cannot create order', 'warning');
opentoast(createRes.message || 'Something went wrong, Cannot create order', 'warning');
}
setLoading(false);
console.log(createRes);
} catch (error) {
console.log('createResErr', error);
// Previously silent — a thrown error here (network failure, CityGate
// rejection, validation error) left the operator with no feedback at
// all and a permanently stuck loading spinner (setLoading(false) was
// only reachable on the success path).
opentoast(error.response?.data?.message || error.message || 'Cannot create order', 'error', 3000);
} finally {
setLoading(false);
}
}
};
@@ -774,41 +659,22 @@ const Createorder1 = () => {
const clientdetails = async () => {
setLoading2(true);
try {
let url =
searchCustList == ''
? // ? `${process.env.REACT_APP_URL}/customers/getbytid/?tenantid=${tid}&pageno=1&pagesize=1`
`${process.env.REACT_APP_URL}/customers/gettenantcustomers/?tenantid=${tid}&pageno=1&pagesize=20`
: `${process.env.REACT_APP_URL}/customers/search/?tenantid=${tid}&keyword=${searchCustList}`;
await axios
.get(url)
.then((res) => {
console.log('clientdetails', res.data.details);
if (res.data.status && res.data.details) {
setClientdetail(res.data.details || []);
setCustomerlist(res.data.details || []);
let arr = [];
res.data.details.map((val) => {
arr.push({
label: `${val.firstname} | ${val.contactno}`,
...val
});
});
setClientdetailarr(arr);
} else {
setClientdetail([]);
setCustomerlist([]);
setClientdetailarr([]);
}
setLoading2(false);
})
.catch((err) => {
console.log(err);
setLoading2(false);
opentoast('server error', 'warning');
});
const customers = (await getTenantCustomers()) || [];
const filtered = searchCustList
? customers.filter(
(val) =>
val.firstname?.toLowerCase().includes(searchCustList.toLowerCase()) ||
val.phone?.includes(searchCustList) ||
val.contactno?.includes(searchCustList)
)
: customers;
setClientdetail(filtered);
setCustomerlist(filtered);
setClientdetailarr(filtered.map((val) => ({ label: `${val.firstname} | ${val.phone || val.contactno}`, ...val })));
} catch (err) {
console.log(err);
opentoast('server error', 'warning');
} finally {
setLoading2(false);
}
};
@@ -821,74 +687,21 @@ const Createorder1 = () => {
const clientdetailsbusiness = async () => {
setLoading2(true);
try {
await axios
.get(`${process.env.REACT_APP_URL}/customers/getbytid/?tenantid=${tid}&locationid=1`)
.then((res) => {
console.log('clientdetailsbusiness', res.data.details);
if (res.data.status) {
setClientdetail(res.data.details);
// if (!searchword) {
// setClientdetailarr(res.data.details)
// }
let arr = [];
res.data.details.map((val) => {
arr.push({
label: `${val.firstname} | ${val.contactno}`,
...val
});
});
setClientdetailbusinessarr(arr);
}
setLoading2(false);
})
.catch((err) => {
console.log(err);
setLoading2(false);
opentoast('server error', 'warning');
});
const customers = (await getTenantCustomers()) || [];
setClientdetail(customers);
setClientdetailbusinessarr(customers.map((val) => ({ label: `${val.firstname} | ${val.phone || val.contactno}`, ...val })));
} catch (err) {
console.log(err);
opentoast('server error', 'warning');
} finally {
setLoading2(false);
}
};
// ================================================== || sendnotifications || ==================================================
const sendnotifications = async () => {
setLoading(true);
await axios
.post(`${process.env.REACT_APP_URL}/utils/sendnotifications`, {
priority: 'high',
registration_ids: admintoken,
data: {
accessid: process.env.REACT_APP_RIDER_ACCESS_ID
},
notification: {
title: 'Nearle Merchant',
body: 'An Order has been placed successfully,kindly process the same',
sound: 'ring'
}
})
.then((res) => {
console.log(res);
if (res.data.message == 'Success') {
enqueueSnackbar('Notification sent Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
}
setLoading(false);
})
.catch((err) => {
console.log(err);
enqueueSnackbar(err.message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
setLoading(false);
});
};
// No bulk-notify endpoint in the new API — never actually called (dead in
// the original code too, only referenced from a commented-out call site).
const sendnotifications = async () => {};
// ============================================= || Address Autocomplete (pick) || =============================================
const handlePickPlaceSelected = (place) => {
setInputValue2(`${place.name}, ${place.formatted_address}`);
@@ -1015,16 +828,11 @@ const Createorder1 = () => {
// ============================================= || gettenantlocations (branches) || =============================================
const gettenantlocations = async () => {
try {
const res = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations/?tenantid=${tid}`);
console.log('gettenantlocations', res.data.details);
if (res.data && res.data.details) {
setTenantlocations(res.data.details);
if (res.data.details.length == 1) {
const locations = (await getTenantLocations(tid)) || [];
setTenantlocations(locations);
if (locations.length == 1) {
setIsLocation(true);
setTenanatLocoId(res.data.details[0].locationid);
}
} else {
setTenantlocations([]);
setTenanatLocoId(locations[0].locationid);
}
} catch (err) {
console.log('gettenantlocations', err);
@@ -1124,9 +932,7 @@ const Createorder1 = () => {
) : (
<Autocomplete
options={tenantLocations || []}
getOptionLabel={(option) =>
option && option.locationname ? `${option.locationname} (${option.suburb || ''})` : ''
}
getOptionLabel={(option) => (option && option.locationname ? `${option.locationname} (${option.suburb || ''})` : '')}
isOptionEqualToValue={(option, value) => option?.locationid === value?.locationid}
PaperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 300 } }}
@@ -1926,16 +1732,13 @@ const Createorder1 = () => {
{/* ================================================= || Time || ================================================= */}
<Grid item xs={12} sm={6}>
<SectionCard sx={{ mt: 2, minHeight: 390 }}>
<SectionHeader
color="#f59e0b"
icon={<MdSchedule size={16} />}
title="Schedule"
subtitle="Pickup date & time slot"
/>
<SectionHeader color="#f59e0b" icon={<MdSchedule size={16} />} title="Schedule" subtitle="Pickup date & time slot" />
<Box sx={{ p: { xs: 2, md: 2.5 } }}>
<Grid container>
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textSecondary, mb: 1 }}>Date</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textSecondary, mb: 1 }}>
Date
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs} sx={{}}>
<DatePicker
format="DD-MM-YYYY"
@@ -2006,7 +1809,8 @@ const Createorder1 = () => {
<Stack direction="row" flexWrap="wrap" gap={0.75} useFlexGap>
{timeslotarr.map((val, index) => {
if (
dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0
dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <=
0
) {
const active = dayjs(selectedtime).format('HH:mm') == dayjs(val).format('HH:mm');
return (
@@ -2082,7 +1886,9 @@ const Createorder1 = () => {
getOptionLabel={(option) => `${option.subcategoryname}` || ''}
sx={{ my: 2, zIndex: '100' }}
fullWidth
renderInput={(params) => <TextField {...params} label={subCatName == '' ? tenant.subcategoryname : subCatName} />}
renderInput={(params) => (
<TextField {...params} label={subCatName == '' ? tenant.subcategoryname : subCatName} />
)}
onChange={(event, value, reason) => {
if (value) {
console.log(value);
@@ -2423,7 +2229,9 @@ const Createorder1 = () => {
}}
>
<Avatar sx={{ width: 36, height: 36, bgcolor: soft(BRAND), color: BRAND, fontWeight: 800 }}>
{String(address.firstname || '?').charAt(0).toUpperCase()}
{String(address.firstname || '?')
.charAt(0)
.toUpperCase()}
</Avatar>
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.75} flexWrap="wrap" useFlexGap>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ import { useState, useEffect, Fragment, useRef } from 'react';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import axios from 'axios';
import {
Avatar,
@@ -23,17 +22,14 @@ import {
Dialog,
TableRow,
DialogContent,
DialogTitle,
DialogActions,
Tooltip,
Skeleton,
CircularProgress,
InputBase,
Backdrop,
SpeedDial,
SpeedDialIcon,
SpeedDialAction,
Badge,
TableContainer,
Checkbox
TableContainer
} from '@mui/material';
import {
MdAccessTime,
@@ -51,7 +47,8 @@ import {
MdCalendarMonth,
MdReceiptLong,
MdClear,
MdNotes
MdNotes,
MdMyLocation
} from 'react-icons/md';
import { DeleteOutlined } from '@ant-design/icons';
import Loader from 'components/Loader';
@@ -60,13 +57,8 @@ import CircularLoader from 'components/CircularLoader';
import AiImage from '../../../assets/images/aiImage.png';
import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query';
import { useNavigate, useLocation } from 'react-router-dom';
import {
fetchPercentageData,
createAutomationDeliveries,
cancelMultipleOrder,
getallriders,
fetchorderscount
} from '../../api/api';
import { fetchPercentageData, createAutomationDeliveries, getallriders, fetchorderscount } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack } from 'pages/api/doormileApi';
// ============================================================================
// Design tokens — shared with the rest of the redesigned operator pages.
@@ -99,29 +91,37 @@ const dtEdge = edge;
const BRAND = '#C01227';
const BRAND_LIGHT = '#D25463';
// Semantic per-row status palette — colors per brand standard:
// green=delivered, amber=pending, blue=created/processing, red=cancelled,
// dark-red=failed, purple=on-hold.
// Semantic per-row status palette, keyed by GET /admin/bookings' real `status`
// enum (lowercased — StatusBadge lowercases before lookup). Only
// pending_pickup and converted_to_consignment have been observed against the
// live API so far; the rest are reasonable guesses at likely sibling states
// with a safe unknown-status fallback in StatusBadge below.
const ROW_STATUS_META = {
created: { label: 'Created', color: '#3b82f6', icon: MdLocalShipping },
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
processing: { label: 'Processing', color: '#3b82f6', icon: MdAccessTime },
modified: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle },
confirmed: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle },
ready: { label: 'Accepted', color: '#6366f1', icon: MdCheckCircle },
active: { label: 'Picked', color: '#8b5cf6', icon: MdLocalShipping },
onhold: { label: 'On Hold', color: '#8b5cf6', icon: MdHistoryToggleOff },
closed: { label: 'Closed', color: '#06b6d4', icon: MdCheckCircle },
pending_pickup: { label: 'Pending Pickup', color: '#f59e0b', icon: MdHourglassEmpty },
pending_assignment: { label: 'Pending Assignment', color: '#f59e0b', icon: MdHourglassEmpty },
converted_to_consignment: { label: 'Converted', color: '#6366f1', icon: MdCheckCircle },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
failed: { label: 'Failed', color: '#991b1b', icon: MdCancel },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
};
// Top-level pill tabs.
const ORDERS_STATUS_TABS = [
{ idx: 0, status: 'created', label: 'Created', color: BRAND, icon: MdLocalShipping, countKey: 'created' }
];
// Top-level pill tabs. GET /admin/bookings has no documented status filter
// param, so this tab doesn't narrow the server query — it's cosmetic until
// the API supports it.
const ORDERS_STATUS_TABS = [{ idx: 0, status: 'created', label: 'Bookings', color: BRAND, icon: MdLocalShipping, countKey: 'created' }];
// Haversine straight-line distance in km — GET /admin/bookings has no `kms`
// field, but does carry both pickup*/delivery* lat/lng, so this is computed
// client-side rather than left blank. It's a straight line, not the road
// route the old `kms` field represented — labelled accordingly in the header.
const haversineKm = (lat1, lon1, lat2, lon2) => {
if (![lat1, lon1, lat2, lon2].every(Number.isFinite)) return null;
const R = 6371;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const StatusBadge = ({ status }) => {
const meta = ROW_STATUS_META[String(status || '').toLowerCase()] || {
@@ -201,6 +201,16 @@ const Orders = () => {
const [tabstatus, setTabstatus] = useState('Created');
const [currentStatus, setCurrentStatus] = useState('created');
const [cancelOpen, setCancelOpen] = useState(false);
const [trackBookingId, setTrackBookingId] = useState(null);
// GET /admin/bookings/:id/track — response shape isn't documented beyond
// the endpoint existing (Status: Done in jupiter2doormile.md). Rendered
// defensively below rather than assuming specific field names.
const { data: trackData, isLoading: trackLoading } = useQuery({
queryKey: ['booking-track', trackBookingId],
queryFn: () => getBookingTrack(trackBookingId),
enabled: !!trackBookingId
});
const [orderheaderid, setOrderheaderid] = useState('');
const locationId = 0;
const locoName = 'All Locations';
@@ -213,10 +223,8 @@ const Orders = () => {
// Floating button and Dialog states
const [speedDialOpen, setSpeedDialOpen] = useState(false);
const [multiDeleteDialog, setMultiDeleteDialog] = useState(false);
const [createloader, setCreateloader] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [deliverylist, setDeliverylist] = useState([]);
useEffect(() => {
const handler = setTimeout(() => {
@@ -247,10 +255,7 @@ const Orders = () => {
refetchInterval: 15000
});
const {
data: ordersCountData,
refetch: orderscountRefetch
} = useQuery({
const { data: ordersCountData, refetch: orderscountRefetch } = useQuery({
queryKey: ['ordersCount', locationId, startdate, enddate, currentStatus, tid, locationId],
queryFn: fetchorderscount,
refetchOnMount: true,
@@ -258,24 +263,36 @@ const Orders = () => {
refetchInterval: 15000
});
const {
data: autoRiders
} = useQuery({
const { data: autoRiders } = useQuery({
queryKey: ['getallriders'],
queryFn: getallriders,
refetchOnMount: true,
refetchOnWindowFocus: true
});
// GET /admin/bookings only carries `appcustomerid` — no name/phone on the
// booking itself — so customer display fields are joined client-side
// against GET /admin/customers. Response field names for that endpoint
// aren't documented; firstname/phone are guessed by symmetry with
// tenantcustomers and fall back to showing the raw id.
const { data: customerMap } = useQuery({
queryKey: ['adminCustomersMap'],
queryFn: async () => {
const customers = (await getAdminCustomers()) || [];
const map = new Map();
customers.forEach((c) => {
const id = c.appcustomerid ?? c.customerid ?? c.id;
if (id != null) map.set(id, c);
});
return map;
},
staleTime: 5 * 60 * 1000
});
const cancelorder = async () => {
await axios
.put(`${process.env.REACT_APP_URL}/orders/updateorder`, {
orderheaderid: orderheaderid,
orderstatus: 'cancelled',
cancelled: dayjs().format('YYYY-MM-DD HH:mm:ss')
})
.then((res) => {
if (res.data.status) {
try {
const res = await cancelBooking(orderheaderid);
if (res.success) {
enqueueSnackbar('Order Cancelled Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
@@ -285,20 +302,30 @@ const Orders = () => {
orderscountRefetch();
percentagedataRefetch();
setCancelOpen(false);
}
})
.catch((err) => {
console.log(err);
} else {
enqueueSnackbar(res.message || 'Failed to cancel order', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
} catch (err) {
console.log(err);
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to cancel order', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
};
// Maps to /admin/bookings — tenantid/locationid/status/date filters used by
// the old endpoint aren't documented on this resource; pageno/pagesize are.
const fetchOrders = async ({ pageParam = 1 }) => {
const res = await axios.get(
`${process.env.REACT_APP_URL}/orders/tenant/getorders/?tenantid=${tid}&locationid=${locationId}&status=${currentStatus}&fromdate=${startdate}&todate=${enddate}&pageno=${pageParam}&pagesize=${rowsPerPage}&keyword=${debouncedSearch}`
);
const data = (await getBookings(pageParam, rowsPerPage)) || [];
return {
data: res.data.details,
nextPage: res.data.details.length === rowsPerPage ? pageParam + 1 : undefined
data,
nextPage: data.length === rowsPerPage ? pageParam + 1 : undefined
};
};
@@ -352,7 +379,11 @@ const Orders = () => {
const createDeliveryMutation = useMutation({
mutationFn: createAutomationDeliveries,
onSuccess: (data, variables) => {
enqueueSnackbar('Orders Optimised Successfully', { variant: 'success', autoHideDuration: 2000, anchorOrigin: { vertical: 'top', horizontal: 'right' } });
enqueueSnackbar('Orders Optimised Successfully', {
variant: 'success',
autoHideDuration: 2000,
anchorOrigin: { vertical: 'top', horizontal: 'right' }
});
orderscountRefetch();
refetchOrders();
setCreateloader(false);
@@ -379,43 +410,37 @@ const Orders = () => {
}
});
const cancelMultipleOrderMutation = useMutation({
mutationFn: cancelMultipleOrder,
onSuccess: (data) => {
if (data.status) {
setMultiDeleteDialog(false);
enqueueSnackbar('Orders Cancelled Successfully', { variant: 'success', autoHideDuration: 2000 });
refetchOrders();
orderscountRefetch();
percentagedataRefetch();
setDeliverylist([]);
}
},
onError: (error) => {
enqueueSnackbar(error.message, { variant: 'error', autoHideDuration: 4000 });
}
});
const handleCreateDelivery = async () => {
if (rows.length === 0) return;
setIsLoading(true);
setCreateloader(true);
const deliveryData = rows.map((val) => ({
// The optimiser service (routes.workolik.com) itself is untouched, but the
// fields sourced here have to match GET /admin/bookings' real shape —
// deliverylat/pickuplat/deliverycharge/etc. don't exist on a booking
// object (see pickuplatitude/deliverylatitude/serviceoptions[0].estimatedprice
// instead). The solver's own expected input contract is unverified since
// it's a separate, unchanged service.
const deliveryData = rows.map((val) => {
const charge = val.serviceoptions?.[0]?.estimatedprice;
return {
...val,
deliveryid: 0,
deliverydate: dayjs(val.deliverydate).utc().format('YYYY-MM-DD HH:mm:ss'),
deliverydate: dayjs(val.serviceoptions?.[0]?.estimateddeliveryat || val.createdat)
.utc()
.format('YYYY-MM-DD HH:mm:ss'),
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderstatus: 'pending',
orderamount: val.deliverycharge,
droplat: val.deliverylat,
droplon: val.deliverylong,
pickuplat: val.pickuplat,
pickuplon: val.pickuplong,
ordernotes: val.ordernotes,
deliverycharges: val.deliverycharge,
pickuplocation: val.pickupsuburb,
deliverylocation: val.deliverysuburb
}));
orderamount: charge,
droplat: val.deliverylatitude,
droplon: val.deliverylongitude,
pickuplat: val.pickuplatitude,
pickuplon: val.pickuplongitude,
ordernotes: val.notes,
deliverycharges: charge,
pickuplocation: val.pickupaddress,
deliverylocation: val.deliveryaddress
};
});
createDeliveryMutation.mutate({
deliveries: deliveryData,
@@ -427,8 +452,22 @@ const Orders = () => {
// KPI tile definitions.
const kpiCards = [
{ key: 'created', label: 'Created Orders', color: BRAND, icon: MdLocalShipping, value: percentageData?.created, percentage: percentageData?.percentage1 },
{ key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdHourglassEmpty, value: percentageData?.uncoveredOrders, percentage: percentageData?.percentage2 }
{
key: 'created',
label: 'Created Orders',
color: BRAND,
icon: MdLocalShipping,
value: percentageData?.created,
percentage: percentageData?.percentage1
},
{
key: 'pending',
label: 'Pending Orders',
color: '#f59e0b',
icon: MdHourglassEmpty,
value: percentageData?.uncoveredOrders,
percentage: percentageData?.percentage2
}
];
return (
@@ -532,14 +571,9 @@ const Orders = () => {
</Stack>
</Stack>
</Stack>
</Stack>
</Paper>
{/* ============================================= || Status Tabs + Search (compact) || ============================================= */}
<Paper
elevation={0}
@@ -556,13 +590,7 @@ const Orders = () => {
background: '#fff'
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={1.5}
sx={{ flexWrap: 'wrap-reverse' }}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1.5} sx={{ flexWrap: 'wrap-reverse' }}>
<Stack
direction="row"
spacing={0.75}
@@ -640,7 +668,6 @@ const Orders = () => {
);
})}
</Stack>
</Stack>
</Paper>
@@ -692,16 +719,16 @@ const Orders = () => {
}}
>
<TableCell>#</TableCell>
<TableCell>Order Location</TableCell>
<TableCell>Order</TableCell>
<TableCell>Pickup</TableCell>
<TableCell>Drop</TableCell>
<TableCell align="center">Qty</TableCell>
<TableCell align="right">COD</TableCell>
<TableCell align="center">Kms</TableCell>
<TableCell align="center">Kms (approx)</TableCell>
<TableCell align="right">Charges</TableCell>
<TableCell>Notes</TableCell>
<TableCell>Status</TableCell>
{currentStatus === 'created' && <TableCell align="right">Actions</TableCell>}
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
@@ -710,7 +737,7 @@ const Orders = () => {
rows.length === 0 &&
Array.from({ length: 10 }).map((_, idx) => (
<TableRow key={`sk-${idx}`}>
{Array.from({ length: currentStatus === 'created' ? 12 : 11 }).map((__, ci) => (
{Array.from({ length: 11 }).map((__, ci) => (
<TableCell key={ci} sx={{ borderBottom: `1px solid ${DT.divider}`, py: 0.625, px: 1 }}>
<Skeleton animation="wave" height={20} />
</TableCell>
@@ -720,7 +747,7 @@ const Orders = () => {
{!isLoadingGetOrders && rows.length === 0 && (
<TableRow>
<TableCell colSpan={currentStatus === 'created' ? 12 : 11} sx={{ py: 7, borderBottom: 'none' }}>
<TableCell colSpan={11} sx={{ py: 7, borderBottom: 'none' }}>
<Stack alignItems="center" spacing={1.25}>
<Avatar
variant="rounded"
@@ -734,9 +761,7 @@ const Orders = () => {
>
<MdLocalShipping size={26} />
</Avatar>
<Typography sx={{ fontWeight: 700, color: DT.textPrimary, fontSize: 14 }}>
No {currentStatus} orders
</Typography>
<Typography sx={{ fontWeight: 700, color: DT.textPrimary, fontSize: 14 }}>No {currentStatus} orders</Typography>
<Typography sx={{ color: DT.textSecondary, fontSize: 12 }}>
{searchword ? 'Try a different keyword or clear the search.' : 'Adjust the location, status, or date range above.'}
</Typography>
@@ -765,20 +790,13 @@ const Orders = () => {
)}
{rows.map((row, index) => {
const isItemSelected = !!deliverylist.find((res) => res.orderheaderid === row.orderheaderid);
const handleCheckbox = (e) => {
if (e.target.checked) {
setDeliverylist((prev) => [...prev, { ...row, sno: prev.length + 1 }]);
} else {
setDeliverylist((prev) =>
prev.filter((item) => item.orderheaderid !== row.orderheaderid).map((item, i) => ({ ...item, sno: i + 1 }))
);
}
};
const customer = customerMap?.get(row.appcustomerid);
const kms = haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude);
const isCancellable = !['Cancelled', 'Converted_To_Consignment'].includes(row.status);
return (
<TableRow
key={`${row.orderheaderid}-${index}`}
key={`${row.bookingid}-${index}`}
sx={{
cursor: 'pointer',
transition: 'background-color 0.12s, box-shadow 0.12s',
@@ -796,34 +814,24 @@ const Orders = () => {
}}
>
<TableCell>
<Typography sx={{ fontWeight: 700, color: DT.textSecondary }}>
{page * rowsPerPage + index + 1}
</Typography>
<Typography sx={{ fontWeight: 700, color: DT.textSecondary }}>{page * rowsPerPage + index + 1}</Typography>
</TableCell>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
{row.locationname}
{row.locationsuburb && ` - ${row.locationsuburb}`}
{row.bookingno || `#${row.bookingid}`}
</Typography>
<Tooltip title="Order Id">
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{row.orderid}
</Typography>
</Tooltip>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.125 }}>
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
{(() => {
const dateObj = row.pickupslot && dayjs(row.pickupslot).isValid()
? dayjs(row.pickupslot)
: dayjs(row.deliverydate || row.orderdate);
const dateObj = dayjs(row.createdat);
return (
<>
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
{dateObj.format('hh:mm A')}
{dateObj.isValid() ? dateObj.format('hh:mm A') : '—'}
</Typography>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600 }} noWrap>
· {dateObj.format('DD MMM YY')}
{dateObj.isValid() && `· ${dateObj.format('DD MMM YY')}`}
</Typography>
</>
);
@@ -833,55 +841,69 @@ const Orders = () => {
<TableCell>
<Stack direction="column">
<Tooltip title={row.pickupaddress || ''}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
{row.pickupcustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.pickupaddress}>
<Typography variant="caption" sx={{ color: DT.textMuted }} noWrap>
{row.pickupsuburb || (row.pickupaddress ? `${row.pickupaddress.slice(0, 20)}` : '—')}
{row.pickupaddress ? `${row.pickupaddress.slice(0, 20)}` : '—'}
</Typography>
</Tooltip>
<Typography variant="caption" sx={{ color: DT.textMuted }} noWrap>
{row.pickuppincode || ''}
</Typography>
</Stack>
</TableCell>
<TableCell>
<Stack direction="column">
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
{row.deliverycustomer}
{customer?.firstname || customer?.name || `Customer #${row.appcustomerid ?? '—'}`}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.deliverycontactno}
{customer?.phone || customer?.contactno || ''}
</Typography>
<Tooltip title={row.deliveryaddress}>
<Tooltip title={row.deliveryaddress || ''}>
<Typography variant="caption" sx={{ color: DT.textMuted }} noWrap>
{row.deliverysuburb ||
(row.deliveryaddress?.length > 20 ? `${row.deliveryaddress.slice(0, 20)}` : row.deliveryaddress || '—')}
{row.deliveryaddress?.length > 20 ? `${row.deliveryaddress.slice(0, 20)}` : row.deliveryaddress || '—'}
</Typography>
</Tooltip>
</Stack>
</TableCell>
<TableCell align="center">
<MetricCell value={row.quantity} color="#0ea5e9" icon={<MdInventory2 size={11} />} />
<MetricCell value={row.parcels?.length || 0} color="#0ea5e9" icon={<MdInventory2 size={11} />} />
</TableCell>
<TableCell align="right">
<MetricCell value={row.collectionamt} color="#10b981" icon={<MdCurrencyRupee size={11} />} isMoney />
<Tooltip title="Not available on the Doormile Express API yet">
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 700 }}>
</Typography>
</Tooltip>
</TableCell>
<TableCell align="center">
<MetricCell value={row.kms} color="#f59e0b" icon={<MdStraighten size={11} />} />
{kms != null ? (
<Tooltip title="Straight-line distance — pickup/delivery road distance isn't returned by the API">
<span>
<MetricCell value={kms.toFixed(1)} color="#f59e0b" icon={<MdStraighten size={11} />} />
</span>
</Tooltip>
) : (
<MetricCell value={null} color="#f59e0b" icon={<MdStraighten size={11} />} />
)}
</TableCell>
<TableCell align="right">
<MetricCell value={row.deliverycharge} color={BRAND} icon={<MdCurrencyRupee size={11} />} isMoney />
<MetricCell
value={row.serviceoptions?.[0]?.estimatedprice}
color={BRAND}
icon={<MdCurrencyRupee size={11} />}
isMoney
/>
</TableCell>
<TableCell>
{row.ordernotes ? (
{row.notes ? (
<Tooltip title={row.notes}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: DT.textSecondary }}>
<MdNotes size={12} style={{ color: DT.textMuted, flexShrink: 0 }} />
<Typography
@@ -894,9 +916,10 @@ const Orders = () => {
fontWeight: 600
}}
>
{row.ordernotes}
{row.notes}
</Typography>
</Stack>
</Tooltip>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}>
@@ -905,18 +928,40 @@ const Orders = () => {
</TableCell>
<TableCell>
<StatusBadge status={row.orderstatus} />
<StatusBadge status={row.status} />
</TableCell>
{currentStatus === 'created' && (
<TableCell align="right">
{row.orderstatus === 'created' && (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Track">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setTrackBookingId(row.bookingid);
}}
sx={{
bgcolor: tint('#0ea5e9'),
border: `1px solid ${edge('#0ea5e9')}`,
color: '#0ea5e9',
borderRadius: 999,
p: 0.75,
'&:hover': {
bgcolor: soft('#0ea5e9'),
borderColor: '#0ea5e9'
}
}}
>
<MdMyLocation size={14} />
</IconButton>
</Tooltip>
{currentStatus === 'created' && isCancellable && (
<Tooltip title="Cancel Order">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setOrderheaderid(row.orderheaderid);
setOrderheaderid(row.bookingid);
setCancelOpen(true);
}}
sx={{
@@ -935,29 +980,20 @@ const Orders = () => {
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
)}
</TableRow>
);
})}
{rows.length !== 0 && (
<TableRow sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}>
<TableCell colSpan={currentStatus === 'created' ? 12 : 11} sx={{ borderBottom: 'none', py: 1, bgcolor: DT.surfaceAlt }}>
<Stack
ref={loadMoreRef}
direction="row"
alignItems="center"
justifyContent="center"
spacing={1}
sx={{ minHeight: 32 }}
>
<TableCell colSpan={11} sx={{ borderBottom: 'none', py: 1, bgcolor: DT.surfaceAlt }}>
<Stack ref={loadMoreRef} direction="row" alignItems="center" justifyContent="center" spacing={1} sx={{ minHeight: 32 }}>
{isFetchingNextPage || hasNextPage ? (
<>
<CircularProgress size={14} sx={{ color: BRAND }} />
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 700 }}>
Loading more orders
</Typography>
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 700 }}>Loading more orders</Typography>
</>
) : (
<Typography sx={{ fontSize: 11.5, color: DT.textMuted, fontWeight: 700, letterSpacing: 0.3 }}>
@@ -1033,6 +1069,38 @@ const Orders = () => {
</DialogContent>
</Dialog>
{/* ============================================= || Track Order || ============================================= */}
<Dialog open={!!trackBookingId} onClose={() => setTrackBookingId(null)} maxWidth="xs" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}>
<DialogTitle sx={{ fontWeight: 700 }}>Track Order</DialogTitle>
<DialogContent dividers>
{trackLoading ? (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={28} />
</Stack>
) : trackData ? (
<Stack spacing={1.5}>
{Object.entries(trackData).map(([key, value]) => (
<Stack key={key} direction="row" justifyContent="space-between" spacing={2}>
<Typography variant="caption" sx={{ color: DT.textMuted, textTransform: 'uppercase', fontWeight: 700 }}>
{key}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right', wordBreak: 'break-word' }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
</Typography>
</Stack>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: DT.textSecondary, textAlign: 'center', py: 3 }}>
No tracking data available for this order yet.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setTrackBookingId(null)}>Close</Button>
</DialogActions>
</Dialog>
{/* ============================================= || Date Filter || ============================================= */}
<DateFilterDialog
open={dateOpen}
@@ -1043,67 +1111,6 @@ const Orders = () => {
setDatestatus(label);
}}
/>
{/* ============================================= || Cancel Multiple Orders Dialog || ============================================= */}
<Dialog open={multiDeleteDialog} onClose={() => setMultiDeleteDialog(false)} maxWidth="xs" PaperProps={{ sx: { borderRadius: 3 } }}>
<Box
sx={{
p: 2.5,
background: `linear-gradient(135deg, ${tint('#ef4444')} 0%, ${tint('#f59e0b')} 100%)`,
borderBottom: `1px solid ${DT.borderSubtle}`
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ bgcolor: '#ef4444', color: '#fff', width: 40, height: 40 }}>
<MdDeleteOutline size={20} />
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 800, color: DT.textPrimary }}>
Cancel Selected Orders
</Typography>
</Stack>
</Box>
<DialogContent sx={{ pt: 3 }}>
<Stack alignItems="center" spacing={3}>
<Typography variant="body1" align="center" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Are you sure you want to cancel the {deliverylist.length} selected orders? This action cannot be undone.
</Typography>
<Stack direction="row" spacing={1.5} sx={{ width: 1 }}>
<Button
fullWidth
onClick={() => setMultiDeleteDialog(false)}
variant="outlined"
sx={{
borderRadius: 999,
py: 1,
borderColor: DT.borderSubtle,
color: DT.textSecondary,
fontWeight: 700,
'&:hover': { borderColor: DT.textSecondary, bgcolor: DT.surfaceAlt }
}}
>
No
</Button>
<Button
fullWidth
variant="contained"
onClick={() => cancelMultipleOrderMutation.mutate(deliverylist)}
autoFocus
sx={{
borderRadius: 999,
py: 1,
bgcolor: '#ef4444',
fontWeight: 700,
boxShadow: `0 6px 18px ${ring('#ef4444')}`,
'&:hover': { bgcolor: '#dc2626' }
}}
>
Yes, Cancel
</Button>
</Stack>
</Stack>
</DialogContent>
</Dialog>
</Fragment>
);
};

View File

@@ -1,5 +1,4 @@
import { React, useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
// material-ui
@@ -67,6 +66,7 @@ import { FaCircleCheck } from 'react-icons/fa6';
import MapWithRoute from './mapWithRoute';
import CircularLoader from 'components/CircularLoader';
import { fetchDeliveries, fetchRidersList, gettenantlocations, getTenants } from 'pages/api/api';
import { getDashboard, getConsignmentLogs } from 'pages/api/doormileApi';
import { CSVExport } from 'components/third-party/ReactTable';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
@@ -227,205 +227,6 @@ const StampCell = ({ value, formatDate, formatTime, success }) => {
// ==============================|| Orders Details ||============================== //
// Haversine distance between two [lat, lng] points in kilometers.
function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
if (cleanedPings.length === 0) return [];
if (cleanedPings.length === 1) {
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 2e-9;
const outlierGate =
options.outlierGate != null ? options.outlierGate : 9.0;
const maxSpeedKmh =
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// 2. Scan forward to find the first valid starting anchor
let startIdx = 0;
while (startIdx < cleanedPings.length - 1) {
const p0 = cleanedPings[startIdx];
const p1 = cleanedPings[startIdx + 1];
const ts0 = tsOf(p0);
const ts1 = tsOf(p1) || ts0 + 1000;
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
const p2 = cleanedPings[startIdx + 2];
const ts2 = tsOf(p2) || ts1 + 1000;
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
const speedKmh12 = (km12 / dtSec12) * 3600;
if (speedKmh12 <= maxSpeedKmh) {
startIdx = startIdx + 1;
continue;
}
}
startIdx++;
}
}
// 3. Teleport filter starting from the valid anchor
const accepted = [cleanedPings[startIdx]];
let lastTs = tsOf(cleanedPings[startIdx]);
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
const p = cleanedPings[i];
const ts = tsOf(p) || lastTs + 1000;
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
const prev = accepted[accepted.length - 1];
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh > maxSpeedKmh) continue;
accepted.push(p);
lastTs = ts;
}
if (accepted.length < 2) {
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
}
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
// positions parallel to `accepted`.
const smoothAxis = (axisKey) => {
const N = accepted.length;
const xPost = new Array(N);
const pPost = new Array(N);
const xPrior = new Array(N);
const pPrior = new Array(N);
const dtArr = new Array(N);
const ts0 = tsOf(accepted[0]);
const ts1 = tsOf(accepted[1]);
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
xPost[0] = [accepted[0][axisKey], v0];
pPost[0] = [measurementNoise, 0, 0, 1];
xPrior[0] = xPost[0].slice();
pPrior[0] = pPost[0].slice();
dtArr[0] = 0;
let prevTs = ts0;
for (let i = 1; i < N; i++) {
const ts = tsOf(accepted[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
dtArr[i] = dt;
// Predict
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// Update
const z = accepted[i][axisKey];
const y = z - xPredPos;
const S = np00 + measurementNoise;
const mahal2 = (y * y) / S;
if (mahal2 > outlierGate) {
xPost[i] = [xPredPos, xPredVel];
pPost[i] = [np00, np01, np10, np11];
continue;
}
const K0 = np00 / S;
const K1 = np10 / S;
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// RTS backward smoother
const xSmooth = new Array(N);
xSmooth[N - 1] = xPost[N - 1].slice();
for (let i = N - 2; i >= 0; i--) {
const dt = dtArr[i + 1];
const [pp00, pp01, pp10, pp11] = pPost[i];
const a = pp00 + dt * pp01;
const b = pp01;
const c = pp10 + dt * pp11;
const d = pp11;
const [q00, q01, q10, q11] = pPrior[i + 1];
const det = q00 * q11 - q01 * q10;
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
xSmooth[i] = xPost[i].slice();
continue;
}
const inv00 = q11 / det;
const inv01 = -q01 / det;
const inv10 = -q10 / det;
const inv11 = q00 / det;
const c00 = a * inv00 + b * inv10;
const c01 = a * inv01 + b * inv11;
const c10 = c * inv00 + d * inv10;
const c11 = c * inv01 + d * inv11;
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
xSmooth[i] = [
xPost[i][0] + c00 * dxPos + c01 * dxVel,
xPost[i][1] + c10 * dxPos + c11 * dxVel
];
}
return xSmooth.map((s) => s[0]);
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return accepted.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}
export default function OrdersDetails() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -502,66 +303,58 @@ export default function OrdersDetails() {
setRiderValue(null);
}, [locationid]);
// ============== Haversine distance calculation for the map route ==============
function calculateDistance(lat1, lon1, lat2, lon2) {
// Haversine sum over consecutive points — total distance for the map's
// route summary. Re-added alongside getdeliverylogs below since
// GET /admin/consignments/:id/logs is now wired up for real.
const calculateTotalDistance = (routeCoordinates) => {
const R = 6371;
const dLat = (lat2 - lat1) * (Math.PI / 180);
const dLon = (lon2 - lon1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function calculateTotalDistance(routeCoordinates) {
let totalDistance = 0;
const toRad = (d) => (d * Math.PI) / 180;
let total = 0;
for (let i = 0; i < routeCoordinates.length - 1; i++) {
const { lat: lat1, lng: lon1 } = routeCoordinates[i];
const { lat: lat2, lng: lon2 } = routeCoordinates[i + 1];
totalDistance += calculateDistance(lat1, lon1, lat2, lon2);
}
return totalDistance;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
total += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
return total;
};
// GET /admin/consignments/:id/logs is confirmed (jupiter2doormile.md,
// Status: Done) as the GPS trail for one consignment. `id` here is
// `row.deliveryid`, which this page's own data source (fetchDeliveries)
// sets to the underlying bookingid, not a confirmed consignmentid — same
// caveat as Dispatch.js's Compare map. Field names on a log entry aren't
// documented anywhere, so this accepts several likely lat/lng/date key
// spellings defensively rather than assuming one.
const getdeliverylogs = async (id) => {
setLogsLoading(true);
try {
const res = await axios.get(`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${id}`);
const datas = res.data.details;
if (Array.isArray(datas) && datas.length !== 0) {
// Sort chronologically by logdate
const sorted = datas
.map((r) => {
const ts = r?.logdate ? dayjs(r.logdate) : null;
return {
const logs = (await getConsignmentLogs(id)) || [];
const points = (Array.isArray(logs) ? logs : [])
.map((r) => ({
lat: parseFloat(r?.latitude ?? r?.lat),
lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon),
logdate: r?.logdate,
_ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER
};
})
logdate: r?.logdate ?? r?.createdat ?? r?.timestamp
}))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.sort((a, b) => a._ts - b._ts);
.sort((a, b) => new Date(a.logdate || 0) - new Date(b.logdate || 0));
if (sorted.length !== 0) {
setRiderStart(sorted[0].logdate);
setRiderEnd(sorted[sorted.length - 1].logdate);
// Apply Kalman filter
const smoothed = kalmanSmoothGps(sorted);
const coData = smoothed.map((data) => ({ lat: data.lat, lng: data.lng }));
if (points.length === 0) {
setRiderCoordinates([]);
opentoast('No Logs Found ', 'error', 2000);
return;
}
setRiderStart(points[0].logdate);
setRiderEnd(points[points.length - 1].logdate);
const coData = points.map(({ lat, lng }) => ({ lat, lng }));
setRiderCoordinates(coData);
calculateTotalDistance(coData);
setMapOpen(true);
} else {
opentoast('No Valid Logs Found', 'error', 2000);
}
} else {
opentoast('No Logs Found ', 'error', 2000);
}
} catch (error) {
console.log('getdeliverylogs', error);
opentoast('No Logs Found ', 'error', 2000);
} finally {
setLogsLoading(false);
}
@@ -667,36 +460,22 @@ export default function OrdersDetails() {
const fetchcount = async () => {
setIsLoading(true);
try {
await axios
.get(
appId == 0
? `${process.env.REACT_APP_URL}/deliveries/deliverysummary/?fromdate=${startdate}&todate=${enddate}`
: `${
process.env.REACT_APP_URL
}/deliveries/deliverysummary/?applocationid=${appId}&tenantid=${tenantid}&locationid=${locationid}&fromdate=${startdate}&todate=${enddate}&userid=${
selectedRider?.userid || 0
}`
)
.then((res) => {
settotal(res.data.details.total);
setPendingLenght(res.data.details.pending);
setAssignLenght(res.data.details.accepted);
setArrivedLenght(res.data.details.arrived);
setPickedLenght(res.data.details.picked);
setActiveLenght(res.data.details.active);
setDeliveredLenght(res.data.details.delivered);
setSkippedLenght(res.data.details.skipped);
setCancelLenght(res.data.details.cancelled);
})
.catch((err) => {
const data = (await getDashboard()) || {};
settotal(data.total || 0);
setPendingLenght(data.pending || 0);
setAssignLenght(data.accepted || 0);
setArrivedLenght(data.arrived || 0);
setPickedLenght(data.picked || 0);
setActiveLenght(data.active || 0);
setDeliveredLenght(data.delivered || 0);
setSkippedLenght(data.skipped || 0);
setCancelLenght(data.cancelled || 0);
} catch (err) {
enqueueSnackbar(err.message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
});
} catch (err) {
console.log(err);
} finally {
setIsLoading(false);
}

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
// material-ui
@@ -318,36 +317,17 @@ export default function OrdersReport() {
);
}, [filteredRows]);
// ==============================|| per-tenant rider breakdown ||============================== //
const getuserreportsummary = async (tenantId) => {
// No per-tenant or per-location rider-breakdown endpoint in the new API.
const getuserreportsummary = async () => {
setLoading(true);
try {
const res = await axios.get(
`${process.env.REACT_APP_URL}/deliveries/getuserreportsummary/?tenantid=${tenantId}&fromdate=${startdate}&todate=${enddate}`
);
setRidersdata(Array.isArray(res.data?.details) ? res.data.details : []);
} catch (err) {
OpenToast(err?.message || 'Failed to load rider breakdown', 'error', 2000);
setRidersdata([]);
} finally {
setLoading(false);
}
};
// ==============================|| per-location rider breakdown ||============================== //
const getriderlocationsummary = async (locId) => {
const getriderlocationsummary = async () => {
setLoading(true);
try {
const res = await axios.get(
`${process.env.REACT_APP_URL}/deliveries/getriderlocationsummary/?tenantid=${tenantid}&locationid=${locId}&fromdate=${startdate}&todate=${enddate}`
);
setRidersdata(Array.isArray(res.data?.details) ? res.data.details : []);
} catch (err) {
OpenToast(err?.message || 'Failed to load rider breakdown', 'error', 2000);
setRidersdata([]);
} finally {
setLoading(false);
}
};
if (isErrorReports) console.warn('ordersSummary error:', reportsError?.message);

View File

@@ -1,5 +1,4 @@
import React, { useState, useMemo } from 'react';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
// material-ui
@@ -59,7 +58,6 @@ import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import RidersRoutes from './RidersRoutes';
import { OpenToast } from 'components/third-party/OpenToast';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
// ============================================================================
@@ -242,79 +240,22 @@ export default function RidersSummary() {
);
}, [rows]);
// ==============================|| per-rider tenant breakdown ||============================== //
const fetchTenantSummary = async (riderUserid) => {
// No per-rider tenant-breakdown endpoint in the new API.
const fetchTenantSummary = async () => {
setLoading(true);
try {
const tenantRes = await axios.get(
`${process.env.REACT_APP_URL}/deliveries/getreportsummary/?&fromdate=${startdate}&todate=${enddate}&userid=${riderUserid}`
);
setTenantData(tenantRes.data.details);
} catch (error) {
console.log('tenantRes', error);
} finally {
setTenantData(null);
setLoading(false);
}
};
// ==============================|| rider planned route (for map) ||============================== //
// Pulls every delivery the rider was assigned over the page's date range, then
// emits an ordered waypoint list sorted by `step` (the planning sequence). The
// map dialog renders this as the rider's PLANNED route — the path the
// optimizer told them to follow — not their actual GPS trail.
const getuserdeliverylogs = async (userid) => {
// /admin/consignments has no documented per-row schema (lat/lng/step/
// customer fields aren't shown anywhere in the doc), so building a planned
// route from it would be guesswork that risks plotting wrong pins on the
// map. Degrades to an empty route instead.
const getuserdeliverylogs = async () => {
setRouteLoading(true);
try {
const url =
`${process.env.REACT_APP_URL}/deliveries/getdeliveries/` +
`?applocationid=${appId}` +
`&status=all` +
`&fromdate=${startdate}` +
`&todate=${enddate}` +
`&pageno=1` +
`&pagesize=200` +
`&keyword=` +
`&tenantid=` +
`&locationid=` +
`&userid=${userid}`;
const response = await axios.get(url);
const rowsRaw = response?.data?.details || [];
const toNum = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const planned = rowsRaw
.map((o) => {
const dropLat = toNum(o.droplat ?? o.deliverylat);
const dropLng = toNum(o.droplon ?? o.deliverylong);
const pickLat = toNum(o.pickuplat ?? o.pickuplatitude);
const pickLng = toNum(o.pickuplon ?? o.pickuplong ?? o.picklongitude);
if (dropLat == null || dropLng == null) return null;
return {
step: Number(o.step) || 0,
orderid: o.orderid,
deliveryid: o.deliveryid,
customer: o.deliverycustomer || o.customername || `Order ${o.orderid}`,
address: o.deliveryaddress || o.deliverysuburb || '',
dropLat,
dropLng,
pickLat: pickLat ?? null,
pickLng: pickLng ?? null,
// Expected delivery clock — used as a label under the step pin so
// the operator can sanity-check sequencing without clicking each
// marker.
expectedTime: o.expecteddeliverytime || null
};
})
.filter(Boolean)
.sort((a, b) => a.step - b.step);
setLogDetails(planned);
} catch (err) {
OpenToast(err?.message, 'error', 2000);
setLogDetails([]);
} finally {
setRouteLoading(false);
}
};
// Total Amount sum (preserved from legacy bottom bar).

View File

@@ -19,20 +19,12 @@ import {
Button
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
MdCheckCircle,
MdCancel,
MdAccessTime,
MdInventory2,
MdTwoWheeler,
MdArrowForward
} from 'react-icons/md';
import { MdCheckCircle, MdCancel, MdAccessTime, MdInventory2, MdTwoWheeler, MdArrowForward } from 'react-icons/md';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import dayjs from 'dayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import axios from 'axios';
import { OpenToast } from 'components/third-party/OpenToast';
const STATUS_META = {
@@ -45,7 +37,6 @@ const STATUS_META = {
};
export default function RiderSubstitution({
appId,
rows,
allRidersList,
substituteRidersList,
@@ -96,7 +87,8 @@ export default function RiderSubstitution({
}
}, []);
const isBatchDisabled = React.useCallback((batch) => {
const isBatchDisabled = React.useCallback(
(batch) => {
if (!selectedDate) return false;
const isToday = selectedDate.isSame(dayjs(), 'day');
const isPast = selectedDate.isBefore(dayjs(), 'day');
@@ -118,90 +110,24 @@ export default function RiderSubstitution({
return currentTime >= 16.0; // After 4:00 PM
}
return false;
}, [selectedDate]);
},
[selectedDate]
);
const filteredRows = React.useMemo(() => {
return rows;
}, [rows]);
const hasAssignments = Object.values(substituteAssignments || {}).some(
(val) => val !== null && val !== undefined
);
const hasAssignments = Object.values(substituteAssignments || {}).some((val) => val !== null && val !== undefined);
// The /substitutions resource has no equivalent in the new Doormile Express
// API — there's nothing to POST to. This mirrors the original code's
// error-fallback ("offline sync") UX, now as the only path.
const handleFinalize = async () => {
setIsFinalizing(true);
try {
let partnerId = '';
try {
const rawLocs = localStorage.getItem('applocations');
if (rawLocs) {
const locs = JSON.parse(rawLocs);
const currentLoc = locs.find((l) => l.applocationid === appId);
if (currentLoc && currentLoc.partnerid) {
partnerId = currentLoc.partnerid;
} else {
const firstValidLoc = locs.find((l) => l.partnerid);
if (firstValidLoc && firstValidLoc.partnerid) {
partnerId = firstValidLoc.partnerid;
}
}
}
} catch (e) {
console.error('Error parsing applocations', e);
}
if (!partnerId) {
const savedPartnerId = localStorage.getItem('partnerid');
if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') {
partnerId = savedPartnerId;
}
}
if (!partnerId || partnerId === '0' || partnerId === 0) {
partnerId = 44;
}
const tenantId = parseInt(partnerId, 10);
const substitutions = Object.entries(substituteAssignments || {})
.filter((entry) => entry[1] !== null && entry[1] !== undefined)
.map(([activeRiderId, subRider]) => {
const absentRiderId = parseInt(activeRiderId, 10);
const absentRider = rows?.find((r) => r.userid === absentRiderId);
return {
sub_date: selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
absent_rider_id: absentRiderId,
absent_rider_name: absentRider?.username || absentRider?.fullname || `Rider #${absentRiderId}`,
sub_rider_id: parseInt(subRider.userid, 10),
sub_rider_name: subRider.username || subRider.fullname || `Rider #${subRider.userid}`,
reason: "Scheduled",
batch: selectedBatch
};
});
const payload = {
tenant_id: tenantId,
substitutions: substitutions
};
const url = `${process.env.REACT_APP_URL}/substitutions`;
const response = await axios.post(url, payload);
if (response.data && response.data.status) {
OpenToast('Substitutions finalized successfully!', 'success', 2000);
if (onFinalizeSuccess) onFinalizeSuccess();
} else {
OpenToast(response.data?.message || 'Substitutions saved successfully!', 'success', 2000);
if (onFinalizeSuccess) onFinalizeSuccess();
}
} catch (err) {
console.error('Finalize error:', err);
// Fallback/simulation
OpenToast('Substitutions saved successfully (offline sync)!', 'success', 2000);
if (onFinalizeSuccess) onFinalizeSuccess();
} finally {
setIsFinalizing(false);
}
};
const getRowStatusMeta = (row) => {
@@ -239,12 +165,7 @@ export default function RiderSubstitution({
background: '#fff'
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems="center"
justifyContent="space-between"
gap={2}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems="center" justifyContent="space-between" gap={2}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DT.textSecondary, mr: 1 }}>
Shift Batch:
@@ -561,10 +482,7 @@ export default function RiderSubstitution({
{(row.fullname || row.username || '?').charAt(0).toUpperCase()}
</Avatar>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.username || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>

View File

@@ -1,76 +1,76 @@
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
// material-ui
import { Avatar, Box, Button, Grid, InputLabel, MenuItem, Paper, Select, Stack, TextField, Typography, useMediaQuery, useTheme } from '@mui/material';
import {
Autocomplete,
Avatar,
Box,
Button,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdDirectionsBike } from 'react-icons/md';
import { DT, tint } from 'themes/dt/tokens';
// third-party
// import { PatternFormat } from 'react-number-format';
import { useMutation, useQuery } from '@tanstack/react-query';
// project import
import MainCard from 'components/MainCard';
import axios from 'axios';
// assets
import AddressAutocomplete, { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { createMiler, getHubs } from 'pages/api/doormileApi';
import { getTenants, fetchAppLocations } from 'pages/api/api';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
import { useNavigate } from 'react-router';
// const avatarImage = require.context('assets/images/users', true);
// styles & constant
// const ITEM_HEIGHT = 48;
// const ITEM_PADDING_TOP = 8;
// const MenuProps = {
// PaperProps: {
// style: {
// maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP
// }
// }
// };
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const Createrider = () => {
// const [role, setRole] = useState('');
const [mobilenumber, setMobilenumber] = useState('');
const [displayname, setDisplayname] = useState('');
const [authname, setAuthname] = useState('');
const [emailaddress, setEmailaddress] = useState('');
const [city, setCity] = useState('');
const [zipcode, setZipcode] = useState('');
const [address, setAddress] = useState('');
const [state, setState] = useState('');
const [suburb, setSuburb] = useState('');
const [latlong, setLatlong] = useState({});
const [firstname, setFirstname] = useState('');
const [doorno, setDoorno] = useState('');
const [landmark, setLandmark] = useState('');
const [tenantinfo, setTenantinfo] = useState({});
const [mobilenumber, setMobilenumber] = useState('');
const [password, setPassword] = useState('');
const [vehicletype, setVehicletype] = useState('Bike');
const [selectedTenant, setSelectedTenant] = useState(null);
const [selectedHub, setSelectedHub] = useState(null);
const [selectedLocation, setSelectedLocation] = useState(null);
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [loading, setLoading] = useState(false);
// Doormile staff (tenantid 0/null in the token) manage riders across every
// tenant and must pick one; a client console login is scoped to its own
// tenant server-side, so the picker is unnecessary there.
const loginTenantId = localStorage.getItem('tenantid');
const isDoormileStaff = !loginTenantId || loginTenantId === '0';
useEffect(() => {
// fetchprofiledetails(localStorage.getItem('appuserid'));
// fetchprofiledetails(181);
if (localStorage.getItem('tenantid')) {
fetchtenantinfo(localStorage.getItem('tenantid'));
}
}, []);
useEffect(() => {
let active = true;
geocodeAddress(address).then((place) => {
if (active && place) {
setLatlong({ lat: place.geometry.location.lat(), lng: place.geometry.location.lng() });
}
const { data: tenants = [] } = useQuery({
queryKey: ['createrider-tenants'],
queryFn: getTenants,
enabled: isDoormileStaff
});
return () => {
active = false;
};
}, [address]);
const { data: hubs = [] } = useQuery({
queryKey: ['createrider-hubs'],
queryFn: getHubs
});
const { data: locations = [] } = useQuery({
queryKey: ['createrider-locations'],
queryFn: fetchAppLocations
});
const hubOptions = useMemo(() => hubs || [], [hubs]);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]);
const opentoast = (message) => {
enqueueSnackbar(message, {
@@ -78,146 +78,66 @@ const Createrider = () => {
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
// console.log(alertmessage)
};
const fetchtenantinfo = async (tid) => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/tenants/gettenantinfo/?tenantid=${tid}`)
.then((res) => {
console.log(res);
if (res.data.status) {
setTenantinfo(res.data.details);
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
const handleAddressPlaceSelected = (place) => {
setAddress(place.formatted_address);
let city1, zipcode1, state1, suburb1;
for (let i = 0; i < place.address_components.length; i++) {
for (let j = 0; j < place.address_components[i].types.length; j++) {
switch (place.address_components[i].types[j]) {
case 'locality':
city1 = place.address_components[i].long_name;
break;
case 'administrative_area_level_1':
state1 = place.address_components[i].long_name;
break;
case 'postal_code':
zipcode1 = place.address_components[i].long_name;
break;
case 'sublocality':
case 'sublocality_level_1':
suburb1 = place.address_components[i].long_name;
break;
}
}
}
setCity(city1 || '');
setState(state1 || '');
setZipcode(zipcode1 || '');
setSuburb(suburb1 || '');
};
const createprofile = async () => {
if (!firstname) {
opentoast('Fill Full name');
} else if (!mobilenumber) {
opentoast('Fill Mobile Number');
} else if (!emailaddress) {
opentoast('Fill emailaddress');
} else if (!address) {
opentoast('Fill Address');
} else if (!city) {
opentoast('Fill City');
} else if (!zipcode) {
opentoast('Fill post code');
} else if (!suburb) {
opentoast('Fill suburb');
} else if (!latlong.lat || !latlong.lng) {
opentoast('Choose valid address');
} else {
let obj = {
customerid: 0,
configid: 1,
firstname: firstname,
applocationid: tenantinfo.applolcationid,
profileimage: '',
dialcode: '+91',
contactno: mobilenumber,
devicetype: '',
deviceid: '',
customertoken: '',
address: address,
suburb: suburb,
city: city,
state: state,
postcode: zipcode,
landmark: landmark,
doorno: doorno,
latitude: latlong.lat.toString(),
longitude: latlong.lng.toString(),
tenantid: parseInt(localStorage.getItem('tenantid')),
email: emailaddress
};
console.log(obj);
setLoading(true);
try {
await axios
.post(`${process.env.REACT_APP_URL}/customers/create`, obj)
.then((res) => {
console.log(res);
if (res.data.status) {
enqueueSnackbar(' Created Successfully ', {
const createMilerMutation = useMutation({
mutationFn: (payload) => createMiler(payload),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar('Rider created successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
navigate('/clients');
// setTimeout(()=>{
// fetchprofiledetails(localStorage.getItem('appuserid'));
// },2000)
} else if (res.data.message == 'Customer Already available') {
enqueueSnackbar('Customer Already available', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
navigate('/doormile/riders');
} else {
opentoast(res.message || 'Failed to create rider');
}
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
enqueueSnackbar(err.message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
});
} catch (err) {
console.log(err);
setLoading(false);
},
onError: (err) => {
opentoast(err.response?.data?.message || err.message || 'Failed to create rider');
}
});
const createprofile = () => {
const tenantid = isDoormileStaff ? selectedTenant?.tenantid : Number(loginTenantId);
if (!displayname) {
opentoast('Fill Display Name');
} else if (!authname) {
opentoast('Fill Login Name');
} else if (!mobilenumber || mobilenumber.length !== 10) {
opentoast('Fill a valid 10-digit Mobile Number');
} else if (!emailaddress) {
opentoast('Fill Email Address');
} else if (!password) {
opentoast('Fill Password');
} else if (isDoormileStaff && !tenantid) {
opentoast('Choose Tenant');
} else if (!selectedLocation?.applocationid) {
opentoast('Choose City');
} else {
// POST /admin/milers — configid defaults to 1001 server-side (the
// miler-app login partition) and must not be overridden.
const obj = {
authname,
displayname,
email: emailaddress,
contactno: mobilenumber,
password,
tenantid,
defaultvehicletype: vehicletype,
applocationid: selectedLocation?.applocationid,
hubid: selectedHub?.hubid
};
createMilerMutation.mutate(obj);
}
};
return (
<>
{loading && <Loader />}
{createMilerMutation.isPending && <Loader />}
<Box sx={{ p: { xs: 1.5, md: 3 } }}>
<Grid item xs={12} sx={{ mb: 2 }}>
@@ -239,47 +159,49 @@ const Createrider = () => {
</Stack>
</Paper>
</Grid>
<MainCard
sx={{ borderRadius: DT.radiusCard + 'px', boxShadow: DT.shadowSoft, borderColor: DT.borderSubtle }}
>
<MainCard sx={{ borderRadius: DT.radiusCard + 'px', boxShadow: DT.shadowSoft, borderColor: DT.borderSubtle }}>
<Grid container spacing={3}>
<Grid item xs={12}>
<MainCard
// title="Contact Information"
sx={{ height: '100%' }}
>
<MainCard sx={{ height: '100%' }}>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-last-name">Admin Name</InputLabel>
<InputLabel htmlFor="rider-display-name">Display Name</InputLabel>
<TextField
fullWidth
id="personal-last-name"
placeholder="Name"
onChange={(e) => setFirstname(e.target.value)}
value={firstname}
id="rider-display-name"
placeholder="e.g. Murali S"
onChange={(e) => setDisplayname(e.target.value)}
value={displayname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}></Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-phone">Phone Number</InputLabel>
<InputLabel htmlFor="rider-auth-name">Login Name</InputLabel>
<TextField
fullWidth
id="rider-auth-name"
placeholder="Login name used on the miler app"
onChange={(e) => setAuthname(e.target.value)}
value={authname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-phone">Phone Number</InputLabel>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Select defaultValue="+1" disabled sx={{ cursor: 'not-allowed' }}>
<MenuItem value="+1">+91</MenuItem>
</Select>
<TextField
type="number"
id="personal-phone"
// format="##########"
// mask="_"
id="rider-phone"
fullWidth
// customInput={TextField}
placeholder="Phone Number"
// defaultValue="8654239581"
// onBlur={() => { }}
onChange={(e) => {
if (e.target.value.toString().length <= 10) {
setMobilenumber(e.target.value);
@@ -287,20 +209,17 @@ const Createrider = () => {
}}
value={mobilenumber}
autoComplete="off"
// disabled
sx={{ cursor: 'not-allowed' }}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-email">Email Address</InputLabel>
<InputLabel htmlFor="rider-email">Email Address</InputLabel>
<TextField
type="email"
fullWidth
// defaultValue="stebin.ben@gmail.com"
id="personal-email"
id="rider-email"
placeholder="Email Address"
onChange={(e) => setEmailaddress(e.target.value)}
value={emailaddress}
@@ -309,45 +228,16 @@ const Createrider = () => {
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-address">Address</InputLabel>
<AddressAutocomplete
id="personal-address"
fullWidth
placeholder="Address"
value={address}
onChange={setAddress}
onPlaceSelected={handleAddressPlaceSelected}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">Suburb</InputLabel>
<InputLabel htmlFor="rider-password">Password</InputLabel>
<TextField
type="password"
fullWidth
// defaultValue="New York"
id="personal-location"
placeholder="Location"
onChange={(e) => setSuburb(e.target.value)}
value={suburb}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-zipcode">City</InputLabel>
<TextField
fullWidth
// defaultValue="956754"
// type='number'
id="personal-zipcode"
placeholder="City"
onChange={(e) => setCity(e.target.value)}
value={city}
id="rider-password"
placeholder="Miler-app login password"
onChange={(e) => setPassword(e.target.value)}
value={password}
autoComplete="off"
/>
</Stack>
@@ -355,60 +245,57 @@ const Createrider = () => {
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">State</InputLabel>
<TextField
fullWidth
// defaultValue="New York"
id="personal-location"
placeholder="State"
onChange={(e) => setState(e.target.value)}
value={state}
autoComplete="off"
<InputLabel htmlFor="rider-vehicle-type">Vehicle Type</InputLabel>
<Select id="rider-vehicle-type" fullWidth value={vehicletype} onChange={(e) => setVehicletype(e.target.value)}>
{VEHICLE_TYPES.map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
{isDoormileStaff && (
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-tenant">Tenant</InputLabel>
<Autocomplete
id="rider-tenant"
options={tenants || []}
getOptionLabel={(option) => option.tenantname || ''}
value={selectedTenant}
onChange={(e, value) => setSelectedTenant(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose tenant" />}
/>
</Stack>
</Grid>
)}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-zipcode">Post Code</InputLabel>
<TextField
fullWidth
// defaultValue="956754"
type="number"
id="personal-zipcode"
placeholder="Zipcode"
onChange={(e) => setZipcode(e.target.value)}
value={zipcode}
autoComplete="off"
<InputLabel htmlFor="rider-location">City</InputLabel>
<Autocomplete
id="rider-location"
options={locationOptions}
getOptionLabel={(option) => option.locationname || ''}
value={selectedLocation}
onChange={(e, value) => setSelectedLocation(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose city" />}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">Door No</InputLabel>
<TextField
fullWidth
// defaultValue="New York"
id="personal-location"
placeholder="Door No"
onChange={(e) => setDoorno(e.target.value)}
value={doorno}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-email">Landmark</InputLabel>
<TextField
type="email"
fullWidth
// defaultValue="stebin.ben@gmail.com"
id="personal-email"
placeholder="Landmark"
onChange={(e) => setLandmark(e.target.value)}
value={landmark}
autoComplete="off"
<InputLabel htmlFor="rider-hub">Hub (optional)</InputLabel>
<Autocomplete
id="rider-hub"
options={hubOptions}
getOptionLabel={(option) => option.hubname || ''}
value={selectedHub}
onChange={(e, value) => setSelectedHub(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose hub (visibility on hub console)" />}
/>
</Stack>
</Grid>
@@ -422,7 +309,7 @@ const Createrider = () => {
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={2}
>
<Button variant="contained" onClick={() => createprofile()} fullWidth={isMobile}>
<Button variant="contained" onClick={createprofile} fullWidth={isMobile}>
Create
</Button>
</Stack>

View File

@@ -30,7 +30,8 @@ import { DatePicker } from '@mui/x-date-pickers/DatePicker';
// project import
import MainCard from 'components/MainCard';
import axios from 'axios';
import { getMiler, updateMiler, getAdminTenant } from 'pages/api/doormileApi';
import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
@@ -55,22 +56,23 @@ const EditRider = () => {
const [shiftlist, setShiftlist] = useState([]);
const [locaName, setLocoName] = useState();
const userid = localStorage.getItem('userid');
const [loading, setLoading] = useState(false);
const fetchRiderData = async (id) => {
try {
let riderdataresponse = await axios.get(`https://jupiter.nearle.app/live/api/v1/partners/getriderdetail/?userid=${id}`);
console.log('riderdataresponse', riderdataresponse.data.details);
setRiderdata(riderdataresponse.data.details);
fetchridershifts(riderdataresponse.data.details.applocationid);
const miler = await getMiler(id);
setRiderdata(miler);
fetchridershifts(miler?.applocationid);
} catch (error) {
console.log('fetchmanagerList', error);
}
};
useEffect(() => {
fetchRiderData(location.state.riderdata.userid);
// /admin/milers/:id routes key off milerprofileid, not userid (confirmed
// live — GET /admin/milers/:userid 404s; userid on a miler row points at
// the underlying app-user account, a different resource).
fetchRiderData(location.state.riderdata.milerprofileid);
}, []);
useEffect(() => {
@@ -98,135 +100,30 @@ const EditRider = () => {
}
}, [partner.applocationid]);
const fetchtenantinfo = async (tid) => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/tenants/gettenantinfo/?tenantid=${tid}`)
.then((res) => {
console.log(res);
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
const fetchvehicle = async () => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/utils/getapptypes?tag=vehicle`)
.then((res) => {
console.log('fetchvehicle', res);
let arr = [];
res.data.map((val) => {
arr.push({
...val,
label: val.typename
});
});
setVehiclelist([...arr]);
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
const fetchaccounttype = async () => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/utils/getapptypes?tag=accounttype`)
.then((res) => {
console.log(res);
// if (res.data.status) {
let arr = [];
res.data.map((val) => {
arr.push({
...val,
label: val.typename
});
});
setAccountlist([...arr]);
console.log(arr);
// }
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
// const fetchpartnerlist = async () => {
// setLoading(true);
// await axios
// .get(`${process.env.REACT_APP_URL}/partners/getpartners`)
// .then((res) => {
// console.log('fetchpartnerlist', res);
// // if (res.data.status) {
// let arr = [];
// res.data.details.map((val) => {
// arr.push({
// ...val,
// label: val.partnername
// });
// });
// setPartnerlist([...arr]);
// console.log(arr);
// // }
// setLoading(false);
// })
// .catch((err) => {
// console.log(err);
// setLoading(false);
// });
// };
// ==============================|| fetchAppLocations ||============================== //
const fetchAppLocations = async () => {
try {
const locationRes = await axios.get(`${process.env.REACT_APP_URL}/partners/getlocations/?userid=${userid}`);
// const updatedLocations = [
// ...locationRes.data.details,
// { locationname: 'All', applocationid: 0 } // Add your new object here
// ];
console.log('fetchAppLocations', locationRes.data.details);
setPartnerlist(locationRes.data.details);
await getAdminTenant(tid);
} catch (err) {
console.log('locationRes', err);
console.log(err);
} finally {
setLoading(false);
}
};
// No vehicle-type/account-type lookup endpoint in the new API — these lists
// stay empty (dropdowns render with no options) instead of crashing.
const fetchvehicle = async () => setVehiclelist([]);
const fetchaccounttype = async () => setAccountlist([]);
// ==============================|| fetchAppLocations ||============================== //
useEffect(() => {
fetchAppLocations();
fetchZoneLocations().then(setPartnerlist);
}, []);
const fetchridershifts = async (id) => {
setLoading(true);
await axios
.get(`${process.env.REACT_APP_URL}/partners/getridershifts/?applocationid=${id}`)
.then((res) => {
console.log('fetchridershifts', res);
// if (res.data.status) {
let arr = [];
res.data.details.map((val) => {
arr.push({
...val,
label: val.shiftname
});
});
setShiftlist([...arr]);
console.log(arr);
// }
setLoading(false);
})
.catch((err) => {
console.log(err);
setLoading(false);
});
};
// No rider-shift lookup endpoint in the new API.
const fetchridershifts = async () => setShiftlist([]);
const handleAddressPlaceSelected = (place) => {
setAddress(place.formatted_address);
@@ -255,48 +152,27 @@ const EditRider = () => {
const updateRider = async () => {
setLoading(true);
console.log('updated riderData', riderdata);
await axios
.put(`https://jupiter.nearle.app/live/api/v1/partners/updaterider`, {
userid: riderdata.userid,
contactno: riderdata.contactno,
firstname: riderdata.firstname,
lastname: riderdata.lastname,
// PUT /admin/milers/:id has no documented fields for bank/vehicle/shift
// details (the old ridersettings sub-object) — only the miler's own
// identity/contact/tenant/zone fields carry over. Those settings stay in
// local state but no longer persist server-side until the new API grows
// matching endpoints.
//
// POST /admin/milers (the confirmed create body) has a single
// `displayname` field — no firstname/lastname split exists on a miler at
// all. This used to reconstruct displayname from riderdata.firstname/
// lastname, which fetchRiderData never populates (the GET response has
// no such fields either), so it always sent displayname: '' and silently
// blanked the rider's name on every save.
try {
const res = await updateMiler(riderdata.milerprofileid, {
contactno: riderdata.phone,
displayname: riderdata.displayname,
email: riderdata.email,
address: riderdata.address,
suburb: riderdata.suburb,
city: riderdata.city,
state: riderdata.state,
partnerid: riderdata.partnerid,
applocationid: riderdata.applocationid,
ridersettings: {
riderid: riderdata.riderid,
userid: riderdata.userid,
partnerid: riderdata.partnerid,
shiftid: riderdata.shiftid,
identificationno: riderdata.identificationno,
basefare: riderdata.basefare,
additionalkm: riderdata.additionalkm,
othercharges: riderdata.othercharges,
accountno: riderdata.accountno,
accountname: riderdata.accountname,
accounttypeid: riderdata.accounttypeid,
accounttype: riderdata.accounttype,
bankname: riderdata.bankname,
ifsccode: riderdata.ifsccode,
Branch: riderdata.branch,
vehicleid: riderdata.vehicleid,
vehiclename: riderdata.vehiclename,
vehicleno: riderdata.vehicleno,
model: riderdata.model,
color: riderdata.color,
licenseno: riderdata.licenseno,
insurancedate: dayjs(riderdata.insurancedate).format('YYYY-MM-DD HH:mm:ss')
}
})
.then((response) => {
console.log('post response', response);
if (response.status == 200) {
tenantid: riderdata.tenantid,
applocationid: riderdata.applocationid
});
if (res.success) {
enqueueSnackbar(`Updated Sucessfully`, {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
@@ -304,16 +180,22 @@ const EditRider = () => {
});
setRiderdata(null);
navigate('/doormile/riders');
setLoading(false);
} else {
enqueueSnackbar('Update Failed', {
enqueueSnackbar(res.message || 'Update Failed', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
} catch (err) {
enqueueSnackbar(err.message || 'Update Failed', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
} finally {
setLoading(false);
}
});
};
return (
<>
@@ -377,18 +259,18 @@ const EditRider = () => {
}
>
<Grid container spacing={{ xs: 2, sm: 3 }}>
{/* ========================== || First Name || ========================== */}
{/* ========================== || Display Name || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-last-name">First Name</InputLabel>
<InputLabel htmlFor="personal-display-name">Display Name</InputLabel>
<TextField
fullWidth
id="personal-last-name"
value={riderdata?.firstname}
id="personal-display-name"
value={riderdata?.displayname || ''}
onChange={(e) => {
setRiderdata({
...riderdata,
firstname: e.target.value
displayname: e.target.value
});
}}
placeholder="Name"
@@ -396,23 +278,11 @@ const EditRider = () => {
/>
</Stack>
</Grid>
{/* ========================== || Last Name || ========================== */}
{/* ========================== || Login Name || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-last-name">Last Name</InputLabel>
<TextField
fullWidth
id="personal-last-name"
placeholder="Name"
onChange={(e) => {
setRiderdata({
...riderdata,
lastname: e.target.value
});
}}
value={riderdata?.lastname}
autoComplete="off"
/>
<InputLabel htmlFor="personal-auth-name">Login Name</InputLabel>
<TextField fullWidth id="personal-auth-name" value={riderdata?.authname || ''} disabled autoComplete="off" />
</Stack>
</Grid>
{/* ========================== || Phone Number || ========================== */}
@@ -433,10 +303,10 @@ const EditRider = () => {
onChange={(e) => {
setRiderdata({
...riderdata,
contactno: e.target.value
phone: e.target.value
});
}}
value={riderdata?.contactno}
value={riderdata?.phone || ''}
autoComplete="off"
// disabled
sx={{ cursor: 'not-allowed' }}
@@ -562,10 +432,10 @@ const EditRider = () => {
/>
</Stack>
</Grid>
{/* ========================== || Choose Partner|| ========================== */}
{/* ========================== || City / Zone || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">Choose Partner</InputLabel>
<InputLabel htmlFor="personal-location">City / Zone</InputLabel>
<Autocomplete
disablePortal
id="combo-box-demo"
@@ -574,12 +444,16 @@ const EditRider = () => {
sx={{ width: { xs: '100%', sm: 300 }, height: '30px', ml: { xs: 0, sm: 3 }, zIndex: '100' }}
onChange={(event, value) => {
if (value) {
console.log(value);
// This picker is fetchAppLocations() (hub-derived
// cities), not a Partners resource — the field used
// to write a `partnerid` that doesn't exist on
// these options (always undefined) alongside the
// real applocationid. Only applocationid is a
// confirmed miler field.
setLocoName(value.locationname);
fetchridershifts(value.applocationid);
setRiderdata({
...riderdata,
partnerid: value.partnerid,
applocationid: value.applocationid
});
} else {

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { useState, useEffect, useRef, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import {
Avatar,
Paper,
@@ -58,7 +57,9 @@ import {
MdInventory2,
MdTwoWheeler,
MdArrowForward,
MdDelete
MdDelete,
MdBlock,
MdDirectionsCar
} from 'react-icons/md';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import PageHeader from 'components/nearle_components/PageHeader';
@@ -66,8 +67,9 @@ import StatCard from 'components/nearle_components/StatCard';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import CircularLoader from 'components/CircularLoader';
import { fetchAllRiders, getallridersummary, getriderstatus } from 'pages/api/api';
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchAllRiders, getallridersummary } from 'pages/api/api';
import { getMilers, blockMiler, assignMilerVehicle, getVehicles } from 'pages/api/doormileApi';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { OpenToast } from 'components/third-party/OpenToast';
@@ -131,11 +133,17 @@ const AccentAvatar = ({ color, selected, size = 24, children }) => (
// Status palette — semantic only (do NOT swap for brand purple). Used by the
// per-row status badges and the lifecycle tabs (ALL, Active).
// GET /admin/milers returns `availabilitystatus` (confirmed live: Available,
// Assigned, On_Pickup, Offline) — not the "active"/"inactive" this used to
// assume (there is no `status` field on a miler at all).
const STATUS_META = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel },
online: { label: 'Online', color: '#10b981', icon: MdCheckCircle },
offline: { label: 'Offline', color: '#ef4444', icon: MdCancel },
available: { label: 'Available', color: '#10b981', icon: MdCheckCircle },
assigned: { label: 'Assigned', color: '#6366f1', icon: MdTwoWheeler },
on_pickup: { label: 'On Pickup', color: '#14b8a6', icon: MdTwoWheeler },
idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime },
unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2 }
};
@@ -170,46 +178,13 @@ const Riders = () => {
const roleid = localStorage.getItem('roleid');
const totalCols = roleid == 1 ? 11 : 10;
const [logsRow, setLogsRow] = useState(null);
const [riderLogsdata, setRiderLogsdata] = useState(null);
const [riderLogsdata] = useState(null);
const [historyDate, setHistoryDate] = useState(dayjs());
const [historyStatus, setHistoryStatus] = useState('all');
const [selectedDate, setSelectedDate] = useState(dayjs());
const [editingSub, setEditingSub] = useState(null);
const [newSubRider, setNewSubRider] = useState(null);
const getResolvedPartnerId = () => {
let partnerId = '';
try {
const rawLocs = localStorage.getItem('applocations');
if (rawLocs) {
const locs = JSON.parse(rawLocs);
const currentLoc = locs.find((l) => l.applocationid === appId);
if (currentLoc && currentLoc.partnerid) {
partnerId = currentLoc.partnerid;
} else {
const firstValidLoc = locs.find((l) => l.partnerid);
if (firstValidLoc && firstValidLoc.partnerid) {
partnerId = firstValidLoc.partnerid;
}
}
}
} catch (e) {
console.error('Error parsing applocations', e);
}
if (!partnerId) {
const savedPartnerId = localStorage.getItem('partnerid');
if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') {
partnerId = savedPartnerId;
}
}
if (!partnerId || partnerId === '0' || partnerId === 0) {
partnerId = 44;
}
return partnerId;
};
const getHistoryStatusColor = (status) => {
const s = (status || '').toLowerCase();
if (s === 'cancelled' || s === 'deleted' || s === 'inactive') return '#ef4444'; // Red
@@ -218,24 +193,12 @@ const Riders = () => {
return '#64748b'; // Gray
};
// The rider-substitution feature (/substitutions/*) has no equivalent in the
// new Doormile Express API at all — no such resource is documented. These
// handlers now no-op with an explanatory toast instead of calling jupiter.
const handleDeleteSubstitution = async (row) => {
if (window.confirm(`Are you sure you want to delete the substitution for ${row.absent_rider_name}?`)) {
try {
const resolvedTenantId = row.tenant_id || row.tenantid || getResolvedPartnerId();
const url = `${process.env.REACT_APP_URL}/substitutions/${row.id}?tenant_id=${resolvedTenantId}`;
const res = await axios.delete(url);
if (res.data && res.data.status) {
OpenToast('Substitution deleted successfully!', 'success', 2000);
} else {
OpenToast(res.data?.message || 'Failed to delete substitution.', 'error', 2000);
}
} catch (err) {
console.error(err);
OpenToast(err.response?.data?.message || err.message || 'Failed to delete substitution.', 'error', 2000);
} finally {
queryClient.invalidateQueries({ queryKey: ['substitutionsHistory'] });
queryClient.invalidateQueries({ queryKey: ['activeSubstitutionsToday'] });
}
OpenToast('Substitutions are not available on the Doormile Express API yet.', 'warning', 2500);
}
};
@@ -244,29 +207,9 @@ const Riders = () => {
OpenToast('Please select a substitute rider', 'error', 2000);
return;
}
try {
const resolvedTenantId = editingSub.tenant_id || editingSub.tenantid || getResolvedPartnerId();
const url = `${process.env.REACT_APP_URL}/substitutions/${editingSub.id}`;
const payload = {
tenant_id: parseInt(resolvedTenantId, 10),
sub_rider_id: parseInt(newSubRider.userid, 10),
sub_rider_name: newSubRider.username || newSubRider.fullname || `Rider #${newSubRider.userid}`
};
const res = await axios.put(url, payload);
if (res.data && res.data.status) {
OpenToast('Substitution updated successfully!', 'success', 2000);
} else {
OpenToast(res.data?.message || 'Failed to update substitution.', 'error', 2000);
}
} catch (err) {
console.error(err);
OpenToast(err.response?.data?.message || err.message || 'Failed to update substitution.', 'error', 2000);
} finally {
OpenToast('Substitutions are not available on the Doormile Express API yet.', 'warning', 2500);
setEditingSub(null);
setNewSubRider(null);
queryClient.invalidateQueries({ queryKey: ['substitutionsHistory'] });
queryClient.invalidateQueries({ queryKey: ['activeSubstitutionsToday'] });
}
};
const [substituteAssignments, setSubstituteAssignments] = useState(() => {
@@ -304,45 +247,9 @@ const Riders = () => {
queryKey: ['allRidersForSub', appId],
queryFn: async () => {
try {
// Resolve partner ID dynamically
let partnerId = '';
try {
const rawLocs = localStorage.getItem('applocations');
if (rawLocs) {
const locs = JSON.parse(rawLocs);
const currentLoc = locs.find((l) => l.applocationid === appId);
if (currentLoc && currentLoc.partnerid) {
partnerId = currentLoc.partnerid;
} else {
const firstValidLoc = locs.find((l) => l.partnerid);
if (firstValidLoc && firstValidLoc.partnerid) {
partnerId = firstValidLoc.partnerid;
}
}
}
} catch (e) {
console.error('Error parsing applocations', e);
}
if (!partnerId) {
const savedPartnerId = localStorage.getItem('partnerid');
if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') {
partnerId = savedPartnerId;
}
}
if (!partnerId || partnerId === '0' || partnerId === 0) {
partnerId = 44; // final fallback
}
const url = `${process.env.REACT_APP_URL}/partners/getallriders/?applocationid=0&partnerid=${partnerId}&pagesize=1000&pageno=1&status=`;
const res = await axios.get(url);
const allRiders = res.data.details || [];
console.log('Substitute query - all riders returned from API:', allRiders.map(r => ({ userid: r.userid, username: r.username, partnerid: r.partnerid })));
const allRiders = (await getMilers()) || [];
const allowedIds = [1121, 772, 1116];
const filtered = allRiders.filter((rider) => allowedIds.includes(parseInt(rider.userid)));
console.log('Substitute query - filtered allowed riders:', filtered);
return filtered;
return allRiders.filter((rider) => allowedIds.includes(parseInt(rider.userid ?? rider.milerid)));
} catch (err) {
console.error(err);
return [];
@@ -350,144 +257,16 @@ const Riders = () => {
}
});
// No rider login/checkout audit-log endpoint in the new API.
const { data: subRidersLogsData, isLoading: subRidersLogsLoading } = useQuery({
queryKey: ['subRidersLogs', appId, debouncedSearch, selectedDate.format('YYYY-MM-DD')],
queryFn: async () => {
try {
const dateStr = selectedDate.format('YYYY-MM-DD');
// Resolve partner ID dynamically
let partnerId = '';
try {
const rawLocs = localStorage.getItem('applocations');
if (rawLocs) {
const locs = JSON.parse(rawLocs);
const currentLoc = locs.find((l) => l.applocationid === appId);
if (currentLoc && currentLoc.partnerid) {
partnerId = currentLoc.partnerid;
} else {
const firstValidLoc = locs.find((l) => l.partnerid);
if (firstValidLoc && firstValidLoc.partnerid) {
partnerId = firstValidLoc.partnerid;
}
}
}
} catch (e) {
console.error('Error parsing applocations', e);
}
if (!partnerId) {
const savedPartnerId = localStorage.getItem('partnerid');
if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') {
partnerId = savedPartnerId;
}
}
if (!partnerId || partnerId === '0' || partnerId === 0) {
partnerId = 44;
}
const url = `${process.env.REACT_APP_URL2}/partners/getriderlogs/?applocationid=${appId}&partnerid=${partnerId}&fromdate=${dateStr}&todate=${dateStr}&keyword=${debouncedSearch}`;
const res = await axios.get(url);
return res.data.details || [];
} catch (err) {
console.error(err);
return [];
}
}
});
const { data: activeSubsToday, isLoading: activeSubsTodayLoading } = useQuery({
queryKey: ['activeSubstitutionsToday', appId, selectedDate.format('YYYY-MM-DD')],
queryFn: async () => {
try {
let partnerId = '';
try {
const rawLocs = localStorage.getItem('applocations');
if (rawLocs) {
const locs = JSON.parse(rawLocs);
const currentLoc = locs.find((l) => l.applocationid === appId);
if (currentLoc && currentLoc.partnerid) {
partnerId = currentLoc.partnerid;
} else {
const firstValidLoc = locs.find((l) => l.partnerid);
if (firstValidLoc && firstValidLoc.partnerid) {
partnerId = firstValidLoc.partnerid;
}
}
}
} catch (e) {
console.error('Error parsing applocations', e);
}
if (!partnerId) {
const savedPartnerId = localStorage.getItem('partnerid');
if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') {
partnerId = savedPartnerId;
}
}
if (!partnerId || partnerId === '0' || partnerId === 0) {
partnerId = 44;
}
const dateStr = selectedDate.format('YYYY-MM-DD');
const url = `${process.env.REACT_APP_URL}/substitutions?tenant_id=${partnerId}&from_date=${dateStr}&to_date=${dateStr}`;
const res = await axios.get(url);
return res.data.details || [];
} catch (err) {
console.error(err);
return [];
}
}
queryFn: async () => []
});
// The /substitutions resource has no equivalent in the new API.
const { data: subHistoryData, isLoading: subHistoryLoading } = useQuery({
queryKey: ['substitutionsHistory', appId, historyDate, historyStatus],
queryFn: async () => {
try {
let partnerId = '';
try {
const rawLocs = localStorage.getItem('applocations');
if (rawLocs) {
const locs = JSON.parse(rawLocs);
const currentLoc = locs.find((l) => l.applocationid === appId);
if (currentLoc && currentLoc.partnerid) {
partnerId = currentLoc.partnerid;
} else {
const firstValidLoc = locs.find((l) => l.partnerid);
if (firstValidLoc && firstValidLoc.partnerid) {
partnerId = firstValidLoc.partnerid;
}
}
}
} catch (e) {
console.error('Error parsing applocations', e);
}
if (!partnerId) {
const savedPartnerId = localStorage.getItem('partnerid');
if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') {
partnerId = savedPartnerId;
}
}
if (!partnerId || partnerId === '0' || partnerId === 0) {
partnerId = 44;
}
const dateStr = historyDate.format('YYYY-MM-DD');
let url = `${process.env.REACT_APP_URL}/substitutions?tenant_id=${partnerId}&from_date=${dateStr}&to_date=${dateStr}`;
if (historyStatus !== 'all') {
url += `&status=${historyStatus}`;
}
const res = await axios.get(url);
return res.data.details || [];
} catch (err) {
console.error(err);
return [];
}
}
queryFn: async () => []
});
const handleChangetab = (i) => {
@@ -502,32 +281,17 @@ const Riders = () => {
});
// ==============================|| getRiderLogs (riders)||============================== //
const getRiderLogs = async (userid) => {
try {
const res = await axios.get(`${process.env.REACT_APP_URL}/utils/getriderperiodiclogs?userid=${userid}`);
if (res.data.data.length == 0) {
setLogsRow(null);
OpenToast(res.data.message, 'error', 2000);
} else {
setRiderLogsdata(res.data.data);
}
} catch (err) {
OpenToast(err.message, 'error', 2000);
}
// No live GPS/battery/periodic-log endpoint in the new API — the expanded
// row still opens, showing the panel's built-in "—" placeholders.
const getRiderLogs = async () => {
OpenToast('Live rider logs are not available on the Doormile Express API yet.', 'warning', 2500);
};
// ==============================|| getriderstatus||============================== //
const {
data: ridersStatus,
isLoading: riderStatusLoading,
isError: riderstatusIsError,
error: riderStatusError
} = useQuery({
queryKey: ['ridersStatus'],
queryFn: getriderstatus
});
// ==============================|| fetchAllRiders||============================== //
// GET /admin/milers isn't paginated (fetchAllRiders always returns the full
// list in one shot) — getNextPageParam must not request a "next page" or it
// re-fetches and appends the identical full list again, duplicating every
// rider in `rows`.
const {
data: allRidersData,
isLoading: allRidersLoading,
@@ -537,11 +301,24 @@ const Riders = () => {
} = useInfiniteQuery({
queryKey: ['allriders', appId, debouncedSearch, tabvalue],
queryFn: fetchAllRiders,
getNextPageParam: (lastPage, pages) => (lastPage.details?.length ? pages.length + 1 : undefined)
getNextPageParam: () => undefined
});
const rows = (allRidersData?.pages.flatMap((page) => page.details || []) || []).filter(Boolean);
// GET /admin/milers accepts no search/status params — debouncedSearch and
// tabvalue only trigger a refetch (via the queryKey) that returns the exact
// same full list every time. Search and the "Active" tab were both silently
// no-ops until filtered client-side here.
const filteredRows = rows.filter((row) => {
// "Active" here means currently online/working (Available/Assigned/
// On_Pickup) — the only real "not working" state is Offline.
if (tabvalue === 1 && String(row.availabilitystatus || '').toLowerCase() === 'offline') return false;
if (!debouncedSearch) return true;
const q = debouncedSearch.toLowerCase();
return [row.displayname, row.phone].some((v) => String(v || '').toLowerCase().includes(q));
});
useEffect(() => {
if (!hasNextPage) return;
const observer = new IntersectionObserver(
@@ -571,30 +348,85 @@ const Riders = () => {
}
};
const errMessage = riderstatusIsError ? riderStatusError : null;
useEffect(() => {
if (errMessage) {
OpenToast(errMessage, 'error', 2000);
}
}, [errMessage]);
// Per-row status meta — falls back to "unknown" if the rider's state key
// isn't in the palette (e.g. brand-new status string from the backend).
// Previously joined against a separate rider-status endpoint on every tab
// except "All Riders" — that endpoint has no equivalent in the new API and
// always returned null, so every rider on every other tab showed "Unknown"
// regardless of their real state. Now reads the miler's own
// `availabilitystatus` field directly on every tab (confirmed live — there
// is no `status` field on a miler at all).
const getRowStatusMeta = (row) => {
if (tabvalue == 0) {
const key = (row?.status || '').toLowerCase() === 'active' ? 'active' : 'inactive';
return STATUS_META[key] || STATUS_META.unknown;
}
const state = ridersStatus?.find((s) => s.userid === row?.userid);
const key = (state?.status || 'unknown').toLowerCase();
const key = (row?.availabilitystatus || '').toLowerCase();
return STATUS_META[key] || STATUS_META.unknown;
};
// PUT /admin/milers/:id/block — body shape isn't documented (Status: Built,
// never exercised against a real request), so no fields are sent beyond the
// id in the URL. Blocks whichever rider is passed after a confirm prompt.
const blockRiderMutation = useMutation({
// /admin/milers/:id routes key off milerprofileid, not userid (confirmed
// live — GET /admin/milers/:userid 404s; the miler's own userid field
// points at the underlying app-user account, a different resource).
mutationFn: (row) => blockMiler(row.milerprofileid),
onSuccess: (res) => {
if (res.success) {
OpenToast('Rider blocked', 'success', 2000);
queryClient.invalidateQueries({ queryKey: ['allriders'] });
} else {
OpenToast(res.message || 'Failed to block rider', 'error', 2000);
}
},
onError: (err) => {
OpenToast(err.response?.data?.message || err.message || 'Failed to block rider', 'error', 2000);
}
});
const handleBlockRider = (row) => {
if (window.confirm(`Block ${row.displayname || row.authname || 'this rider'}? They will not be able to log in to the miler app.`)) {
blockRiderMutation.mutate(row);
}
};
// PUT /admin/milers/:id/assign-vehicle — body shape isn't documented
// (Status: Built, never exercised), so this assumes `vehicleid` following
// the codebase's own <resource>id convention (GET /admin/vehicles/:id).
const [assignVehicleRider, setAssignVehicleRider] = useState(null);
const [selectedVehicle, setSelectedVehicle] = useState(null);
const { data: vehicles = [] } = useQuery({
queryKey: ['riders-vehicles'],
queryFn: getVehicles,
enabled: !!assignVehicleRider
});
const assignVehicleMutation = useMutation({
mutationFn: () => assignMilerVehicle(assignVehicleRider.milerprofileid, { vehicleid: selectedVehicle?.vehicleid }),
onSuccess: (res) => {
if (res.success) {
OpenToast('Vehicle assigned', 'success', 2000);
queryClient.invalidateQueries({ queryKey: ['allriders'] });
setAssignVehicleRider(null);
setSelectedVehicle(null);
} else {
OpenToast(res.message || 'Failed to assign vehicle', 'error', 2000);
}
},
onError: (err) => {
OpenToast(err.response?.data?.message || err.message || 'Failed to assign vehicle', 'error', 2000);
}
});
return (
<>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={allRidersLoading || riderSummarysLoading || riderStatusLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)}
open={
allRidersLoading ||
riderSummarysLoading ||
(tabvalue === 2 && subRidersLogsLoading) ||
(tabvalue === 3 && subHistoryLoading)
}
>
<CircularLoader color="inherit" />
</Backdrop>
@@ -653,13 +485,7 @@ const Riders = () => {
background: '#fff'
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={1.5}
sx={{ flexWrap: 'wrap-reverse' }}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1.5} sx={{ flexWrap: 'wrap-reverse' }}>
<Stack
direction="row"
spacing={0.75}
@@ -675,16 +501,13 @@ const Riders = () => {
{TAB_META.map((t) => {
const Icon = t.icon;
const active = tabvalue === t.key;
const count = t.key === 2
? (subRidersLogsData?.length || 0)
const count =
t.key === 2
? subRidersLogsData?.length || 0
: t.key === 3
? (subHistoryData?.length || 0)
: (allRidersSummary?.[t.countKey] ?? 0);
const countLoading = t.key === 2
? subRidersLogsLoading
: t.key === 3
? subHistoryLoading
: riderSummarysLoading;
? subHistoryData?.length || 0
: allRidersSummary?.[t.countKey] ?? 0;
const countLoading = t.key === 2 ? subRidersLogsLoading : t.key === 3 ? subHistoryLoading : riderSummarysLoading;
return (
<Box
key={t.key}
@@ -780,12 +603,7 @@ const Riders = () => {
background: '#fff'
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems="center"
justifyContent="space-between"
gap={2}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems="center" justifyContent="space-between" gap={2}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DT.textSecondary, mr: 1 }}>
Status:
@@ -1156,7 +974,7 @@ const Riders = () => {
</Stack>
)}
{rows?.length === 0 && !allRidersLoading && (
{filteredRows?.length === 0 && !allRidersLoading && (
<Stack alignItems="center" spacing={1.5} sx={{ py: 6 }}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdTwoWheeler size={28} />
@@ -1165,15 +983,13 @@ const Riders = () => {
No riders to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center', px: 2 }}>
{searchword
? 'Try a different keyword.'
: `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`}
{searchword ? 'Try a different keyword.' : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`}
</Typography>
</Stack>
)}
{rows?.length !== 0 &&
rows?.map((row, index) => {
{filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const StatusIcon = statusMeta.icon;
const expanded = logsRow === row.userid;
@@ -1246,6 +1062,32 @@ const Riders = () => {
>
<MdEdit size={14} />
</IconButton>
<IconButton
size="small"
sx={{
borderRadius: 999,
bgcolor: soft('#ef4444'),
color: '#ef4444',
border: `1px solid ${edge('#ef4444')}`,
'&:hover': { bgcolor: '#ef4444', color: '#fff' }
}}
onClick={() => handleBlockRider(row)}
>
<MdBlock size={14} />
</IconButton>
<IconButton
size="small"
sx={{
borderRadius: 999,
bgcolor: soft('#0ea5e9'),
color: '#0ea5e9',
border: `1px solid ${edge('#0ea5e9')}`,
'&:hover': { bgcolor: '#0ea5e9', color: '#fff' }
}}
onClick={() => setAssignVehicleRider(row)}
>
<MdDirectionsCar size={14} />
</IconButton>
{tabvalue != 0 && (
<IconButton
size="small"
@@ -1284,14 +1126,14 @@ const Riders = () => {
flexShrink: 0
}}
>
{(row.fullname || row.username || '?').charAt(0).toUpperCase()}
{(row.displayname || row.authname || '?').charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, color: DT.textPrimary, fontSize: 15 }} noWrap>
{row.username || '—'}
{row.displayname || row.authname || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{row.contactno || '—'}
{row.phone || '—'}
</Typography>
</Box>
</Stack>
@@ -1313,7 +1155,7 @@ const Riders = () => {
<MdTwoWheeler size={12} />
</AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textPrimary }}>
{row.vehicleno || '—'}
{row.defaultvehicletype || '—'}
</Typography>
</Stack>
</MobileField>
@@ -1358,7 +1200,6 @@ const Riders = () => {
</MobileField>
<MobileField label="Fare" value={row.basefare ?? '—'} />
<MobileField label="Fuel" value={row.fuelcharge ?? '—'} />
</MobileFieldGrid>
{expanded && tabvalue !== 0 && (
@@ -1384,7 +1225,7 @@ const Riders = () => {
fontSize: 11
}}
>
Live telemetry {row.username || `Rider #${row.userid}`}
Live telemetry {row.displayname || row.authname || `Rider #${row.userid}`}
</Typography>
</Stack>
<Grid container spacing={1.25}>
@@ -1433,7 +1274,7 @@ const Riders = () => {
);
})}
{rows?.length !== 0 && (
{filteredRows?.length !== 0 && (
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage || hasNextPage ? (
<LoaderWithImage />
@@ -1480,7 +1321,7 @@ const Riders = () => {
<TableBody>
{allRidersLoading && <OrdersTableSkeleton col={6} />}
{rows?.length === 0 && !allRidersLoading && (
{filteredRows?.length === 0 && !allRidersLoading && (
<TableRow>
<TableCell colSpan={totalCols} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
@@ -1491,17 +1332,15 @@ const Riders = () => {
No riders to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{searchword
? 'Try a different keyword.'
: `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`}
{searchword ? 'Try a different keyword.' : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`}
</Typography>
</Stack>
</TableCell>
</TableRow>
)}
{rows?.length !== 0 &&
rows?.map((row, index) => {
{filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const StatusIcon = statusMeta.icon;
const expanded = logsRow === row.userid;
@@ -1558,17 +1397,14 @@ const Riders = () => {
border: `1px solid ${edge(BRAND)}`
}}
>
{(row.fullname || row.username || '?').charAt(0).toUpperCase()}
{(row.displayname || row.authname || '?').charAt(0).toUpperCase()}
</Avatar>
<Stack sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
{row.username || '—'}
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.displayname || row.authname || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.contactno || '—'}
{row.phone || '—'}
</Typography>
</Stack>
</Stack>
@@ -1576,11 +1412,7 @@ const Riders = () => {
<TableCell sx={{ maxWidth: 220 }}>
<Tooltip title={row.address || ''} placement="top">
<Stack>
<Typography
variant="caption"
sx={{ color: DT.textSecondary, fontWeight: 600 }}
noWrap
>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }} noWrap>
{row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')}
</Typography>
<Typography variant="caption" sx={{ color: DT.textMuted }} noWrap>
@@ -1595,7 +1427,7 @@ const Riders = () => {
<MdTwoWheeler size={12} />
</AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textPrimary }}>
{row.vehicleno || '—'}
{row.defaultvehicletype || '—'}
</Typography>
</Stack>
</TableCell>
@@ -1699,6 +1531,34 @@ const Riders = () => {
<MdEdit size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Block rider" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#ef4444'),
color: '#ef4444',
border: `1px solid ${edge('#ef4444')}`,
'&:hover': { bgcolor: '#ef4444', color: '#fff' }
}}
onClick={() => handleBlockRider(row)}
>
<MdBlock size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Assign vehicle" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#0ea5e9'),
color: '#0ea5e9',
border: `1px solid ${edge('#0ea5e9')}`,
'&:hover': { bgcolor: '#0ea5e9', color: '#fff' }
}}
onClick={() => setAssignVehicleRider(row)}
>
<MdDirectionsCar size={14} />
</IconButton>
</Tooltip>
{tabvalue != 0 && (
<Tooltip title={expanded ? 'Hide logs' : 'View live logs'} placement="top">
<IconButton
@@ -1752,7 +1612,7 @@ const Riders = () => {
fontSize: 11
}}
>
Live telemetry {row.username || `Rider #${row.userid}`}
Live telemetry {row.displayname || row.authname || `Rider #${row.userid}`}
</Typography>
</Stack>
<Grid container spacing={1.25}>
@@ -1760,11 +1620,7 @@ const Riders = () => {
color="#ef4444"
icon={MdLocationOn}
label="Location"
value={
riderLogsdata?.latitude
? `${riderLogsdata.latitude}, ${riderLogsdata.longitude}`
: '—'
}
value={riderLogsdata?.latitude ? `${riderLogsdata.latitude}, ${riderLogsdata.longitude}` : '—'}
/>
<LogChip
color="#10b981"
@@ -1782,24 +1638,15 @@ const Riders = () => {
color="#0ea5e9"
icon={MdSpeed}
label="Speed"
value={
riderLogsdata?.speed !== undefined ? `${riderLogsdata.speed} km/h` : '—'
}
value={riderLogsdata?.speed !== undefined ? `${riderLogsdata.speed} km/h` : '—'}
/>
<LogChip
color="#8b5cf6"
icon={MdGpsFixed}
label="Accuracy"
value={
riderLogsdata?.accuracy !== undefined ? `${riderLogsdata.accuracy} m` : '—'
}
/>
<LogChip
color="#f59e0b"
icon={MdAccessTime}
label="Log time"
value={riderLogsdata?.logdate || '—'}
value={riderLogsdata?.accuracy !== undefined ? `${riderLogsdata.accuracy} m` : '—'}
/>
<LogChip color="#f59e0b" icon={MdAccessTime} label="Log time" value={riderLogsdata?.logdate || '—'} />
<LogChip
color={BRAND}
icon={MdInventory2}
@@ -1807,9 +1654,7 @@ const Riders = () => {
value={riderLogsdata?.orderid || 'N/A'}
/>
<LogChip
color={
riderLogsdata?.status === 'idle' ? '#f59e0b' : '#10b981'
}
color={riderLogsdata?.status === 'idle' ? '#f59e0b' : '#10b981'}
icon={MdCheckCircle}
label="Status"
value={riderLogsdata?.status || 'unknown'}
@@ -1824,7 +1669,7 @@ const Riders = () => {
);
})}
{rows?.length !== 0 && (
{filteredRows?.length !== 0 && (
<TableRow>
<TableCell colSpan={totalCols} sx={{ borderBottom: 'none' }}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
@@ -1918,6 +1763,43 @@ const Riders = () => {
</Button>
</DialogActions>
</Dialog>
<Dialog
open={!!assignVehicleRider}
onClose={() => {
setAssignVehicleRider(null);
setSelectedVehicle(null);
}}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontWeight: 700 }}>
Assign Vehicle · {assignVehicleRider?.displayname || assignVehicleRider?.authname}
</DialogTitle>
<DialogContent>
<Autocomplete
sx={{ mt: 1 }}
options={vehicles || []}
getOptionLabel={(option) => `${option.vehicleno || ''}${option.vehicletype ? ` (${option.vehicletype})` : ''}`}
value={selectedVehicle}
onChange={(e, value) => setSelectedVehicle(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose vehicle" autoFocus />}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => {
setAssignVehicleRider(null);
setSelectedVehicle(null);
}}
>
Cancel
</Button>
<Button variant="contained" disabled={!selectedVehicle} onClick={() => assignVehicleMutation.mutate()}>
Assign
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -1,22 +1,14 @@
import { Avatar, Box, Chip, Grid, Paper, Stack, Typography, useMediaQuery } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { useQuery } from '@tanstack/react-query';
import {
MdBadge,
MdPerson,
MdLocationOn,
MdMail,
MdPhone,
MdPlace,
MdMyLocation,
MdLocationCity,
MdMap,
MdMarkunreadMailbox,
MdVerifiedUser
} from 'react-icons/md';
import { useState } from 'react';
import { Avatar, Box, Button, Chip, Grid, InputLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query';
import { MdBadge, MdPerson, MdLocationOn, MdMail, MdShield, MdVerifiedUser, MdLock } from 'react-icons/md';
import CircularLoader from 'components/CircularLoader';
import Loader from 'components/Loader';
import { getusers } from 'pages/api/api';
import { updateProfilePassword } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
const ROLE_LABELS = { 1: 'Admin', 3: 'Manager', 4: 'Executive' };
// ---- shared design tokens (mirrors the DT block used across the console) ----
const DT = {
@@ -99,14 +91,72 @@ const SectionCard = ({ title, icon: Icon, children }) => (
);
const ViewProfile = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { data: userData, isLoading } = useQuery({
queryKey: ['getuser'],
queryFn: getusers
});
const fullname = userData?.fullname || userData?.firstname || 'User Profile';
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const changePasswordMutation = useMutation({
mutationFn: () => updateProfilePassword(currentPassword, newPassword),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar('Password updated successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
enqueueSnackbar(res.message || 'Failed to update password', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to update password', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const handleChangePassword = () => {
if (!currentPassword || !newPassword) {
enqueueSnackbar('Fill current and new password', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return;
}
if (newPassword !== confirmPassword) {
enqueueSnackbar('New password and confirmation do not match', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return;
}
changePasswordMutation.mutate();
};
// GET /admin/profile has no documented schema, but the console's own login
// response ({ success, token, user: { id, name, email, role, tenantid } })
// is confirmed — /admin/profile almost certainly returns the same "user"
// shape. Fields like address/suburb/city/postcode were jupiter-era guesses
// with no basis in the new API and have been dropped rather than shown blank.
const fullname = userData?.name || userData?.fullname || 'User Profile';
const roleLabel = ROLE_LABELS[userData?.role] || ROLE_LABELS[userData?.roleid] || userData?.role || '—';
const tenantLabel = userData?.tenantid ? `Tenant #${userData.tenantid}` : 'Doormile staff (all tenants)';
return (
<>
@@ -153,11 +203,11 @@ const ViewProfile = () => {
{fullname}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
{userData?.authname && (
{roleLabel && (
<Chip
size="small"
icon={<MdVerifiedUser size={14} style={{ color: BRAND }} />}
label={userData.authname}
label={roleLabel}
sx={{
bgcolor: soft(BRAND),
color: BRAND,
@@ -167,19 +217,17 @@ const ViewProfile = () => {
}}
/>
)}
{userData?.userid != null && userData?.userid !== '' && (
{(userData?.id != null || userData?.userid != null) && (
<Typography variant="body2" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
ID&nbsp;#{userData.userid}
ID&nbsp;#{userData.id ?? userData.userid}
</Typography>
)}
{userData?.applocation && (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: DT.textSecondary }}>
<MdLocationOn size={15} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{userData.applocation}
{tenantLabel}
</Typography>
</Stack>
)}
</Stack>
</Box>
</Stack>
@@ -190,40 +238,64 @@ const ViewProfile = () => {
<Grid item xs={12} md={6}>
<SectionCard title="Account" icon={MdBadge}>
<Stack spacing={2.5}>
<InfoField icon={MdPerson} label="User Name" value={userData?.fullname} />
<InfoField icon={MdBadge} label="User ID" value={userData?.userid} />
<InfoField icon={MdVerifiedUser} label="Auth Name" value={userData?.authname} />
<InfoField icon={MdLocationOn} label="App Location" value={userData?.applocation} />
<InfoField icon={MdPerson} label="User Name" value={userData?.name || userData?.fullname} />
<InfoField icon={MdBadge} label="User ID" value={userData?.id ?? userData?.userid} />
<InfoField icon={MdMail} label="E-Mail" value={userData?.email} accent="#0ea5e9" />
<InfoField icon={MdVerifiedUser} label="Role" value={roleLabel} />
<InfoField icon={MdLocationOn} label="Tenant" value={tenantLabel} accent="#14b8a6" />
</Stack>
</SectionCard>
</Grid>
<Grid item xs={12} md={6}>
<SectionCard title="Contact" icon={MdMail}>
<Stack spacing={2.5}>
<InfoField icon={MdPhone} label="Contact No" value={userData?.contactno} accent="#0ea5e9" />
<InfoField icon={MdMail} label="E-Mail" value={userData?.email} accent="#0ea5e9" />
<InfoField icon={MdPlace} label="Address" value={userData?.address} accent="#0ea5e9" />
<SectionCard title="Security" icon={MdShield}>
<Stack spacing={2}>
<Stack spacing={1}>
<InputLabel htmlFor="current-password">Current Password</InputLabel>
<TextField
id="current-password"
type="password"
fullWidth
placeholder="Current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="off"
/>
</Stack>
<Stack spacing={1}>
<InputLabel htmlFor="new-password">New Password</InputLabel>
<TextField
id="new-password"
type="password"
fullWidth
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="off"
/>
</Stack>
<Stack spacing={1}>
<InputLabel htmlFor="confirm-password">Confirm New Password</InputLabel>
<TextField
id="confirm-password"
type="password"
fullWidth
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="off"
/>
</Stack>
<Button
variant="contained"
startIcon={<MdLock size={16} />}
onClick={handleChangePassword}
disabled={changePasswordMutation.isPending}
sx={{ alignSelf: 'flex-start' }}
>
Update Password
</Button>
</Stack>
</SectionCard>
</Grid>
<Grid item xs={12}>
<SectionCard title="Location" icon={MdMap}>
<Grid container spacing={isMobile ? 2.5 : 3}>
<Grid item xs={12} sm={6} md={3}>
<InfoField icon={MdMyLocation} label="Suburb" value={userData?.suburb} accent="#14b8a6" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<InfoField icon={MdLocationCity} label="City" value={userData?.city} accent="#14b8a6" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<InfoField icon={MdMap} label="State" value={userData?.state} accent="#14b8a6" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<InfoField icon={MdMarkunreadMailbox} label="Postcode" value={userData?.postcode} accent="#14b8a6" />
</Grid>
</Grid>
</SectionCard>
</Grid>
</Grid>

View File

@@ -14,7 +14,6 @@ const MaintenanceComingSoon = Loadable(lazy(() => import('pages/maintenance/comi
// render - sample page
// const SamplePage = Loadable(lazy(() => import('pages/extra-pages/sample-page')));
const Login = Loadable(lazy(() => import('pages/nearle/login1')));
// const Dashboard = Loadable(lazy(() => import('pages/nearle/dashboard')));
const Orders = Loadable(lazy(() => import('pages/nearle/orders/orders')));
@@ -25,8 +24,6 @@ const Deliveries = Loadable(lazy(() => import('pages/nearle/deliveries/deliverie
const Tenants = Loadable(lazy(() => import('pages/nearle/clients/Tenants')));
const ClientsPricing = Loadable(lazy(() => import('pages/nearle/clientPricing/clientPricing')));
const Details = Loadable(lazy(() => import('pages/nearle/orders/details')));
const ViewProfile = Loadable(lazy(() => import('pages/nearle/viewProfile')));
const Createorder1 = Loadable(lazy(() => import('pages/nearle/orders/createorder1')));
@@ -34,7 +31,6 @@ const MultipleOrders = Loadable(lazy(() => import('pages/nearle/orders/multipleO
const Createclient = Loadable(lazy(() => import('pages/nearle/clients/createclient')));
const Requests = Loadable(lazy(() => import('pages/nearle/requests/requests')));
const OrdersSummary = Loadable(lazy(() => import('pages/nearle/reports/ordersSummary')));
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
const RidersSummary = Loadable(lazy(() => import('pages/nearle/reports/ridersSummary')));
@@ -45,6 +41,14 @@ const EditRider = Loadable(lazy(() => import('pages/nearle/riders/editRider')));
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview')));
const Customers = Loadable(lazy(() => import('pages/nearle/customers/customers')));
const Hubs = Loadable(lazy(() => import('pages/nearle/hubs/hubs')));
const Vehicles = Loadable(lazy(() => import('pages/nearle/vehicles/vehicles')));
const AppUsers = Loadable(lazy(() => import('pages/nearle/appUsers/appUsers')));
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
// ==============================|| MAIN ROUTING ||============================== //
const MainRoutes = {
@@ -81,10 +85,6 @@ const MainRoutes = {
path: 'pricing',
element: <ClientsPricing />
},
{
path: 'requests',
element: <Requests />
},
{
path: 'riders',
element: <Riders />
@@ -106,10 +106,6 @@ const MainRoutes = {
path: 'orders/createorders',
element: <MultipleOrders />
},
{
path: 'orders/details',
element: <Details />
},
{
path: 'clients/create',
element: <Createclient />
@@ -138,6 +134,34 @@ const MainRoutes = {
{
path: 'dispatch/preview',
element: <DispatchPreview />
},
{
path: 'customers',
element: <Customers />
},
{
path: 'hubs',
element: <Hubs />
},
{
path: 'vehicles',
element: <Vehicles />
},
{
path: 'app-users',
element: <AppUsers />
},
{
path: 'tripsheets',
element: <Tripsheets />
},
{
path: 'exceptions',
element: <Exceptions />
},
{
path: 'competitive-intel',
element: <CompetitiveIntel />
}
]
},
@@ -154,10 +178,6 @@ const MainRoutes = {
]
},
{
path: '/login',
element: <Login />
},
{
path: '/maintenance',
element: <CommonLayout />,

View File

@@ -0,0 +1,38 @@
import axios from 'axios';
// Client for the new Doormile Express admin API (api.doormile.com/api/v1/admin/*).
// Separate instance from utils/axios.js and the raw axios calls in pages/api/api.js
// because this backend requires a JWT Bearer token on every request and uses its
// own token lifecycle — it does not share the `authname` gate in App.js.
// See express-console-api.md at the repo root for the full endpoint reference.
export const DOORMILE_TOKEN_KEY = 'doormileToken';
export const DOORMILE_USER_KEY = 'doormileUser';
const doormileAxios = axios.create({
baseURL: process.env.REACT_APP_DOORMILE_URL || 'https://api.doormile.com/api/v1'
});
doormileAxios.interceptors.request.use((config) => {
const token = localStorage.getItem(DOORMILE_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
doormileAxios.interceptors.response.use(
(response) => response,
(error) => {
// Mirror utils/session.js's performSessionLogout contract (full localStorage
// wipe + hard navigate) so an expired doormile token logs the console out the
// same way inactivity/manual logout does, instead of leaving a half-authed state.
if (error.response?.status === 401 && !window.location.href.includes('/login')) {
localStorage.clear();
window.location.replace('/login');
}
return Promise.reject(error.response?.data || error);
}
);
export default doormileAxios;

View File

@@ -14,5 +14,13 @@
"ordersummary": "Orders Summary",
"ordersdetails": "Orders Details",
"riderssummary": "Riders Summary",
"dispatch": "Dispatch"
"dispatch": "Dispatch",
"customers": "Customers",
"fleetops": "Fleet & Ops",
"hubs": "Hubs",
"vehicles": "Vehicles",
"tripsheets": "Tripsheets",
"exceptions": "Exceptions",
"competitiveintel": "Competitive Intel",
"appusers": "App Users"
}