updates on the redesign page for all the pages

This commit is contained in:
2026-05-30 17:54:07 +05:30
parent ba88501bc4
commit b8097efbcf
20 changed files with 8664 additions and 3331 deletions

44
.claude/settings.json Normal file
View File

@@ -0,0 +1,44 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(npm install)",
"Bash(npm install:*)",
"Bash(npm run lint)",
"Bash(npm run lint:*)",
"Bash(npm run build)",
"Bash(npm run build:*)",
"Bash(npm start)",
"Bash(npm run start:*)",
"Bash(yarn)",
"Bash(yarn install)",
"Bash(yarn lint)",
"Bash(yarn build)",
"Bash(yarn start)",
"Bash(npx eslint:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git ls-files:*)",
"Bash(git rev-parse:*)",
"Bash(ls:*)",
"Bash(pwd)",
"Bash(wc:*)",
"Bash(node -e:*)",
"Bash(node --version)"
],
"deny": [
"Bash(rm -rf /:*)",
"Bash(git push --force:*)",
"Bash(git reset --hard:*)",
"Bash(git commit --amend:*)",
"Bash(git checkout -- .:*)",
"Bash(git clean -f:*)",
"Bash(git branch -D:*)",
"Bash(npm publish:*)",
"Bash(yarn publish:*)"
]
}
}

View File

@@ -0,0 +1,409 @@
---
name: nearlexpress-docs
description: "Architecture, navigation flow, per-page API map, optimisation pipeline, and FCM rules for the NearlExpress Console (nearlexpress-xpressconsole-d0ee01adebe9). Load when working anywhere in this repo or when the user asks about its pages (orders, deliveries, dispatch, tenants, pricing, customers, riders, invoices, reports, requests), the AI dispatch solvers (routes.workolik.com / routemate.workolik.com), the optimisation → preview → reconcile → assign → notify pipeline, FCM/Firebase notifications, TanStack Query data flow, or which API endpoint a given page calls."
metadata:
version: 2.0.0
---
# 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.
This skill is the **knowledge** Claude needs about the system (what each page does, which API each calls, how the pieces wire together). The **rules** Claude must follow when editing live in the root `CLAUDE.md` — don't restate them here.
---
## 1. Architecture at a glance
- **Brand colour: `#662582` (NearlExpress purple)** — defined in `src/themes/theme/default.js` as `primary.main`. Used by the sidebar, page headers, dialog/popup headers, KPI primary tiles, search bars, edit-action buttons. Gradient pair `#662582 → #9255AB`. Status badges use a separate semantic palette (amber/indigo/cyan/teal/emerald/red/orange) — see root `CLAUDE.md` §6.
- **Stack:** React 18.2 + react-app-rewired (CRA), MUI 5, TanStack Query 5, Redux Toolkit, react-router 6, axios, dayjs, leaflet, firebase 10 (FCM), notistack, formik+yup, react-dnd.
- **Entry:** `src/index.js` → providers (Redux store, QueryClient, Router, ThemeCustomization, Notistack) → `src/App.js`.
- **Auth gate:** `App.js` checks `localStorage.getItem('authname')` — empty redirects to `/login`. Login posts to `jupiter.nearle.app/users/console/login` with `configid: 9` and the device's FCM token.
- **Routing:** `src/routes/MainRoutes.js` declares all `/nearle/*` routes, lazy-loaded via `components/Loadable`. Sidebar items are declared in `src/menu-items/nearle.js`.
- **Data layer:** every server call lives in `src/pages/api/api.js`. Pages call those exports via `useQuery` / `useInfiniteQuery` / `useMutation`. Query keys MUST include every filter parameter so caching invalidates correctly.
- **State:** Redux Toolkit slices (`fcmSlice`, `loginUserSlice`, `menu`, `snackbar`, `toastSlice`, `auth`) for cross-page state; per-page UI state stays in `useState`.
- **Two API bases:** `process.env.REACT_APP_URL` (primary) and `process.env.REACT_APP_URL2` (used for `/users/update`, `/tenants/update`, archival `/orders/getorders`, rider logs).
- **External services (hardcoded):** `routes.workolik.com` (Bike/Manual solver), `routemate.workolik.com` (Auto/multi-trip solver), `jupiter.nearle.app` (login + final delivery commit).
- **localStorage keys:** `authname`, `userid`, `roleid`, `userfcmtoken`, `applocations` (cached zone list).
- **Notifications:** FCM init in `App.js` via `firebase_notification/notification.js` (`generateToken`, `initFirebaseNotificationListener`). Service worker at `public/firebase-messaging-sw.js`.
---
## 2. End-to-end console flow
```mermaid
flowchart TD
classDef entry fill:#f9f6ff,stroke:#a78bfa,stroke-width:2px,color:#2e1065;
classDef auth fill:#fdf2f8,stroke:#ec4899,stroke-width:2px,color:#831843;
classDef nav fill:#eef2ff,stroke:#6366f1,stroke-width:2px,color:#312e81;
classDef core fill:#e0f2fe,stroke:#0ea5e9,stroke-width:2px,color:#0c4a6e;
classDef sub fill:#ecfeff,stroke:#06b6d4,stroke-width:1.5px,color:#155e75;
classDef action fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#78350f;
classDef ext fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#7f1d1d;
classDef report fill:#f5f5f4,stroke:#78716c,stroke-width:1.5px,color:#292524;
classDef state fill:#fdf4ff,stroke:#8b5cf6,stroke-width:1.5px,color:#581c87;
Boot([Browser load<br/>src/index.js]):::entry
Providers["Providers wiring<br/>Redux · QueryClient · Router · Theme · Notistack"]:::entry
App["src/App.js<br/>localStorage('authname') gate"]:::auth
FCMInit["generateToken()<br/>initFirebaseNotificationListener()"]:::auth
SW["public/firebase-messaging-sw.js"]:::auth
Login[/"/login<br/>pages/nearle/login1.js"/]:::auth
Boot --> Providers --> App
App -- no authname --> Login
Login -- POST jupiter.nearle.app/users/console/login --> AuthOK{Auth OK?}
AuthOK -- yes (save authname/userid/roleid/userfcmtoken) --> Layout
AuthOK -- no --> Login
App --> FCMInit --> SW
Layout["layout/MainLayout<br/>Sidebar + Header + Outlet"]:::nav
Sidebar["menu-items/nearle.js"]:::nav
Layout --- Sidebar
Sidebar --> Dispatch
Sidebar --> Orders
Sidebar --> Deliveries
Sidebar --> Tenants
Sidebar --> Pricing
Sidebar --> Customers
Sidebar --> Riders
Sidebar --> Invoice
Sidebar --> ReportsHub
Dispatch[/"/nearle/dispatch<br/>Live Map · Riders · Batches"/]:::core
Orders[/"/nearle/orders<br/>Orders Dashboard"/]:::core
Deliveries[/"/nearle/deliveries<br/>Dispatched Deliveries"/]:::core
Tenants[/"/nearle/tenants<br/>Client/Tenant Management"/]:::core
Pricing[/"/nearle/pricing<br/>Pricing Matrix (master-detail)"/]:::core
Customers[/"/nearle/customers<br/>Customer Directory"/]:::core
Riders[/"/nearle/riders<br/>Rider Pool"/]:::core
Invoice[/"/nearle/invoice<br/>Billing"/]:::core
Requests[/"/nearle/requests<br/>Expense Approvals"/]:::core
ReportsHub[/"/nearle/reports/*<br/>BI Suite"/]:::core
OrdersCreate[/"/orders/create"/]:::sub
OrdersMulti[/"/orders/createorders"/]:::sub
OrdersDetails[/"/orders/details"/]:::sub
OrdersPreview[/"/orders/preview"/]:::sub
DispatchPreview[/"/dispatch/preview"/]:::sub
RidersCreate[/"/riders/create"/]:::sub
RidersEdit[/"/riders/edit"/]:::sub
ClientsCreate[/"/clients/create"/]:::sub
CustomerCreate[/"/customer/create"/]:::sub
InvoicePreview[/"/invoice/preview"/]:::sub
ReportsOS[/"/reports/orderssummary"/]:::report
ReportsOD[/"/reports/ordersdetails"/]:::report
ReportsRS[/"/reports/riderssummary"/]:::report
ReportsRL[/"/reports/riderslogs"/]:::report
Orders --> OrdersCreate
Orders --> OrdersMulti
Orders --> OrdersDetails
Riders --> RidersCreate
Riders --> RidersEdit
Tenants --> ClientsCreate
Customers --> CustomerCreate
Invoice --> InvoicePreview
ReportsHub --> ReportsOS
ReportsHub --> ReportsOD
ReportsHub --> ReportsRS
ReportsHub --> ReportsRL
%% Optimisation pipeline
SelectOrders["Select N pending orders"]:::action
ChooseSolver{"Choose Dispatch Mode"}:::action
SolverBike["Mode 1 · Bike<br/>POST routes.workolik.com<br/>/optimization/riderassign"]:::ext
SolverAuto["Mode 2 · Auto<br/>POST routemate.workolik.com<br/>/optimization/riderassign"]:::ext
SolverManual["Mode 0 · Manual<br/>POST routes.workolik.com<br/>/optimization/createdeliveries"]:::ext
Reconcile["POST routes.workolik.com<br/>/optimization/reconcile-steps"]:::ext
FinalAssign["POST jupiter.nearle.app<br/>/deliveries/createdeliveries"]:::ext
NotifyRider["POST /utils/notifyuser (FCM)"]:::ext
Orders --> SelectOrders --> ChooseSolver
ChooseSolver -- Bike --> SolverBike
ChooseSolver -- Auto --> SolverAuto
ChooseSolver -- Manual --> SolverManual
SolverBike --> OrdersPreview
SolverAuto --> OrdersPreview
SolverManual --> OrdersPreview
OrdersPreview --> DispatchPreview
DispatchPreview -. manual edit .-> Reconcile
Reconcile --> DispatchPreview
DispatchPreview -- Assign --> FinalAssign
FinalAssign --> NotifyRider
FinalAssign -- redirect --> Deliveries
%% Deliveries actions
DChangeRider["Change Rider"]:::action
DCancel["Cancel Delivery"]:::action
DUpdate["Update Amount / Notes"]:::action
Deliveries --> DChangeRider --> NotifyRider
Deliveries --> DCancel --> NotifyRider
Deliveries --> DUpdate
%% Cross-cutting
subgraph CROSS [Cross-cutting infrastructure]
direction LR
Store["Redux: fcm · login · menu · snackbar · toast · auth"]:::state
QC["TanStack Query (cache + infinite scroll)"]:::state
Env["REACT_APP_URL · REACT_APP_URL2 · REACT_APP_GOOGLE_MAPS_API_KEY"]:::state
LS["localStorage: authname · userid · roleid · userfcmtoken · applocations"]:::state
end
Layout -. reads .-> Store
Layout -. reads .-> LS
Orders & Deliveries & Dispatch & Tenants & Pricing & Customers & Riders & Invoice -. fetches via .-> QC
QC -. base URLs from .-> Env
```
---
## 3. Optimisation pipeline (sequence)
```mermaid
sequenceDiagram
autonumber
participant U as Operator
participant O as /nearle/orders
participant S as Solver (routes/routemate.workolik.com)
participant P as /nearle/dispatch/preview
participant R as reconcile-steps
participant J as jupiter.nearle.app
participant D as /nearle/deliveries
participant F as FCM (rider device)
U->>O: Select pending orders (checkbox)
U->>O: Pick dispatch mode (Bike / Auto / Manual)
O->>S: POST riderassign or createdeliveries
S-->>O: Optimised route & assignments
O->>P: Redirect to preview
U->>P: Drag steps / swap rider (optional)
P->>R: POST reconcile-steps (after each manual edit)
R-->>P: Updated, validated sequence
U->>P: Confirm "Assign"
P->>J: POST /deliveries/createdeliveries
J-->>P: 200 OK
P->>F: POST /utils/notifyuser (per rider)
P->>D: Redirect to deliveries dashboard
```
**Hard rule:** never call `POST /deliveries/createdeliveries` after manual edits without calling `POST /optimization/reconcile-steps` first. Skipping reconcile corrupts route sequences.
---
## 4. Page-by-page API map
> Every named function below lives in `src/pages/api/api.js`. URLs use `REACT_APP_URL` unless noted otherwise (`REACT_APP_URL2`).
### 4.1 Authentication
- **Page:** `pages/nearle/login1.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `https://jupiter.nearle.app/live/api/v1/users/console/login` | `POST` | *direct axios* | Console staff auth. Requires `authname`, `password`, `configid: 9`, `userfcmtoken`. |
| `/partners/getlocations/?userid={id}` | `GET` | `fetchAppLocations` | Operational locations assigned to the operator. |
| `REACT_APP_URL2/users/update` | `PUT` | `updateUser` | First-time password setup / change password. |
### 4.2 Orders
- **Page:** `pages/nearle/orders/orders.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/orders/getordersummary/?applocationid={appId}` | `GET` | `fetchPercentageData` / `fetchorderscount` | Status statistics (total, pending, delivered, cancelled). |
| `/orders/tenant/getorders/` | `GET` | `fetchOrders` | Infinite paginated order list. |
| `/utils/getapptypes/?tag=paymentmode` | `GET` | `fetchPaymentType` | Payment-mode dropdown options. |
| `/partners/getriders/?applocationid={appId}` | `GET` | `fetchRidersList` | Active riders for manual assignment. |
| `/tenants/gettenants/?applocationid={appId}&status=active` | `GET` | `getTenants` | Active tenants for filter. |
| `/tenants/gettenantlocations/?tenantid={appId}` | `GET` | `gettenantlocations` | Store branches for a tenant. |
| `/partners/getallriders?partnerid=64` | `GET` | `getallriders` | Full driver pool for bulk dispatch. |
| `/orders/updateorder` | `PUT` | `cancelOrder` | Cancel a single order. |
| `/orders/updatemultipleorders` | `PUT` | `cancelMultipleOrder` | Bulk cancel. |
### 4.3 AI dispatch / optimisation
- **Pages:** `orders/orders.js`, `dispatch/Preview.js`
| Endpoint | Method | Function | Mode |
| --- | --- | --- | --- |
| `https://routes.workolik.com/api/v1/optimization/createdeliveries` | `POST` | `createOptimisationDeliveries` | Mode 0 · Manual |
| `https://routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params={params}` | `POST` | `createAutomationDeliveries` | Mode 1 · Bike hyper-tuning |
| `https://routemate.workolik.com/api/v1/optimization/riderassign?strategy=multi_trip` | `POST` | `createAutomationDeliveries` | Mode 2 · Auto multi-trip |
| `https://routes.workolik.com/api/v1/optimization/reconcile-steps` | `POST` | `reconcileSteps` | Validate sequence after manual edits |
| `https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries` | `POST` | `finalCreatedeliveries` | Commit assignments |
### 4.4 Deliveries
- **Page:** `pages/nearle/deliveries/deliveries.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/deliveries/getdeliveries/` | `GET` | `fetchDeliveries` | Streams delivery records (infinite query). Client-side wave filter by `assigntime`. |
| `/deliveries/deliverysummary/` | `GET` | `fetchCountAPI` / `fetchPercentageAPI` | Lifecycle chip counts (pending/accepted/arrived/picked/active/skipped/delivered/cancelled). |
| `/orders/getorderdetails?orderheaderid={id}` | `GET` | `getorderdetails` | Items detail for invoice preview. |
| `/deliveries/updatedelivery` | `PUT` | `cancelDeliveryAPI` | Cancel with feedback. |
| `/deliveries/updatedelivery` | `PUT` | `changeRiderAPI` | In-transit rider reassignment (also resets `assigntime`). |
| `/deliveries/updatedelivery` | `PUT` | `updateDeliveryAPI` | Amount / notes update. |
| `/utils/notifyuser` | `POST` | `notifyRider` | FCM push to rider's device. Required after every rider-affecting mutation. |
### 4.5 Tenants
- **Page:** `pages/nearle/clients/Tenants.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/tenants/getalltenants/?status={status}&applocationid={appId}&keyword={kw}&pageno={p}&pagesize={n}&moduleid=6` | `GET` | `getalltenants` | Onboarded tenants. |
| `/tenants/gettenantsummary/?moduleid=6&applocationid={appId}` | `GET` | `gettenantsummary` | Tab counts. |
| `/tenants/getpricinglist/?moduleid=6&applocationid={appId}` | `GET` | `getpricinglist` | Subscription / billing contracts. |
| `/tenants/gettenantpricing/?tenantid={id}` | `GET` | *direct axios* | Tenant base pricing config. |
| `REACT_APP_URL2/tenants/update` | `PUT` | *direct axios* | Profile / coordinates / approval. |
| `REACT_APP_URL2/tenants/update/services` | `PUT` | *direct axios* | Customised tenant service rates. |
### 4.6 Pricing
- **Page:** `pages/nearle/clientPricing/clientPricing.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/utils/getallpricing/?applocationid={appId}` | `GET` | *direct axios* | Base pricing models for the zone. |
### 4.7 Customers
- **Page:** `pages/nearle/customers/customers.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/customers/getcustomersummary?applocationid={appId}` | `GET` | `getcustomersummary` | Customer count summary. |
| `/customers/getallcustomers/` | `GET` | `getallcustomers` | Searchable directory (infinite query). |
| `/customers/update` | `PUT` | *direct axios* | Update customer profile + address. |
### 4.8 Riders
- **Pages:** `riders/riders.js`, `createrider.js`, `editRider.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/partners/getallriders/?applocationid={appId}&pageno={p}&pagesize=20&keyword={kw}&status={status}` | `GET` | `fetchAllRiders` | Filtered driver list. |
| `/partners/getallridersummary/?applocationid={appId}&status={status}` | `GET` | `getallridersummary` | Crew breakdowns. |
| `/partners/getriders/?applocationid={appId}&pageno={p}&pagesize=20&keyword={kw}` | `GET` | `fetchRiders` | Active/online riders. |
| `/utils/getriderstatus` | `GET` | `getriderstatus` | Check-in history. |
| `/partners/createrider` | `POST` | *direct axios* | Onboard rider. |
| `/partners/update/rider` | `PUT` | *direct axios* | Edit rider vehicle/license/bank/status. |
### 4.9 Invoices
- **Pages:** `invoice/invoice.js`, `invoicePreview.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/invoice/getinvoiceinsight` | `GET` | `fetchinvoiceinsight` | Global accounting summary. |
| `/invoice/getallinvoice/?billstatus={status}` | `GET` | `fetchdeliverylist` | Invoice list (Paid vs Unpaid tabs). |
### 4.10 Dispatch (live tracker)
- **Page:** `pages/nearle/dispatch/Dispatch.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/utils/getriderperiodiclogs?userid={id}` | `GET` | `getRiderPeriodicLogs` | Rider GPS/battery/connectivity/current order — polled for live map. |
| `https://routes.workolik.com/api/v1/batch/efficiency` | `POST` | `fetchBatchEfficiency` | Per-batch solver efficiency rating. |
### 4.11 Reports / BI
- **Pages:** `reports/ordersSummary.js`, `ordersDetails.js`, `ridersSummary.js`, `ridersLogs.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/deliveries/getreportsummary/?applocationid&tenantid&locationid&fromdate&todate` | `GET` | `getreportsummary` | Aggregated fulfilment. |
| `/deliveries/getreportlocationsummary/?...` | `GET` | `getreportlocationsummary` | Per-branch breakdown. |
| `REACT_APP_URL2/orders/getorders/?fromdate&todate&applocationid&pageno&pagesize` | `GET` | `fetchorderdetails` | Archival historical orders. |
| `/deliveries/getriderbydelivery/?applocationid&tenantid&locationid&fromdate&todate` | `GET` | `getriderbydelivery` | Per-rider delivery trace. |
| `/deliveries/deliverysummary/?applocationid&fromdate&todate` | `GET` | `fetchCount` | Volume metrics over range. |
| `/deliveries/getridersummary/?applocationid&fromdate&todate` | `GET` | `fetchRidersSummary` | Per-rider performance stats. |
| `/partners/getpartners` | `GET` | `fetchLocations` | Merchant locations for routing. |
| `REACT_APP_URL2/partners/getriderlogs/?applocationid&fromdate&todate&keyword` | `GET` | `fetchRidersLogs` | Driver login / checkout audit. |
### 4.12 Requests (expense reimbursements)
- **Page:** `pages/nearle/requests/requests.js`
| Endpoint | Method | Function | Purpose |
| --- | --- | --- | --- |
| `/payments/requests/getpaymentrequest/?partnerid={tid}&status={0 or 1}` | `GET` | `clientdetailspending` / `clientdetailsapproved` | Status 0 = Pending, 1 = Approved. |
---
## 5. Route → file map (full)
| Route | File | Lazy import name |
| --- | --- | --- |
| `/login` | `pages/nearle/login1.js` | `Login` |
| `/nearle/dispatch` | `pages/nearle/dispatch/Dispatch.js` | `Dispatch` |
| `/nearle/dispatch/preview` | `pages/nearle/dispatch/Preview.js` | `DispatchPreview` |
| `/nearle/orders` | `pages/nearle/orders/orders.js` | `Orders` |
| `/nearle/orders/preview` | `pages/nearle/orders/OrdersPreview.js` | `OrdersPreview` |
| `/nearle/orders/create` | `pages/nearle/orders/createorder1.js` | `Createorder1` |
| `/nearle/orders/createorders` | `pages/nearle/orders/multipleOrders.js` | `MultipleOrders` |
| `/nearle/orders/details` | `pages/nearle/orders/details.js` | `Details` |
| `/nearle/deliveries` | `pages/nearle/deliveries/deliveries.js` | `Deliveries` |
| `/nearle/tenants` | `pages/nearle/clients/Tenants.js` | `Tenants` |
| `/nearle/clients/create` | `pages/nearle/clients/createclient.js` | `Createclient` |
| `/nearle/pricing` | `pages/nearle/clientPricing/clientPricing.js` | `ClientsPricing` |
| `/nearle/customers` | `pages/nearle/customers/customers.js` | `Customers` |
| `/nearle/customer/create` | `pages/nearle/clients/createCustomer.js` | `CreateCustomer` |
| `/nearle/riders` | `pages/nearle/riders/riders.js` | `Riders` |
| `/nearle/riders/create` | `pages/nearle/riders/createrider.js` | `Createrider` |
| `/nearle/riders/edit` | `pages/nearle/riders/editRider.js` | `EditRider` |
| `/nearle/invoice` | `pages/nearle/invoice/invoice.js` | `Invoice` |
| `/nearle/invoice/preview` | `pages/nearle/invoice/invoicePreview.js` | `InvoicePreview` |
| `/nearle/requests` | `pages/nearle/requests/requests.js` | `Requests` |
| `/nearle/reports/orderssummary` | `pages/nearle/reports/ordersSummary.js` | `OrdersSummary` |
| `/nearle/reports/ordersdetails` | `pages/nearle/reports/ordersDetails.js` | `OrdersDetails` |
| `/nearle/reports/riderssummary` | `pages/nearle/reports/ridersSummary.js` | `RidersSummary` |
| `/nearle/reports/riderslogs` | `pages/nearle/reports/ridersLogs.js` | `RidersLogs` |
| `/viewprofile` | `pages/nearle/viewProfile.js` | `ViewProfile` |
| `/maintenance/{404,500,under-construction,coming-soon}` | `pages/maintenance/*` | — |
---
## 6. Per-page state-flow primer
```mermaid
flowchart LR
subgraph PAGE [Any operator page]
UI[React component] --> QH["useQuery / useInfiniteQuery / useMutation"]
UI --> US["useState (local UI state)"]
end
QH -- "queryKey = [name, appId, filters...]" --> Cache["TanStack Query cache"]
QH -- queryFn --> ApiLayer["pages/api/api.js"]
ApiLayer --> AxRaw["axios (raw)"]
ApiLayer -. some calls .-> AxInt["utils/axios.js (401 interceptor)"]
AxRaw --> EnvURL["process.env.REACT_APP_URL"]
AxRaw --> EnvURL2["process.env.REACT_APP_URL2"]
AxRaw --> ExtOpt["routes / routemate / jupiter (hardcoded)"]
UI -. dispatches .-> RTK["Redux store"]
UI -. emits toast .-> Notistack
UI -. reads .-> LS["localStorage"]
```
**Conventions for new pages:**
- Query keys must include every filter parameter so caching invalidates correctly.
- Use `useInfiniteQuery` for paginated rows; auto-drain with an `IntersectionObserver` on a sentinel `<div ref={loadMoreRef} />` at the bottom of the `TableContainer`. The canonical pattern is in `deliveries.js` (search for `useInfiniteQuery({` and the adjacent `IntersectionObserver` block).
- Mutations: after success, call `.refetch()` on every related query. Project does not yet use `queryClient.invalidateQueries`.
- Errors → `OpenToast(message, 'error', 2000)` from `components/third-party/OpenToast`.
---
## 7. FCM rules
- Initialised once in `App.js` (`generateToken` + `initFirebaseNotificationListener`).
- After any mutation affecting a rider (assign, cancel, change rider), call `POST /utils/notifyuser` with the target rider's `userfcmtoken`.
- The service worker (`public/firebase-messaging-sw.js`) handles background tokens and desktop notifications. Subtle bugs there cause silent delivery failures — do not edit without a clear reason.
---
## 8. Glossary
- **Zone / `applocationid`** — operator's warehouse / operational hub. `0` means "All Zones". Every list endpoint takes this.
- **Tenant** — the business / brand whose orders flow through the platform.
- **Location** — a tenant's branch within a zone.
- **Rider / Partner** — the delivery driver.
- **Batch / Wave** — a time-of-day slot (Morning 08, Afternoon 912, Evening 1619). Bucketed by `assigntime` on the deliveries page in **local** time to match dispatch's bucketing.
- **Slab** — a pricing tier (base + per-km + min-km + max-km + min-orders).
- **Solver hypertuning** — the optimiser's strategy (Balanced, Fuel Saver, Aggressive, Strict Zones).
---
## 9. Cross-references
- Project rules and conventions Claude must follow are in the root `CLAUDE.md`. Don't restate them here.
- Page-level design system tokens (the `DT` block, `pillFieldSx`, `SoftPaper`, `AccentAvatar`) are documented in `CLAUDE.md` §6 — the implementation source-of-truth is `src/pages/nearle/deliveries/deliveries.js` lines 104297.

302
CLAUDE.md Normal file
View File

@@ -0,0 +1,302 @@
# CLAUDE.md — NearlExpress Console (xpressconsole)
> Project-level rules and conventions for Claude Code when working in this repo.
> **Read this in full before editing.** When in doubt about a pattern, copy from `src/pages/nearle/deliveries/deliveries.js` — it is the canonical reference for both the design system and the data layer.
---
## 1. What this is
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.
For the per-page API map and architectural flow chart, see the project skill **`nearlexpress-docs`** (`.claude/skills/nearlexpress-docs/SKILL.md`). Do not duplicate that content here.
---
## 2. Stack (pinned — do not upgrade without a deliberate audit)
- **React 18.2** + **react-app-rewired 2.2** (CRA, not Next.js — no `pages/` file-system routing; routes live in `src/routes/MainRoutes.js`).
- **MUI 5.12** (`@mui/material`) is the primary UI library. Ant Design (`antd 5.11`) is used **only** for `<Empty />` placeholders in legacy pages — prefer MUI for new work.
- **Icons**: `@ant-design/icons` (page actions: `EditOutlined`, `EyeOutlined`, `CloseOutlined`, etc.) **and** `react-icons` (page identity: `Md*`, `Tb*`, `Fi*`, `Gi*`, `Lu*`). Both coexist; pick by what's already imported nearby.
- **Data layer**: `@tanstack/react-query 5.17` (`useQuery`, `useInfiniteQuery`, `useMutation`). All server fetches go through this. Do **not** introduce `fetch`, `swr`, or `useEffect`-driven fetches for new code.
- **HTTP**: `axios 1.3`. Most pages import `axios` directly (raw). `src/utils/axios.js` exists with a 401 → `/login` interceptor but is **not** widely adopted — match the surrounding file rather than introducing it.
- **State**: `@reduxjs/toolkit 1.9` for cross-page state (FCM token, login user, menu, snackbar, toast). Page-local UI state stays in `useState`.
- **Routing**: `react-router-dom 6.10`, lazy-loaded via `components/Loadable`.
- **Forms**: `formik 2.2` + `yup 1.1` where present; new simple forms can use plain `useState`.
- **Dates**: `dayjs 1.11` with `utc` plugin (already extended at the top of `deliveries.js`). Use `dayjs(...).utc()` for backend timestamps and bare `dayjs(...)` for local-time bucketing — see the batch-bucketing comment in `deliveries.js` for the rationale.
- **Maps**: `leaflet` + `react-leaflet`, plus `@react-google-maps/api` for Google. Geocoding via `react-geocode`. Maps API key is `process.env.REACT_APP_GOOGLE_MAPS_API_KEY`.
- **Notifications**: `firebase 10.14` (FCM) — see `src/firebase_notification/`. Toasts via `notistack 3`.
- **Drag-and-drop**: `react-dnd` (used on the Dispatch Preview page).
---
## 3. Dev workflow
```bash
# Install
npm install # or `yarn`
# Run locally (uses .env)
npm start
# Run with a specific env file
npm run start:dev # env.development
npm run start:staging # env.staging
# Build
npm run build
npm run build:dev
npm run build:staging
# Lint (the only checked gate — there are no tests of consequence)
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`), `REACT_APP_GOOGLE_MAPS_API_KEY`. The optimiser URLs (`routes.workolik.com`, `routemate.workolik.com`) and the Jupiter auth URL (`jupiter.nearle.app`) are hardcoded — see the `nearlexpress-docs` skill.
- **Dev server runs on `http://localhost:3000`**. The user usually has it running already — assume it is up when reporting "reload to see it".
---
## 4. Hard constraints (do NOT)
1. **Do not introduce Next.js / SSR patterns.** No `getServerSideProps`, no `app/` directory, no `next/*` imports. This is CRA.
2. **Do not rewrite shared design tokens.** Every new page that needs the polished UI must reuse the `DT` token block (see §6). Do not invent a parallel palette.
3. **Do not change `package.json` dependency versions** unless explicitly asked. The build is sensitive to webpack/svgr/react-scripts versions (see `resolutions` in `package.json`).
4. **Do not bypass the dispatch reconcile step.** After any manual edit on `/nearle/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`.
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.
---
## 5. Architecture map
```
src/
├── App.js # Auth gate (localStorage('authname') → /login), mounts FCM listener, ThemeCustomization, Locales, Notistack, Snackbar
├── index.js # Provider wiring: Redux store, TanStack QueryClient, Router
├── config.js # Theme constants (DRAWER_WIDTH=260, fontFamily, mode, presetColor, ThemeMode, MenuOrientation, ThemeDirection)
├── routes/
│ ├── index.js # Combines MainRoutes + LoginRoutes
│ ├── MainRoutes.js # All /nearle/* routes, lazy-loaded via Loadable(lazy(...))
│ └── LoginRoutes.js
├── layout/
│ ├── MainLayout/ # Sidebar + header frame (used for all logged-in pages)
│ └── CommonLayout/ # Bare frame (login, maintenance pages)
├── menu-items/
│ └── nearle.js # Sidebar definition — id, title (FormattedMessage), url, icon. Must add new pages here.
├── store/
│ ├── index.js # configureStore + useDispatch/useSelector exports
│ └── reducers/ # fcmSlice, loginUserSlice, menu, snackbar, toastSlice, auth, actions
├── themes/ # MUI theme, palette, typography, shadows, overrides
├── pages/
│ ├── api/api.js # CENTRAL API layer — every query/mutation function lives here
│ └── nearle/ # All operator pages, grouped by feature folder
└── components/
├── Loadable.js # React.Suspense wrapper for lazy routes
├── Loader.js # Full-page backdrop spinner
├── nearle_components/
│ ├── DebounceSearchBar.js # 500ms-debounced search input with ⌘/Ctrl+K focus shortcut
│ ├── LocationAutocomplete.js # Zone picker (has `pill` variant — use it for new pages)
│ ├── LoaderWithImage.js
│ ├── GlobalToast.js
│ ├── SearchBar.js
│ ├── TableLoader.js
│ └── TitleCard.js # LEGACY — do not use for new pages (replaced by the gradient Paper header in §6)
└── third-party/
└── OpenToast.js # Wrapper around notistack — use this for toast emissions
```
- **Absolute imports work from `src/`** thanks to `jsconfig.json` (`"baseUrl": "src"`). Prefer `import x from 'pages/api/api'` over deep relative paths. Look at the surrounding file's import style and match it.
---
## 6. Design system (the `DT` token block)
The polished pages (`deliveries.js`, `clients/Tenants.js`, `clientPricing/clientPricing.js`) share a token block at the top of the file. Every new operator page must paste and reuse this block — do not invent fresh colours, spacings, or radius numbers.
```js
const DT = {
radiusPill: 999,
radiusCard: 16,
radiusInner: 12,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
// Alpha-suffix helpers — append hex transparency to any accent colour.
const a = (c, suffix) => `${c}${suffix}`;
const tint = (c) => a(c, '08'); // very subtle surface tint
const soft = (c) => a(c, '18'); // soft chip / avatar bg
const ring = (c) => a(c, '26'); // focus ring
const edge = (c) => a(c, '55'); // resting border
// Pill-style filter inputs.
const pillFieldSx = (color) => ({
'& .MuiOutlinedInput-root': {
borderRadius: DT.radiusPill + 'px',
bgcolor: tint(color),
fontWeight: 600,
'& fieldset': { borderColor: edge(color), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: color },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(color)}` },
'&.Mui-focused fieldset': { borderColor: color, borderWidth: 2 }
}
});
// Soft Paper used by all Autocomplete popups (matches the deliveries batch dropdown).
const SoftPaper = (props) => (
<Paper {...props} sx={{ mt: 0.75, borderRadius: 2, boxShadow: DT.shadowPop, border: '1px solid', borderColor: 'divider', overflow: 'hidden' }} />
);
// Colored avatar — flips between filled and soft based on `selected`.
const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar sx={{
width: size, height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}>{children}</Avatar>
);
```
### Page anatomy (every operator page should follow this order)
> **Use brand purple `#662582` (with light variant `#9255AB`) for every brand surface below.** Don't use indigo `#6366f1` — that's reserved for the "Accepted" status badge only.
1. **Gradient header `<Paper>`**`linear-gradient(135deg, ${tint('#662582')} 0%, ${tint('#9255AB')} 100%)`, 48px filled `#662582` avatar with a page icon, `Typography variant="h3"` title, "Live · {zone}" sub-line with an 8px green pulsing dot, and a pill `LocationAutocomplete` on the right (`pill accentColor="#662582" paperComponent={SoftPaper}`).
2. **KPI tiles row**`Grid` of 34 `Paper` cards, each with a 3px top stripe gradient, uppercase eyebrow label, large bold number, and a soft-tinted avatar holding an icon. The "primary" tile uses brand purple `#662582`; other tiles use semantic status colours.
3. **Filter bar `<Paper>`** (optional) — pill-style `Autocomplete`s using `pillFieldSx(color)` + `SoftPaper`, each with an `AccentAvatar` start-adornment.
4. **Pill status tabs + pill search** — tabs as clickable `<Box>` pills (active = filled accent + glow ring, inactive = `tint` bg + `edge` border), each with an avatar icon and a count badge. Tab accents use the **semantic status colour** for each tab (pending → amber, delivered → emerald, etc.). Search via `DebounceSearchBar` styled with brand purple `#662582` (tint bg, edge border, focus ring).
5. **Table `<Paper>`** with `<TableContainer>` + sticky `<TableHead>` — uppercase muted headers on `DT.surfaceAlt`, rows with `borderBottom: 1px solid ${DT.divider}` and `:hover` row tint. Scrollbar thumb uses brand purple `edge('#662582')`.
6. **Status badges in cells**`Stack` with `AccentAvatar` + label inside a soft pill (tint bg, edge border). Status colours come from a per-page `STATUS_META` map keyed by lowercase status string — these are **semantic**, not brand.
7. **Edit / action icon buttons** — soft-pill `IconButton` using brand purple `#662582` (NOT `#8b5cf6` — that overlaps with the "Picked" status badge and confuses operators).
8. **Empty state** — centered 64px avatar (soft grey), bold "No X to show" line, and a muted helper sentence. Do not use antd `<Empty />` for new code.
### Universal brand colour
**`#662582` — NearlExpress brand purple.** This is the canonical primary colour for this app, defined in `src/themes/theme/default.js` as `primary.main`. It drives the sidebar, the logo, and is what every new surface (page headers, KPI primary tile, search bars, edit-action buttons, dialog/popup headers, scrollbars) must use as the brand accent.
Variants (also from `theme/default.js`):
| Token | Hex | Use |
|---|---|---|
| `primary.lighter` | `#E8D9EF` | Very subtle wash bg |
| `primary.light` / `primary.400` | `#9255AB` | Gradient pair with main |
| `primary.main` | `#662582` | Brand primary — default for all brand surfaces |
| `primary.dark` | `#4D1C61` | Hover / pressed states |
| `primary.darker` | `#260E30` | Deep contrast text on light bg |
**Page header / dialog header gradient:** `linear-gradient(135deg, ${tint('#662582')} 0%, ${tint('#9255AB')} 100%)` (subtle wash) or `linear-gradient(135deg, #662582 0%, #9255AB 100%)` (solid, for dialog titles).
> **Migration note:** `deliveries.js`, `Tenants.js`, and `clientPricing.js` currently use `#6366f1` (indigo) as their brand accent — a holdover from the first design pass before brand purple was canonicalised. They are scheduled to migrate to `#662582`. `customers.js` and the createorder1 Saved-Address dialog have already been migrated.
### Status palette (semantic — distinct from brand)
These colour-code lifecycle states. Do **not** swap them for brand purple — operators rely on the colour to identify status at a glance.
| Meaning | Colour |
|--------------------------|-----------|
| Pending / waiting | `#f59e0b` (amber) |
| Accepted / assigned | `#6366f1` (indigo — semantically distinct from brand purple) |
| Arrived | `#06b6d4` (cyan) |
| Picked up | `#8b5cf6` (light purple — distinct from brand) |
| Active / in-transit | `#14b8a6` (teal) |
| Delivered / success | `#10b981` (emerald) |
| Cancelled / error | `#ef4444` (red) |
| Skipped | `#f97316` (orange) |
| Neutral / muted | `#94a3b8` (slate) |
| Sky accent (tenant, info)| `#0ea5e9` |
### Don'ts for the design system
- Don't use raw MUI `<Tabs>` for status/filter switching — they were replaced by the pill `<Box>` pattern on every redesigned page. Pages that still use `<Tabs>` (e.g. for inline collapse views) are tolerated but new top-level navigation should use pills.
- Don't reach for `purple lighter` / `e1bee7` or other Mantis theme accents on new pages — those exist in legacy code only.
- Don't apply box-shadow to row hover; only tint the background (`DT.surfaceAlt`).
- Don't change avatar sizes mid-page — pick from `{18, 20, 22, 24, 28, 32, 36, 40, 44, 48, 56, 64}` and stay consistent.
---
## 7. Data layer rules
- **Every server call belongs in `src/pages/api/api.js`** as an exported async function. Page files should never construct URLs inline for `GET`s. Mutations are sometimes kept inline (e.g. tenant pricing update) — that's tolerated but discouraged.
- **Use TanStack Query for all reads.** Query keys must include every filter that affects the response so the cache invalidates correctly. Example:
```js
useQuery({
queryKey: ['fetchCountData', appId, userid, startdate, enddate, tenantid, locationid, riderid, tabstatus],
queryFn: () => fetchCountAPI(appId, userid, ...)
});
```
- **Use `useInfiniteQuery` for paginated rows.** `getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined`. Auto-drain pages with an `IntersectionObserver` on a sentinel `<div ref={loadMoreRef} />` placed at the bottom of the `<TableContainer>`. The canonical pattern lives in `deliveries.js` (search for `useInfiniteQuery({` and the adjacent `IntersectionObserver` setup).
- **Mutations go through `useMutation`** with `onSuccess` / `onError`. After a successful mutation, call `.refetch()` on every related query — the codebase does not yet use `queryClient.invalidateQueries`. Match the surrounding file.
- **Errors → `OpenToast(message, 'error', 2000)`** from `components/third-party/OpenToast`. Toasts always anchor top-right. Duration is 2000ms for normal, 3000ms for "must be acknowledged".
- **Skeleton loading**: use `OrdersTableSkeleton` for tables (props: `rowsPerPage` default 5, `col` default 1 — `col` is the count of variable middle columns; total rendered columns = `col + 4` since checkbox, serial, notes, status, and actions are always present). Use `Loader` for full-page modal backdrop and `LoaderWithImage` for inline "loading deliveries…" states.
---
## 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.
- **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.
---
## 9. FCM / notifications
- Initialised once in `App.js` via `generateToken()` + `initFirebaseNotificationListener()` from `firebase_notification/notification.js`.
- After **any** mutation that affects a rider (assign, cancel, change rider), call `POST /utils/notifyuser` with the target rider's `userfcmtoken`. Don't forget to also call `notifyRider` mutation on success.
- Service worker file: `public/firebase-messaging-sw.js` — do not edit unless the user explicitly asks. Subtle bugs here cause silent delivery failures.
---
## 10. Routing & adding a new page
1. Create the page file under `src/pages/nearle/<feature>/<name>.js`.
2. Add the lazy import at the top of `src/routes/MainRoutes.js`:
```js
const MyPage = Loadable(lazy(() => import('pages/nearle/<feature>/<name>')));
```
3. Register the route inside the `nearle` children array.
4. Add a sidebar entry in `src/menu-items/nearle.js` (id, `<FormattedMessage id="..." />` title, url, icon).
5. Add the i18n key in the relevant `src/utils/locales/*.json` file (or wherever the project keeps them; most existing items use the English string as the id).
---
## 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`.
- **`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.
- **Maps API key** is read from env on every render in some places — wrapping it in a `useMemo` is fine but unnecessary. Don't add new direct `process.env.REACT_APP_GOOGLE_MAPS_API_KEY` reads outside map-related files.
- **`Tenants.js` has known dangling references** (`setClientstatus`, `setState`, `setSuburb`, `setTenanatPricing`, `<Collapse in={open}>`) inherited from legacy code. They are tolerated. Do **not** "fix" them as a drive-by — they are out of scope and removing them risks breaking the row collapse contents.
---
## 12. Communication style for changes
- **For UI changes**, the user runs the dev server at `http://localhost:3000`. After editing a page, end with one short sentence telling them which route to reload (e.g. "Reload http://localhost:3000/nearle/pricing to see it").
- **Don't commit unless asked.** The project has `package-lock.json` + `yarn.lock` both present — match the user's last commit's lockfile choice before suggesting `npm install` vs `yarn install`.
- **Verify after edits** with a quick JSX parse check (the user has `acorn` available in `node_modules`) — do not assume the build will pass just because Edit succeeded.
- **Mark `// removed` / `// unused` comments as code smells** — delete dead code instead of commenting it out, unless the user explicitly says "keep it commented for now".
---
## 13. Cross-references
- **For the per-page API map and the architectural flow chart** (which endpoint each page calls, what the optimisation pipeline does, FCM flow), invoke the project skill **`nearlexpress-docs`** (`.claude/skills/nearlexpress-docs/SKILL.md`) rather than restating the content here.
- **For shared design patterns** between pages, the source of truth is the `DT` token block and helpers near the top of `src/pages/nearle/deliveries/deliveries.js` (search for `const DT = {`). Copy from there, don't redesign.

255
FLOW.md Normal file
View File

@@ -0,0 +1,255 @@
# NearlExpress Console — Repository Flow Graph
> 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)).
>
> The same diagrams (plus the per-endpoint table) are also available to Claude via the project-level skill at `.claude/skills/nearlexpress-docs/SKILL.md`. **This file is the human-facing copy** — kept at the root for quick GitHub browsing.
---
## 1. End-to-end console flow
```mermaid
flowchart TD
%% ============================ Styling ============================
classDef entry fill:#f9f6ff,stroke:#a78bfa,stroke-width:2px,color:#2e1065;
classDef auth fill:#fdf2f8,stroke:#ec4899,stroke-width:2px,color:#831843;
classDef nav fill:#eef2ff,stroke:#6366f1,stroke-width:2px,color:#312e81;
classDef core fill:#e0f2fe,stroke:#0ea5e9,stroke-width:2px,color:#0c4a6e;
classDef sub fill:#ecfeff,stroke:#06b6d4,stroke-width:1.5px,color:#155e75;
classDef action fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#78350f;
classDef solver fill:#fffbeb,stroke:#d97706,stroke-width:2px,color:#7c2d12;
classDef api fill:#f0fdf4,stroke:#10b981,stroke-width:1.5px,color:#064e3b;
classDef state fill:#fdf4ff,stroke:#8b5cf6,stroke-width:1.5px,color:#581c87;
classDef ext fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#7f1d1d;
classDef report fill:#f5f5f4,stroke:#78716c,stroke-width:1.5px,color:#292524;
%% ============================ Entry & Auth ============================
Boot([Browser load<br/>src/index.js]):::entry
Providers["Providers wiring<br/>Redux store · QueryClient · Router · ThemeCustomization · Notistack"]:::entry
App["src/App.js<br/>localStorage('authname') gate"]:::auth
FCMInit["generateToken()<br/>initFirebaseNotificationListener()"]:::auth
SW["public/firebase-messaging-sw.js<br/>FCM Service Worker"]:::auth
Login[/"/login<br/>pages/nearle/login1.js"/]:::auth
Boot --> Providers --> App
App -- no authname --> Login
Login -- POST /users/console/login<br/>(jupiter.nearle.app) --> AuthOK{Auth OK?}
AuthOK -- yes, save authname/userid/roleid/userfcmtoken --> Layout
AuthOK -- no --> Login
App --> FCMInit --> SW
%% ============================ Main Layout & Sidebar ============================
Layout["layout/MainLayout<br/>Sidebar + Header + Outlet"]:::nav
Sidebar["menu-items/nearle.js<br/>(sidebar definition)"]:::nav
Layout --- Sidebar
Sidebar --> Dispatch
Sidebar --> Orders
Sidebar --> Deliveries
Sidebar --> Tenants
Sidebar --> Pricing
Sidebar --> Customers
Sidebar --> Riders
Sidebar --> Invoice
Sidebar --> ReportsHub
Sidebar --> ViewProfile
%% ============================ Top-level pages ============================
Dispatch[/"/nearle/dispatch<br/>Live Map · Riders · Batches"/]:::core
Orders[/"/nearle/orders<br/>Orders Dashboard"/]:::core
Deliveries[/"/nearle/deliveries<br/>Dispatched Deliveries"/]:::core
Tenants[/"/nearle/tenants<br/>Client/Tenant Management"/]:::core
Pricing[/"/nearle/pricing<br/>Pricing Matrix (master-detail)"/]:::core
Customers[/"/nearle/customers<br/>Customer Directory"/]:::core
Riders[/"/nearle/riders<br/>Rider Pool"/]:::core
Invoice[/"/nearle/invoice<br/>Billing"/]:::core
Requests[/"/nearle/requests<br/>Expense Approvals"/]:::core
ReportsHub[/"/nearle/reports/*<br/>BI Suite"/]:::core
ViewProfile[/"/viewprofile<br/>Profile Manager"/]:::sub
%% ============================ Sub-routes ============================
OrdersCreate[/"/orders/create<br/>Single Order Create"/]:::sub
OrdersMulti[/"/orders/createorders<br/>Bulk Order Create"/]:::sub
OrdersDetails[/"/orders/details<br/>Order Detail View"/]:::sub
OrdersPreview[/"/orders/preview<br/>Optimised Preview"/]:::sub
DispatchPreview[/"/dispatch/preview<br/>Dispatch Review &amp; Adjust"/]:::sub
RidersCreate[/"/riders/create<br/>Onboard Rider"/]:::sub
RidersEdit[/"/riders/edit<br/>Edit Rider"/]:::sub
ClientsCreate[/"/clients/create<br/>Onboard Tenant"/]:::sub
CustomerCreate[/"/customer/create<br/>Onboard Customer"/]:::sub
InvoicePreview[/"/invoice/preview<br/>Invoice Preview"/]:::sub
ReportsOS[/"/reports/orderssummary"/]:::report
ReportsOD[/"/reports/ordersdetails"/]:::report
ReportsRS[/"/reports/riderssummary"/]:::report
ReportsRL[/"/reports/riderslogs"/]:::report
Orders --> OrdersCreate
Orders --> OrdersMulti
Orders --> OrdersDetails
Riders --> RidersCreate
Riders --> RidersEdit
Tenants --> ClientsCreate
Customers --> CustomerCreate
Invoice --> InvoicePreview
ReportsHub --> ReportsOS
ReportsHub --> ReportsOD
ReportsHub --> ReportsRS
ReportsHub --> ReportsRL
%% ============================ Optimisation Pipeline ============================
SelectOrders["Select N pending orders<br/>(checkbox column)"]:::action
ChooseSolver{"Choose Dispatch Mode"}:::solver
SolverBike["Mode 1 · Bike<br/>POST routes.workolik.com<br/>/optimization/riderassign"]:::ext
SolverAuto["Mode 2 · Auto<br/>POST routemate.workolik.com<br/>/optimization/riderassign"]:::ext
SolverManual["Mode 0 · Manual<br/>POST routes.workolik.com<br/>/optimization/createdeliveries"]:::ext
Reconcile["POST routes.workolik.com<br/>/optimization/reconcile-steps"]:::ext
FinalAssign["POST jupiter.nearle.app<br/>/deliveries/createdeliveries"]:::ext
NotifyRider["POST /utils/notifyuser<br/>(FCM push to rider)"]:::ext
Orders --> SelectOrders --> ChooseSolver
ChooseSolver -- Bike --> SolverBike
ChooseSolver -- Auto --> SolverAuto
ChooseSolver -- Manual --> SolverManual
SolverBike --> OrdersPreview
SolverAuto --> OrdersPreview
SolverManual --> OrdersPreview
OrdersPreview --> DispatchPreview
DispatchPreview -. drag-and-drop / change rider .-> Reconcile
Reconcile --> DispatchPreview
DispatchPreview -- Assign --> FinalAssign
FinalAssign --> NotifyRider
FinalAssign -- redirect --> Deliveries
%% ============================ Deliveries actions ============================
DChangeRider["Change Rider"]:::action
DCancel["Cancel Delivery"]:::action
DUpdate["Update Amount / Notes"]:::action
Deliveries --> DChangeRider --> NotifyRider
Deliveries --> DCancel --> NotifyRider
Deliveries --> DUpdate
Deliveries -. row expand .-> OrderItems["Order items detail<br/>GET /orders/getorderdetails"]:::api
%% ============================ Dispatch (live) ============================
DispatchMap["Live Leaflet map<br/>Riders · Routes · Batches"]:::action
RiderLogs["GET /utils/getriderperiodiclogs<br/>(polled)"]:::api
BatchEff["POST routes.workolik.com<br/>/batch/efficiency"]:::ext
Dispatch --> DispatchMap
Dispatch --> RiderLogs
Dispatch --> BatchEff
Dispatch -. drilldown .-> DispatchPreview
%% ============================ Tenants / Pricing ============================
TenantTabs["Tabs: Active · Pending · Inactive"]:::action
TenantView["Row → View (collapse)"]:::action
TenantEdit["Row → Edit (collapse)"]:::action
TenantApprove["Approve Pending<br/>+ Set Pricing Dialog"]:::action
Tenants --> TenantTabs
Tenants --> TenantView
Tenants --> TenantEdit
Tenants --> TenantApprove
PricingMaster["Master: Location List"]:::action
PricingDetail["Detail: Slabs (StatChips)"]:::action
Pricing --> PricingMaster --> PricingDetail
%% ============================ Cross-cutting concerns ============================
subgraph CROSS [Cross-cutting infrastructure]
direction LR
Store["Redux Toolkit store<br/>fcmSlice · loginUserSlice<br/>menu · snackbar · toastSlice · auth"]:::state
QC["@tanstack/react-query<br/>QueryClient (cache + infinite scroll)"]:::state
AxiosBase["axios (raw) +<br/>utils/axios.js (401 → /login)"]:::state
Env["env vars: REACT_APP_URL ·<br/>REACT_APP_URL2 · REACT_APP_GOOGLE_MAPS_API_KEY"]:::state
LS["localStorage:<br/>authname · userid · roleid ·<br/>userfcmtoken · applocations"]:::state
Toasts["Notistack · OpenToast wrapper"]:::state
end
Layout -. reads .-> Store
Layout -. reads .-> LS
Layout -. uses .-> QC
Orders & Deliveries & Dispatch & Tenants & Pricing & Customers & Riders & Invoice & ReportsHub -. fetches via .-> QC
QC -. HTTP .-> AxiosBase
AxiosBase -. base URLs from .-> Env
DChangeRider & DCancel & DUpdate -. updates --> QC
Toasts -. consumed by .-> Sidebar
```
---
## 2. Optimisation pipeline (sequence)
```mermaid
sequenceDiagram
autonumber
participant U as Operator
participant O as /nearle/orders
participant S as Solver (routes/routemate.workolik.com)
participant P as /nearle/dispatch/preview
participant R as reconcile-steps
participant J as jupiter.nearle.app
participant D as /nearle/deliveries
participant F as FCM (rider device)
U->>O: Select pending orders (checkbox)
U->>O: Pick dispatch mode (Bike / Auto / Manual)
O->>S: POST riderassign or createdeliveries
S-->>O: Optimised route & assignments
O->>P: Redirect to preview with payload
U->>P: Drag steps / swap rider (optional)
P->>R: POST reconcile-steps (after each manual edit)
R-->>P: Updated, validated sequence
U->>P: Confirm "Assign"
P->>J: POST /deliveries/createdeliveries
J-->>P: 200 OK, deliveries persisted
P->>F: POST /utils/notifyuser (per rider)
F-->>R: (out-of-band ack)
P->>D: Redirect to deliveries dashboard
```
---
## 3. State & data flow (per page)
```mermaid
flowchart LR
subgraph PAGE [Any operator page]
UI[React component] --> QH["useQuery / useInfiniteQuery / useMutation"]
UI --> US["useState (local UI state)"]
end
QH -- "queryKey = [name, appId, filters...]" --> Cache["TanStack Query cache"]
QH -- queryFn --> ApiLayer["pages/api/api.js<br/>(named async fns)"]
ApiLayer --> AxRaw["axios (raw)"]
ApiLayer -. some calls .-> AxInt["utils/axios.js<br/>(401 interceptor)"]
AxRaw --> EnvURL["process.env.REACT_APP_URL"]
AxRaw --> EnvURL2["process.env.REACT_APP_URL2"]
AxRaw --> ExtOpt["routes / routemate / jupiter (hardcoded)"]
UI -. dispatches .-> RTK["Redux store (fcm · login · menu · snackbar · toast · auth)"]
UI -. emits toast .-> Notistack
UI -. reads .-> LS["localStorage"]
Notistack -. renders .-> Header[Top-right toasts]
RTK -. read by .-> Layout[MainLayout / Sidebar]
```
---
## 4. Reading the graph
- **Solid arrow** = direct call / navigation.
- **Dotted arrow** = read-only reference, polling, or async side-effect (FCM, cache, toast).
- **Cluster colours**:
- Purple = entry/auth
- Indigo = navigation frame
- Cyan/Sky = top-level pages
- Soft cyan = sub-routes
- Amber = user-initiated actions
- Red = external service (workolik, jupiter, Firebase)
- Emerald = console-internal API/data
- Violet = cross-cutting infra
For the full per-endpoint table with payloads and use cases (Authentication, Orders, Deliveries, Tenants, Pricing, Customers, Riders, Invoices, Dispatch, Reports, Requests), see `.claude/skills/nearlexpress-docs/SKILL.md`.

View File

@@ -0,0 +1,112 @@
# CLAUDE.md — `src/components/nearle_components/`
Rules for editing the shared NearlExpress UI primitives.
These components are imported across every page. Changes here have **fan-out impact** — measure twice, cut once.
---
## 1. What lives here
| Component | Use | Status |
|---|---|---|
| `LocationAutocomplete.js` | Zone picker on every operator page | ✅ Canonical — has `pill` variant matching DT system |
| `DebounceSearchBar.js` | 500ms-debounced search with ⌘/Ctrl+K focus | ✅ Canonical |
| `LoaderWithImage.js` | Inline branded spinner for "loading more" rows | ✅ Canonical |
| `GlobalToast.js` | Global toast wrapper | ✅ Used at root |
| `SearchBar.js` | Non-debounced search input | Legacy — prefer `DebounceSearchBar` |
| `TableLoader.js` | Inline table loading state | Legacy — prefer `OrdersTableSkeleton` |
| `TitleCard.js` | Old page header | ⛔ **Legacy** — do not use on new pages. The replacement is the gradient `<Paper>` header pattern documented in root `CLAUDE.md` §6, implemented in `deliveries.js` (search for the comment `Header` near the first `<Paper>` in the JSX `return`). |
---
## 2. Design system token discipline
The `DT` design tokens (palette, alpha helpers, `pillFieldSx`, `SoftPaper`, `AccentAvatar`) are documented in the root `CLAUDE.md` §6 and the source-of-truth implementation is the token block near the top of `src/pages/nearle/deliveries/deliveries.js` (search for `const DT = {`).
**Hard rules when editing components here:**
- **Universal brand colour is `#662582`** (NearlExpress purple — from `themes/theme/default.js` `primary.main`). Every brand surface (page header, dialog header, KPI primary tile, search bars, edit-action buttons, scrollbars) uses this. Gradient pair: `#662582 → #9255AB`.
- **Semantic status palette is distinct** from the brand. Use these for lifecycle indicators only: sky `#0ea5e9`, emerald `#10b981`, amber `#f59e0b`, red `#ef4444`, status-purple `#8b5cf6` (lighter than brand), cyan `#06b6d4`, teal `#14b8a6`, orange `#f97316`, muted `#94a3b8`, indigo `#6366f1` (Accepted status). Don't replace these with brand purple — operators colour-code on them.
- Don't introduce a colour from `theme.palette` for new surfaces — those are Mantis defaults and don't match the DT system. Use the hex values above directly.
- Border radii: `12` (inner), `16` (card), `999` (pill). No other values.
- Shadows: `DT.shadowSoft` / `DT.shadowMd` / `DT.shadowPop`. No raw `box-shadow` strings.
> Some existing redesigned pages (`deliveries.js`, `Tenants.js`, `clientPricing.js`) still use `#6366f1` as the brand accent — this is a legacy from the first design pass. `customers.js` and the `createorder1` Saved-Address dialog are already on `#662582`. When you next edit one of the legacy pages, migrate it to brand purple in the same PR.
---
## 3. `LocationAutocomplete` — the `pill` prop is opt-in
The component supports two visual modes, controlled by the `pill` prop:
```jsx
// Default (legacy, "Select Zones" label, outlined TextField) — used by old pages
<LocationAutocomplete setAppId={...} setLocoName={...} />
// Pill variant — used by all redesigned pages (deliveries, tenants, pricing, customers)
<LocationAutocomplete
pill
accentColor="#6366f1"
icon={<MdMyLocation size={14} />}
placeholder="Select Zone"
paperComponent={SoftPaper}
setAppId={...}
setLocoName={...}
/>
```
- **For any new page:** use `pill`. Always.
- **For existing legacy pages (orders, invoice, riders, etc.):** keep the default until that page is redesigned. Don't change them piecemeal.
- The `accentColor` defaults to `#6366f1` — only override when the page's accent is different (rare).
---
## 4. `DebounceSearchBar` — debounce + ⌘/Ctrl+K shortcut
```jsx
<DebounceSearchBar
value={searchword} // controlled input value
onChange={setSearchword} // fires on every keystroke
onDebouncedChange={setDebouncedSearch} // fires 500ms after the last keystroke
placeholder="Search (ctrl+k)"
sx={{ ... }} // pill styling lives in the call site, not here
/>
```
- **Don't lower the debounce below 500ms.** TanStack Query's cache keys include `debouncedSearch`, so each keystroke would cause a new server hit if the debounce drops.
- ⌘/Ctrl+K to focus and `Esc` to blur are wired globally inside this component. Don't add competing keyboard shortcuts at the page level using the same keys.
- The pill aesthetic (rounded `999`, tinted bg, accent border) is applied via the `sx` prop at the call site — see the `<DebounceSearchBar` usage inside the "Status Tabs + Search" section of `deliveries.js` for the canonical incantation.
---
## 5. When to add a NEW shared component here
Only when:
1. Used by **two or more pages** in production code.
2. Has its own internal state or shortcuts (otherwise just use an `AccentAvatar` + `Box` inline).
3. The component encapsulates a non-trivial pattern that's been duplicated more than twice.
Don't add a wrapper that just renames an MUI primitive (e.g. `<NearleButton>`). Don't add a component that's a single instance of a styled `Paper`.
---
## 6. When to inline instead
The `DT` token block at the top of each page is repeated intentionally. Don't extract it into a shared module here. Reasons:
- It's a 30-line block of constants — repetition is fine.
- Pages occasionally tweak a token for their own use (e.g. picking different KPI colours). A shared module would lock everyone into the same defaults.
- Importing from a shared file creates a cross-page coupling that's painful when one page wants to evolve its visual language.
Same logic for `SoftPaper`, `AccentAvatar`, `pillFieldSx` — these are 520 line helpers that live inside each page file. Don't extract.
---
## 7. Backwards compatibility
If you change a component's public prop signature:
- Old call sites still using the old prop name will break silently (React doesn't warn on unknown props).
- Either: keep the old prop name working (alias both), OR grep `src/pages/` for all consumers and update them in the same PR.
The component's `forwardRef` shape is part of the contract — `Tenants.js` uses `tenantRef` / `locationRef` to imperatively focus inputs. Don't break ref-forwarding.

View File

@@ -1,51 +1,135 @@
import React, { forwardRef, useEffect, useState } from 'react';
import { Autocomplete, TextField } from '@mui/material';
import { Autocomplete, TextField, Avatar, Stack } from '@mui/material';
import axios from 'axios';
import { MdMyLocation } from 'react-icons/md';
const LocationAutocomplete = forwardRef(({ setAppId, setLocoName, setPage, sx, textfeildSx }, ref) => {
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem('applocations') || '[]'));
// Pill variant — opt-in via `pill` prop. Mirrors the design used across the
// deliveries / dispatch filter rows (rounded pill, soft tinted bg, accent
// border + focus ring, optional leading avatar icon). The default
// (`pill={false}` or omitted) renders the original outlined TextField with
// label "Select Zones" so existing callers are not affected.
const LocationAutocomplete = forwardRef(
(
{
setAppId,
setLocoName,
setPage,
sx,
textfeildSx,
textfieldSx,
pill = false,
accentColor = '#6366f1',
icon,
placeholder = 'Select Zone',
paperComponent
},
ref
) => {
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem('applocations') || '[]'));
useEffect(() => {
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 }];
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
setLocations(updatedLocations);
useEffect(() => {
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 }];
localStorage.setItem('applocations', JSON.stringify(updatedLocations));
setLocations(updatedLocations);
}
} catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err);
}
} catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err);
};
if (locations.length === 0) {
fetchLocations();
}
};
}, [locations.length]);
if (locations.length === 0) {
fetchLocations();
}
}, [locations.length]);
// Helpers (only used by pill variant) — match the deliveries page's
// token shorthand so the same opacity ramp is applied here.
const a = (suffix) => `${accentColor}${suffix}`;
const tint = a('08');
const ring = a('26');
const edge = a('55');
const soft = a('18');
return (
<Autocomplete
id="location-autocomplete"
options={locations || []}
getOptionLabel={(option) => option?.locationname ?? ''}
sx={{ ...sx }}
onChange={(event, value, reason) => {
if (reason === 'clear') {
setAppId?.(0);
setLocoName?.('');
setPage?.(0);
} else if (value) {
setAppId?.(value.applocationid);
setLocoName?.(value.locationname);
setPage?.(0);
const pillSx = pill
? {
cursor: 'pointer',
'& .MuiOutlinedInput-root': {
borderRadius: '999px',
bgcolor: tint,
fontWeight: 600,
color: '#0f172a',
paddingRight: '8px',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.2s',
'& fieldset': { borderColor: edge, borderWidth: 1.5 },
'&:hover fieldset': { borderColor: accentColor },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring}` },
'&.Mui-focused fieldset': { borderColor: accentColor, borderWidth: 2 }
},
'& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: accentColor }
}
}}
renderInput={(params) => <TextField {...params} inputRef={ref} label={'Select Zones'} sx={{ ...textfeildSx }} />}
/>
);
});
: {};
const Adornment = pill && (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5, mr: 0.25, flexShrink: 0 }}>
<Avatar
sx={{
width: 24,
height: 24,
bgcolor: soft,
color: accentColor,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{icon || <MdMyLocation size={14} />}
</Avatar>
</Stack>
);
return (
<Autocomplete
id="location-autocomplete"
options={locations || []}
getOptionLabel={(option) => option?.locationname ?? ''}
PaperComponent={paperComponent}
sx={{ ...sx }}
onChange={(event, value, reason) => {
if (reason === 'clear') {
setAppId?.(0);
setLocoName?.('');
setPage?.(0);
} else if (value) {
setAppId?.(value.applocationid);
setLocoName?.(value.locationname);
setPage?.(0);
}
}}
renderInput={(params) =>
pill ? (
<TextField
{...params}
inputRef={ref}
placeholder={placeholder}
InputProps={{
...params.InputProps,
startAdornment: Adornment
}}
sx={{ ...pillSx, ...(textfieldSx || textfeildSx || {}) }}
/>
) : (
<TextField {...params} inputRef={ref} label={'Select Zones'} sx={{ ...(textfieldSx || textfeildSx || {}) }} />
)
}
/>
);
}
);
export default LocationAutocomplete;

113
src/pages/api/CLAUDE.md Normal file
View File

@@ -0,0 +1,113 @@
# 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.
---
## 1. 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;
};
// 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);
return {
rows: response.data.details,
nextPage: response.data.details.length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};
```
**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.
---
## 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)`.
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 |
|---|---|
| `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 |
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.
---
## 4. Error handling pattern
```js
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/...`);
return response.data.details; // or .summary, .data, etc — depends on backend shape
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return null; // or return [] / {} — 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.
---
## 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.
---
## 6. Response shape quirks
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.
---
## 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.

View File

@@ -406,6 +406,20 @@ export const getpricinglist = async ({ queryKey }) => {
return null; // return null for failure
}
};
// ==============================|| getallpricing (clientPricing) ||============================== //
export const getallpricing = async ({ queryKey }) => {
const [, appId] = queryKey;
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/utils/getallpricing/?applocationid=${appId}`);
return response.data.details || [];
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return [];
}
};
// ==============================|| getcustomersummary (customers) ||============================== //
export const getcustomersummary = async ({ queryKey }) => {

View File

@@ -1,130 +1,603 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { Stack, TableContainer, Table, TableHead, TableRow, TableCell, TableBody, Chip } from '@mui/material';
import MainCard from 'components/MainCard';
import React, { useMemo, useRef, useState } from 'react';
import {
Avatar,
Box,
Grid,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import {
MdLocalOffer,
MdMyLocation,
MdAttachMoney,
MdGroups,
MdPlace,
MdSpeed,
MdPriceCheck,
MdStraighten,
MdReceiptLong
} from 'react-icons/md';
import { useQuery } from '@tanstack/react-query';
import Loader from 'components/Loader';
import TitleCard from 'components/nearle_components/TitleCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import { Empty } from 'antd';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getallpricing } from 'pages/api/api';
// ============================================================================
// Design tokens — shared with the deliveries / tenants / customers pages so every
// surface (header, KPI tiles, table, badges) speaks the same visual language.
// Keep this block in sync with customers.js / deliveries.js.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const a = (c, suffix) => `${c}${suffix}`;
const tint = (c) => a(c, '08');
const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const BRAND = '#662582';
const BRAND_LIGHT = '#9255AB';
const SoftPaper = (props) => (
<Paper
{...props}
sx={{
mt: 0.75,
borderRadius: 2,
boxShadow: DT.shadowPop,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden'
}}
/>
);
const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar
sx={{
width: size,
height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{children}
</Avatar>
);
const formatRupees = (value) =>
new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 2
}).format(Number(value) || 0);
const formatDecimal = (value) =>
new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2 }).format(Number(value) || 0);
// Soft pill for metric cells in the table.
const MetricPill = ({ color, icon, label, width }) => (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint(color),
border: `1px solid ${edge(color)}`,
color,
fontSize: 11,
fontWeight: 800,
minWidth: width,
justifyContent: 'center'
}}
>
{icon}
{label}
</Box>
);
// ==============================|| Pricing page ||============================== //
// ==============================|| Starts here ||============================== //
const ClientsPricing = () => {
const containerRef = useRef();
const [appId, setAppId] = useState(0);
const [locaName, setLocoName] = useState('');
const [pricing, setpricing] = useState([]);
const [isLoader, setIsloader] = useState([]);
const [locaName, setLocoName] = useState('All');
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// ==============================|| formatNumberToRupees ||============================== //
function formatNumberToRupees(value) {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 2
}).format(value);
}
function dotzerozero(value) {
return new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2
}).format(value);
}
const {
data: pricing = [],
isLoading
} = useQuery({
queryKey: ['getallpricing', appId],
queryFn: getallpricing,
keepPreviousData: true
});
// ==============================|| getAllPricing ||============================== //
const getAllPricing = async () => {
setIsloader(true);
try {
const pricingres = await axios.get(`${process.env.REACT_APP_URL}/utils/getallpricing/?applocationid=${appId}`);
console.log('pricingres', pricingres.data.details);
setpricing(pricingres.data.details);
setIsloader(false);
} catch (err) {
console.log('pricingres', err);
}
};
useEffect(() => {
getAllPricing();
}, [appId]);
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)]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(q))
);
}, [pricing, debouncedSearch]);
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 };
}, [pricing]);
const KPI_META = [
{ key: 'total', label: 'Total Pricing Slabs', color: BRAND, icon: MdLocalOffer, value: stats.total },
{ key: 'tenants', label: 'Tenants Priced', color: '#0ea5e9', icon: MdGroups, value: stats.tenants },
{ key: 'avg', label: 'Avg Base Price', color: '#f59e0b', icon: MdAttachMoney, value: formatRupees(stats.avgBase) },
{ key: 'zone', label: 'Active Zone', color: '#10b981', icon: MdPlace, value: locaName || 'All Zones' }
];
return (
<>
{isLoader && <Loader />}
{/* ============================================= || Title Card || ============================================= */}
<Stack direction={'row'} justifyContent="space-between" alignItems="center" sx={{ my: 1 }}>
<TitleCard title="Pricing" />
<Stack sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
{isLoading && <Loader />}
{/* ============================================= || Header || ============================================= */}
<Paper
elevation={0}
sx={{
mb: { xs: 1.5, md: 2 },
p: { xs: 1.5, sm: 2, md: 2.5 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
boxShadow: DT.shadowMd
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1.5, sm: 2 }}
>
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
<Avatar
sx={{
width: { xs: 40, sm: 48 },
height: { xs: 40, sm: 48 },
bgcolor: BRAND,
color: '#fff',
boxShadow: `0 6px 18px ${ring(BRAND)}`
}}
>
<MdLocalOffer size={22} />
</Avatar>
<Stack>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' }
}}
>
Pricing
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ mt: 0.5 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: '#10b981',
boxShadow: '0 0 0 4px rgba(16,185,129,0.18)'
}}
/>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Live · {locaName || 'All Zones'}
</Typography>
</Stack>
</Stack>
</Stack>
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
sx={{ width: { xs: '100%', custom450: 300 }, zIndex: '100' }}
pill
accentColor={BRAND}
icon={<MdMyLocation size={14} />}
placeholder="Select Zone"
paperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
/>
</Stack>
</Stack>
{/* ============================================= || table || ============================================= */}
<MainCard content={false} sx={{ mt: 2 }}>
<TableContainer>
<Table>
<TableHead sx={{ bgcolor: '#e1bee7' }}>
<TableRow>
<TableCell>S.No</TableCell>
<TableCell>Location</TableCell>
<TableCell align="center">Pricing Id</TableCell>
<TableCell>Name</TableCell>
</Paper>
{/* ============================================= || KPI Cards || ============================================= */}
<Grid container spacing={{ xs: 1.25, sm: 1.5, md: 2 }} sx={{ mt: '1px' }}>
{KPI_META.map((item) => {
const Icon = item.icon;
return (
<Grid item key={item.key} xs={12} sm={6} md={3}>
<Paper
elevation={0}
sx={{
position: 'relative',
overflow: 'hidden',
p: { xs: 1.25, sm: 1.75, md: 2.25 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: '#fff',
transition: 'transform 0.2s, box-shadow 0.2s, border-color 0.2s',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: DT.shadowMd,
borderColor: edge(item.color)
}
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 3,
background: `linear-gradient(90deg, ${item.color} 0%, ${soft(item.color)} 100%)`
}}
/>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="caption"
sx={{
color: DT.textSecondary,
fontWeight: 700,
letterSpacing: 0.4,
textTransform: 'uppercase',
fontSize: { xs: 10, sm: 11 },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.label}
</Typography>
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.value}
</Typography>
</Stack>
<Avatar
sx={{
width: { xs: 36, sm: 42, md: 48 },
height: { xs: 36, sm: 42, md: 48 },
bgcolor: soft(item.color),
color: item.color,
boxShadow: `inset 0 0 0 1px ${edge(item.color)}`,
flexShrink: 0
}}
>
<Icon size={20} />
</Avatar>
</Stack>
</Paper>
</Grid>
);
})}
</Grid>
{/* ============================================= || Search Header || ============================================= */}
<Paper
elevation={0}
sx={{
mt: { xs: 1.5, md: 2 },
p: { xs: 1, md: 1.5 },
borderTopLeftRadius: DT.radiusCard / 8,
borderTopRightRadius: DT.radiusCard / 8,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
border: '1px solid',
borderColor: DT.borderSubtle,
borderBottom: 0,
background: '#fff'
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="space-between"
spacing={1.25}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<AccentAvatar color={BRAND} size={32}>
<MdLocalOffer size={18} />
</AccentAvatar>
<Stack>
<Typography
variant="caption"
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
>
Pricing Catalog
</Typography>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
{pricing.length} total · {rows.length} shown
</Typography>
</Stack>
</Stack>
<Box sx={{ width: { xs: '100%', sm: 280, lg: 340 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder={`Search pricing (ctrl+k)`}
sx={{
m: 0,
width: '100%',
borderRadius: 999,
bgcolor: tint(BRAND),
'& fieldset': { borderColor: edge(BRAND), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: BRAND },
'&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 2 },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
/>
</Box>
</Stack>
</Paper>
{/* ============================================= || Table || ============================================= */}
<Paper
elevation={0}
sx={{
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomLeftRadius: DT.radiusCard / 8,
borderBottomRightRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
<TableContainer
ref={containerRef}
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-track': { backgroundColor: DT.surfaceAlt }
}}
>
<Table stickyHeader sx={{ minWidth: { xs: 860, md: 1080 } }}>
<TableHead>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: { xs: 10, md: 11 },
fontWeight: 800,
letterSpacing: 0.6,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`,
py: { xs: 1, md: 1.25 },
px: { xs: 1, md: 2 }
}
}}
>
<TableCell>#</TableCell>
<TableCell>Tenant</TableCell>
<TableCell>Zone</TableCell>
<TableCell>Slab</TableCell>
<TableCell align="center">Base Price</TableCell>
<TableCell align="center">MinKm</TableCell>
<TableCell align="center">Price/Km</TableCell>
<TableCell align="center">MaxKm</TableCell>
<TableCell>Min Orders</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>
</TableRow>
</TableHead>
<TableBody>
{isLoader && <OrdersTableSkeleton col={5} />}
{pricing?.length === 0 && !isLoader ? (
{isLoading && <OrdersTableSkeleton col={5} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center">
<Empty description={'No Pricing List'} />
<TableCell colSpan={9} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdLocalOffer size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
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.'}
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
pricing?.map((data, index) => (
<TableRow key={data.pricingid || index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{data.applocation}</TableCell>
<TableCell align="center">
<Chip size="small" color="info" label={data.pricingid} sx={{ width: 50 }} />
rows.map((row, index) => (
<TableRow
key={row.pricingid || `${row.appname}-${index}`}
sx={{
transition: 'background-color 0.15s',
'& td': {
borderBottom: `1px solid ${DT.divider}`,
py: { xs: 1, md: 1.5 },
px: { xs: 1, md: 2 }
},
'&:hover': { backgroundColor: DT.surfaceAlt }
}}
>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
{String(index + 1).padStart(2, '0')}
</Typography>
</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{data.appname}</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color={BRAND} size={36}>
<MdGroups size={18} />
</AccentAvatar>
<Stack>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
{row.appname || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{row.pricingid}
</Typography>
</Stack>
</Stack>
</TableCell>
<TableCell>{data.slab}</TableCell>
<TableCell>
{row.applocation ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#10b981'),
border: `1px solid ${edge('#10b981')}`,
color: '#10b981',
fontSize: 11,
fontWeight: 800
}}
>
<MdPlace size={12} /> {row.applocation}
</Box>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
)}
</TableCell>
<TableCell align="center">
<Chip size="small" color="primary" label={formatNumberToRupees(data.baseprice)} sx={{ width: 100 }} />
<TableCell>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#0ea5e9'),
border: `1px solid ${edge('#0ea5e9')}`,
color: '#0ea5e9',
fontSize: 11,
fontWeight: 800
}}
>
<MdSpeed size={12} /> {row.slab || '—'}
</Box>
</TableCell>
<TableCell align="center">
<Chip size="small" color="warning" label={dotzerozero(data.minkm)} sx={{ width: 100 }} />
<MetricPill
color={BRAND}
icon={<MdPriceCheck size={12} />}
label={formatRupees(row.baseprice)}
width={110}
/>
</TableCell>
<TableCell align="center">
<Chip size="small" color="success" label={formatNumberToRupees(data.priceperkm)} sx={{ width: 100 }} />
<MetricPill
color="#f59e0b"
icon={<MdStraighten size={12} />}
label={`${formatDecimal(row.minkm)} km`}
width={90}
/>
</TableCell>
<TableCell align="center">
<Chip size="small" color="error" label={dotzerozero(data.maxkm)} sx={{ width: 100 }} />
<MetricPill
color="#10b981"
icon={<MdAttachMoney size={12} />}
label={formatRupees(row.priceperkm)}
width={110}
/>
</TableCell>
<TableCell>{data.minorder}</TableCell>
<TableCell align="center">
<MetricPill
color="#ef4444"
icon={<MdStraighten size={12} />}
label={`${formatDecimal(row.maxkm)} km`}
width={90}
/>
</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>
</Stack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</MainCard>
</Paper>
</>
);
};

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState, useRef, useMemo } from 'react';
import TitleCard from 'components/nearle_components/TitleCard';
import React, { useEffect, useState, useRef, useMemo, Fragment } from 'react';
import MainCard from 'components/MainCard';
import axios from 'axios';
import { useTheme } from '@mui/material/styles';
@@ -40,26 +39,32 @@ import {
DialogTitle,
FormLabel,
TablePagination,
Chip,
Skeleton
Skeleton,
Avatar,
Paper
} from '@mui/material';
import {
EyeOutlined,
EyeInvisibleOutlined,
EditOutlined,
IssuesCloseOutlined,
StopOutlined
StopOutlined,
CloseOutlined
} from '@ant-design/icons';
import { PopupTransition } from 'components/@extended/Transitions';
import { FaRegCheckCircle } from 'react-icons/fa';
import { TbBrandDatabricks } from 'react-icons/tb';
import { GiMoneyStack } from 'react-icons/gi';
import { FiUser } from 'react-icons/fi';
import { FaRegAddressCard } from 'react-icons/fa';
import { GiModernCity } from 'react-icons/gi';
import { MdNumbers } from 'react-icons/md';
import { TbWorldLongitude } from 'react-icons/tb';
import { FiPhoneCall } from 'react-icons/fi';
import { FaRegCheckCircle, FaRegAddressCard } from 'react-icons/fa';
import { TbBrandDatabricks, TbWorldLongitude, TbWorldLatitude } from 'react-icons/tb';
import { GiMoneyStack, GiModernCity } from 'react-icons/gi';
import { FiUser, FiPhoneCall } from 'react-icons/fi';
import {
MdNumbers,
MdGroups,
MdCheckCircle,
MdHourglassEmpty,
MdCancel,
MdMyLocation,
MdPersonPin
} from 'react-icons/md';
import { LuMail } from 'react-icons/lu';
import { BiUser } from 'react-icons/bi';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
@@ -68,6 +73,97 @@ import { useQuery } from '@tanstack/react-query';
import { getalltenants, gettenantsummary } from 'pages/api/api';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
// ============================================================================
// Design tokens — shared with the deliveries page so every surface (header,
// filters, KPI tiles, tabs, badges, dialogs) speaks the same visual language.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
radiusInner: 12,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const a = (c, suffix) => `${c}${suffix}`;
const tint = (c) => a(c, '08');
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: DT.radiusPill + 'px',
bgcolor: tint(color),
fontWeight: 600,
color: DT.textPrimary,
paddingRight: '8px',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.2s',
'& fieldset': { borderColor: edge(color), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: color },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(color)}` },
'&.Mui-focused fieldset': { borderColor: color, borderWidth: 2 }
},
'& .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' },
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty, statusKey: 'pending' },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, statusKey: 'inactive' }
};
const STATUS_TABS = [
{ status: 'active', countKey: 'active', tabStatus: 'Active' },
{ status: 'pending', countKey: 'pending', tabStatus: 'Pending' },
{ status: 'inactive', countKey: 'inactive', tabStatus: 'InActive' }
];
const KPI_META = [
{ key: 'active', label: 'Active Tenants', color: '#10b981', icon: MdCheckCircle, countKey: 'active' },
{ key: 'pending', label: 'Pending Approval', color: '#f59e0b', icon: MdHourglassEmpty, countKey: 'pending' },
{ key: 'inactive', label: 'Inactive Tenants', color: '#ef4444', icon: MdCancel, countKey: 'inactive' }
];
const SoftPaper = (props) => (
<Paper
{...props}
sx={{
mt: 0.75,
borderRadius: 2,
boxShadow: DT.shadowPop,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden'
}}
/>
);
const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar
sx={{
width: size,
height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{children}
</Avatar>
);
// ==============================|| Starts here||============================== //
const Clients1 = () => {
const textFieldRef = useRef(null);
@@ -474,114 +570,356 @@ const Clients1 = () => {
}
};
const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active;
return (
<div>
<>
{(getalltenantsIsLoading || summaryDataIsLoading || isloader) && <Loader />}
{/* ========================================================== || Titlecard || ========================================================== */}
<TitleCard title="Tenants ">
<LocationAutocomplete
locations={locations}
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
setPage={setPage}
sx={{ width: { xs: '100%', custom450: 300 }, zIndex: '100' }}
/>
</TitleCard>
{/* ========================================================== || MainCard (searchword) || ========================================================== */}
<MainCard
content={false}
sx={{ mt: 2 }}
title={
{/* ============================================= || Header | ============================================= */}
<Paper
elevation={0}
sx={{
mb: { xs: 1.5, md: 2 },
p: { xs: 1.5, sm: 2, md: 2.5 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: `linear-gradient(135deg, ${tint('#6366f1')} 0%, ${tint('#0ea5e9')} 100%)`,
boxShadow: DT.shadowMd
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1.5, sm: 2 }}
>
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
<Avatar
sx={{
width: { xs: 40, sm: 48 },
height: { xs: 40, sm: 48 },
bgcolor: '#6366f1',
color: '#fff',
boxShadow: '0 6px 18px rgba(99,102,241,0.32)'
}}
>
<MdGroups size={22} />
</Avatar>
<Stack>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' }
}}
>
Tenants
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ mt: 0.5 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: '#10b981',
boxShadow: '0 0 0 4px rgba(16,185,129,0.18)'
}}
/>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Live · {locaName || 'All Zones'}
</Typography>
</Stack>
</Stack>
</Stack>
<LocationAutocomplete
locations={locations}
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
setPage={setPage}
pill
accentColor="#6366f1"
icon={<MdMyLocation size={14} />}
placeholder="Select Zone"
paperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
/>
</Stack>
</Paper>
{/* ============================================= || KPI Cards | ============================================= */}
<Grid container spacing={{ xs: 1.25, sm: 1.5, md: 2 }} sx={{ mt: '1px' }}>
{KPI_META.map((item) => {
const Icon = item.icon;
const value = summaryData?.[item.countKey] ?? 0;
return (
<Grid item key={item.key} xs={12} sm={4}>
<Paper
elevation={0}
sx={{
position: 'relative',
overflow: 'hidden',
p: { xs: 1.25, sm: 1.75, md: 2.25 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: '#fff',
transition: 'transform 0.2s, box-shadow 0.2s, border-color 0.2s',
cursor: 'pointer',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: DT.shadowMd,
borderColor: edge(item.color)
}
}}
onClick={() => {
const idx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey);
if (idx >= 0) handleChange(null, idx);
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 3,
background: `linear-gradient(90deg, ${item.color} 0%, ${soft(item.color)} 100%)`
}}
/>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="caption"
sx={{
color: DT.textSecondary,
fontWeight: 700,
letterSpacing: 0.4,
textTransform: 'uppercase',
fontSize: { xs: 10, sm: 11 },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.label}
</Typography>
{summaryDataIsLoading ? (
<Skeleton sx={{ width: 70, height: { xs: 28, md: 36 } }} animation="wave" />
) : (
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.5rem', sm: '1.75rem', md: '2rem' }
}}
>
{value}
</Typography>
)}
</Stack>
<Avatar
sx={{
width: { xs: 36, sm: 42, md: 48 },
height: { xs: 36, sm: 42, md: 48 },
bgcolor: soft(item.color),
color: item.color,
boxShadow: `inset 0 0 0 1px ${edge(item.color)}`,
flexShrink: 0
}}
>
<Icon size={20} />
</Avatar>
</Stack>
</Paper>
</Grid>
);
})}
</Grid>
{/* ============================================= || Status Tabs + Search || ============================================= */}
<Paper
elevation={0}
sx={{
mt: { xs: 1.5, md: 2 },
p: { xs: 1, md: 1.5 },
borderTopLeftRadius: DT.radiusCard / 8,
borderTopRightRadius: DT.radiusCard / 8,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
border: '1px solid',
borderColor: DT.borderSubtle,
borderBottom: 0,
background: '#fff'
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={1.5}
sx={{ flexWrap: 'wrap-reverse' }}
>
<Stack
minWidth={'100%'}
flexDirection={{ xs: 'row', sm: 'row' }}
direction="row"
spacing={0.75}
sx={{
p: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap-reverse',
gap: 1
flex: 1,
overflowX: 'auto',
py: 0.5,
px: 0.25,
'&::-webkit-scrollbar': { height: 6 },
'&::-webkit-scrollbar-thumb': { backgroundColor: DT.borderSubtle, borderRadius: 4 }
}}
>
{/* ============================================= || Tabs || ================================================= */}
<Tabs value={value0} onChange={handleChange} aria-label="basic tabs example">
<Tab
label="Active"
sx={{ ml: -1 }}
onClick={() => {
setstatus('active');
}}
icon={
<Chip
label={summaryDataIsLoading ? <Skeleton variant="text" width={15} height={10} /> : summaryData?.active || 0}
color="primary"
variant="light"
sx={{ minWidth: 32, justifyContent: 'center' }}
size="small"
/>
}
iconPosition="end"
/>
<Tab
label="Pending"
onClick={() => {
setstatus('pending');
}}
icon={
<Chip
label={summaryDataIsLoading ? <Skeleton variant="text" width={15} height={10} /> : summaryData?.pending || 0}
color="primary"
variant="light"
sx={{ minWidth: 32, justifyContent: 'center' }}
size="small"
/>
}
iconPosition="end"
/>
<Tab
label="Inactive"
onClick={() => {
setstatus('inactive');
}}
icon={
<Chip
label={summaryDataIsLoading ? <Skeleton variant="text" width={15} height={10} /> : summaryData?.inactive || 0}
color="primary"
variant="light"
sx={{ minWidth: 32, justifyContent: 'center' }}
size="small"
/>
}
iconPosition="end"
/>
</Tabs>
{/* ============================================= || SearchOutlined || ================================================= */}
{STATUS_TABS.map((t, idx) => {
const meta = STATUS_META[t.status];
const Icon = meta.icon;
const active = value0 === idx;
const count = summaryData?.[t.countKey] ?? 0;
return (
<Box
key={t.status}
onClick={() => {
setstatus(t.status);
handleChange(null, idx);
}}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: { xs: 0.625, md: 0.875 },
pl: 0.5,
pr: { xs: 1, md: 1.25 },
py: 0.5,
flexShrink: 0,
cursor: 'pointer',
borderRadius: 999,
border: `1.5px solid ${active ? meta.color : edge(meta.color)}`,
bgcolor: active ? meta.color : tint(meta.color),
color: active ? '#fff' : meta.color,
fontWeight: 700,
boxShadow: active ? `0 6px 18px ${ring(meta.color)}` : 'none',
transition: 'all 0.18s',
'&:hover': {
borderColor: meta.color,
boxShadow: active ? `0 6px 18px ${ring(meta.color)}` : `0 0 0 3px ${ring(meta.color)}`
}
}}
>
<Avatar
sx={{
width: { xs: 22, md: 26 },
height: { xs: 22, md: 26 },
bgcolor: active ? 'rgba(255,255,255,0.22)' : soft(meta.color),
color: active ? '#fff' : meta.color
}}
>
<Icon size={13} />
</Avatar>
<Typography
variant="caption"
sx={{ fontWeight: 800, fontSize: { xs: 11.5, md: 13 }, lineHeight: 1 }}
>
{meta.label}
</Typography>
<Box
sx={{
minWidth: { xs: 22, md: 26 },
height: { xs: 18, md: 22 },
px: 0.625,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 999,
fontSize: { xs: 10, md: 11 },
fontWeight: 800,
bgcolor: active ? 'rgba(255,255,255,0.22)' : '#fff',
color: active ? '#fff' : meta.color,
border: active ? 'none' : `1px solid ${edge(meta.color)}`
}}
>
{summaryDataIsLoading ? <Skeleton variant="text" width={14} height={10} /> : count}
</Box>
</Box>
);
})}
</Stack>
{/* Search */}
<Box sx={{ width: { xs: '100%', sm: 240, lg: 280 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
sx={{ width: { xs: '100%', custom600: 275 }, m: 0 }}
sx={{
m: 0,
width: '100%',
borderRadius: 999,
bgcolor: tint('#6366f1'),
'& fieldset': { borderColor: edge('#6366f1'), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: '#6366f1' },
'&.Mui-focused fieldset': { borderColor: '#6366f1', borderWidth: 2 },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#6366f1')}` }
}}
/>
</Stack>
}
</Box>
</Stack>
</Paper>
{/* ============================================= || Table || ============================================= */}
<Paper
elevation={0}
sx={{
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomLeftRadius: DT.radiusCard / 8,
borderBottomRightRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
{/* ============================================= || TableContainer| ============================================= */}
<TableContainer sx={{ maxHeight: 'calc(100vh - 300px)' }}>
<Table stickyHeader>
<TableContainer
sx={{
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
'&::-webkit-scrollbar': { width: '10px', height: '10px' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge('#6366f1'),
borderRadius: '8px',
'&:hover': { backgroundColor: '#6366f1' }
},
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
}}
>
<Table stickyHeader sx={{ minWidth: { xs: 720, md: 960 } }}>
<TableHead>
<TableRow>
<TableCell sx={{ position: 'sticky !important', bgcolor: '#e1bee7!important' }}>S.No</TableCell>
<TableCell sx={{ position: 'sticky !important', bgcolor: '#e1bee7!important' }}>Client</TableCell>
<TableCell sx={{ position: 'sticky !important', bgcolor: '#e1bee7!important' }}>Contact</TableCell>
<TableCell sx={{ position: 'sticky !important', bgcolor: '#e1bee7!important' }}>Address</TableCell>
<TableCell sx={{ position: 'sticky !important', bgcolor: '#e1bee7!important' }} align="center">
Actions
</TableCell>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: { xs: 10, md: 11 },
fontWeight: 800,
letterSpacing: 0.6,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`,
py: { xs: 1, md: 1.25 },
px: { xs: 1, md: 2 }
}
}}
>
<TableCell>#</TableCell>
<TableCell>Status</TableCell>
<TableCell>Client</TableCell>
<TableCell>Contact</TableCell>
<TableCell>Address</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -589,111 +927,220 @@ const Clients1 = () => {
{tenantList?.length == 0 && !isloader ? (
<TableRow>
<TableCell colSpan={6}>
<Stack width={'100%'} direction={'row'} justifyContent={'center'}>
<Empty description={`No ${tabStatus} Tenants`} styles={{ description: { color: theme.palette.error.main } }} />
<TableCell colSpan={6} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdGroups size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No tenants to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
tenantList?.map((row, index) => (
<>
tenantList?.map((row, index) => {
const rowStatusKey = (value0 === 0 ? 'active' : value0 === 1 ? 'pending' : 'inactive');
const rowStatusMeta = STATUS_META[rowStatusKey];
const RowStatusIcon = rowStatusMeta.icon;
return (
<Fragment key={row.tenantid ?? index}>
{/* ============================================ || tablerow 1 || ============================================ */}
<TableRow key={index}>
<TableCell>{index + 1 + page * rowsPerPage}</TableCell>
<TableRow
sx={{
cursor: 'pointer',
transition: 'background-color 0.15s',
'& td': {
borderBottom: `1px solid ${DT.divider}`,
py: { xs: 1, md: 1.5 },
px: { xs: 1, md: 2 }
},
'&:hover': { backgroundColor: DT.surfaceAlt }
}}
>
<TableCell>
<Stack>
<Typography variant="subtitle1" sx={{ whiteSpace: 'nowrap' }}>
{row.tenantname}
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
{String(index + 1 + page * rowsPerPage).padStart(2, '0')}
</Typography>
</TableCell>
<TableCell>
<Stack
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
}}
>
<AccentAvatar color={rowStatusMeta.color} size={20}>
<RowStatusIcon size={12} />
</AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: 11, lineHeight: 1 }}>
{rowStatusMeta.label}
</Typography>
<Typography variant="body2">Id : {row.tenantid}</Typography>
</Stack>
</TableCell>
<TableCell>
<Typography>{row.primarycontact}</Typography>
<Typography color="secondary">{row.primaryemail}</Typography>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color="#6366f1" size={32}>
<MdPersonPin size={16} />
</AccentAvatar>
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.tenantname}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{row.tenantid}
</Typography>
</Stack>
</Stack>
</TableCell>
<TableCell sx={{ whiteSpace: 'wrap' }}>{row.address}</TableCell>
<TableCell align="center">
<Stack direction={'row'}>
<IconButton
<TableCell>
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.primarycontact || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.primaryemail || '—'}
</Typography>
</Stack>
</TableCell>
<TableCell sx={{ maxWidth: 280 }}>
<Tooltip title={row.address || ''} placement="top">
<Typography
variant="caption"
sx={{
color: value0 == 1 ? theme.palette.primary.main : theme.palette.success.main
color: DT.textSecondary,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}
>
<Tooltip title={value0 == 0 ? 'Inactive' : value == 1 ? 'Approve' : 'Active'} placement="top">
{value0 == 0 ? (
<StopOutlined
style={{ color: 'red' }}
onClick={() => {
setSelectedCustomer(row);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
setClientstatus(row.approved);
setTimeout(() => {
tenantupdate(row.tenantid);
}, 100);
}}
/>
) : value0 == 1 ? (
<IssuesCloseOutlined
onClick={() => {
setSelectedCustomer(row);
setDialogopen(true);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
getAppPricing(row.applolcationid);
}}
/>
) : (
<FaRegCheckCircle
onClick={() => {
setSelectedCustomer(row);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
setTimeout(() => {
tenantupdate(row.tenantid);
}, 100);
}}
/>
)}
{row.address || '—'}
</Typography>
</Tooltip>
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.75} justifyContent="flex-end">
{value0 == 0 && (
<Tooltip title="Inactivate" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#ef4444'),
color: '#ef4444',
border: `1px solid ${edge('#ef4444')}`,
'&:hover': { bgcolor: '#ef4444', color: '#fff' }
}}
onClick={() => {
setSelectedCustomer(row);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
setTimeout(() => {
tenantupdate(row.tenantid);
}, 100);
}}
>
<StopOutlined style={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</IconButton>
<IconButton
aria-label="expand row"
size="medium"
onClick={() => {
setSelectedCustomer(row);
handleCollapseToggle1(index); // makes 1st collpase open
setOpenRowIndex2(-1); //makes 2nd collapse close , if open
setSelectedtenid(row.tenantid);
// setAppId(row.applocationid);
}}
sx={{
color: openRowIndex1 === index ? theme.palette.error.main : theme.palette.primary.main
}}
>
{openRowIndex1 === index ? <EyeInvisibleOutlined /> : <EyeOutlined />}
</IconButton>
{value0 !== 1 && (
)}
{value0 == 1 && (
<Tooltip title="Approve" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#f59e0b'),
color: '#f59e0b',
border: `1px solid ${edge('#f59e0b')}`,
'&:hover': { bgcolor: '#f59e0b', color: '#fff' }
}}
onClick={() => {
setSelectedCustomer(row);
setDialogopen(true);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
getAppPricing(row.applolcationid);
}}
>
<IssuesCloseOutlined style={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
)}
{value0 == 2 && (
<Tooltip title="Activate" placement="top">
<IconButton
size="small"
sx={{
bgcolor: soft('#10b981'),
color: '#10b981',
border: `1px solid ${edge('#10b981')}`,
'&:hover': { bgcolor: '#10b981', color: '#fff' }
}}
onClick={() => {
setSelectedCustomer(row);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
setTimeout(() => {
tenantupdate(row.tenantid);
}, 100);
}}
>
<FaRegCheckCircle style={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title={openRowIndex1 === index ? 'Hide details' : 'View details'} placement="top">
<IconButton
aria-label="expand row"
size="medium"
size="small"
sx={{
bgcolor: openRowIndex1 === index ? '#6366f1' : soft('#6366f1'),
color: openRowIndex1 === index ? '#fff' : '#6366f1',
border: `1px solid ${edge('#6366f1')}`,
'&:hover': { bgcolor: '#6366f1', color: '#fff' }
}}
onClick={() => {
setSelectedCustomer(row);
handleCollapseToggle2(index); // makse 2nd collpase open
setOpenRowIndex1(-1); // makse 1st collpse close if open
handleCollapseToggle1(index);
setOpenRowIndex2(-1);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
}}
sx={{
color: openRowIndex2 === index ? theme.palette.error.main : theme.palette.primary.main
}}
>
<Tooltip title={openRowIndex2 === index ? 'Close' : 'Edit'} placement="top">
{openRowIndex2 === index ? <CloseOutlined /> : <EditOutlined />}
</Tooltip>
{openRowIndex1 === index ? <EyeInvisibleOutlined style={{ fontSize: 14 }} /> : <EyeOutlined style={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
{value0 !== 1 && (
<Tooltip title={openRowIndex2 === index ? 'Close' : 'Edit'} placement="top">
<IconButton
size="small"
sx={{
bgcolor: openRowIndex2 === index ? '#8b5cf6' : soft('#8b5cf6'),
color: openRowIndex2 === index ? '#fff' : '#8b5cf6',
border: `1px solid ${edge('#8b5cf6')}`,
'&:hover': { bgcolor: '#8b5cf6', color: '#fff' }
}}
onClick={() => {
setSelectedCustomer(row);
handleCollapseToggle2(index);
setOpenRowIndex1(-1);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
}}
>
{openRowIndex2 === index ? <CloseOutlined style={{ fontSize: 14 }} /> : <EditOutlined style={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
@@ -1532,21 +1979,20 @@ const Clients1 = () => {
</TableCell>
</TableRow>
)}
</>
))
</Fragment>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<Divider />
{/* ============================================= || Pagination| ============================================= */}
{!searchword && tenantList?.length > 0 && (
<>
<Divider />
<Divider sx={{ borderColor: DT.borderSubtle }} />
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50, 100]}
component="div"
// count={pageno}
count={value0 == 0 ? summaryData?.active : value0 == 1 ? summaryData?.pending : value0 == 2 ? summaryData?.inactive : 0}
rowsPerPage={rowsPerPage}
page={page}
@@ -1555,7 +2001,7 @@ const Clients1 = () => {
/>
</>
)}
</MainCard>
</Paper>
{/* // ==============================||( Client Pricing ) dialog (dialogopen) ||============================== // */}
<Dialog fullWidth={true} open={dialogopen} onClose={dialogclose} scroll={'paper'} maxWidth="sm" TransitionComponent={PopupTransition}>
<DialogTitle
@@ -1710,7 +2156,7 @@ const Clients1 = () => {
</Grid>
</DialogActions>
</Dialog>
</div>
</>
);
};

View File

@@ -1,15 +1,11 @@
import { React, useState, useEffect, useRef, useMemo } from 'react';
import { Empty } from 'antd';
import axios from 'axios';
import { FaRegEdit } from 'react-icons/fa';
import { RiEdit2Fill } from 'react-icons/ri';
import { useTheme } from '@mui/material/styles';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
// material-ui
import {
Box,
Divider,
Table,
TableBody,
TableCell,
@@ -27,14 +23,26 @@ import {
DialogContent,
Button,
TextField,
Autocomplete
Autocomplete,
Avatar,
Paper
} from '@mui/material';
import {
MdPeopleAlt,
MdMyLocation,
MdPersonPin,
MdPhone,
MdLocationOn,
MdEdit,
MdGroups,
MdHowToReg,
MdPlace
} from 'react-icons/md';
import Geocode from 'react-geocode';
import LocationOnIcon from '@mui/icons-material/LocationOn';
import parse from 'autosuggest-highlight/parse';
import { debounce } from '@mui/material/utils';
// project imports
import MainCard from 'components/MainCard';
import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
@@ -43,6 +51,59 @@ import { getallcustomers, getcustomersummary } from 'pages/api/api';
import { OpenToast } from 'components/third-party/OpenToast';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
// ============================================================================
// Design tokens — shared with the deliveries / tenants / pricing pages so every
// surface (header, KPI tiles, table, badges, dialog) speaks the same visual
// language. Keep this block in sync with deliveries.js:109162.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const a = (c, suffix) => `${c}${suffix}`;
const tint = (c) => a(c, '08');
const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const SoftPaper = (props) => (
<Paper
{...props}
sx={{
mt: 0.75,
borderRadius: 2,
boxShadow: DT.shadowPop,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden'
}}
/>
);
const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar
sx={{
width: size,
height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{children}
</Avatar>
);
// ==============================|| google address ||============================== //
const GOOGLE_MAPS_API_KEY = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
@@ -67,7 +128,6 @@ export default function Customers() {
const loadMoreRef = useRef();
const [rowsPerPage] = useState(50);
const [page] = useState(0);
const theme = useTheme();
const [appId, setAppId] = useState(0);
const [locaName, setLocoName] = useState('All');
const [selectedCustomer, setSelectedCustomer] = useState({}); // to edit
@@ -326,131 +386,433 @@ export default function Customers() {
}
}
};
const KPI_META = [
{ key: 'total', label: 'Total Customers', color: '#662582', icon: MdGroups, value: pageCount?.Total ?? 0 },
{ key: 'loaded', label: 'Loaded in View', color: '#0ea5e9', icon: MdHowToReg, value: rows.length },
{ key: 'zone', label: 'Active Zone', color: '#10b981', icon: MdPlace, value: locaName || 'All Zones' }
];
return (
<>
{(customerSummaryIsLoading || customersIsLoading) && (
<>
{/* <CircularLoader /> */}
<Loader />
</>
)}
{/* <TitleCard title={'Customers'} /> */}
<MainCard
content={false}
sx={{}}
title={
<Stack sx={{ m: 2 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={2}
sx={{ width: '100%' }}
>
{/* Left: Title */}
<Typography variant="h3">Customers</Typography>
{/* Right: Controls */}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center" width={{ xs: '100%', sm: 'auto' }}>
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
sx={{
width: { xs: '100%', sm: 320 },
zIndex: 100
}}
/>
{(customerSummaryIsLoading || customersIsLoading) && <Loader />}
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder={`Search ${pageCount?.Total || ''} Customer (ctrl+k)`}
{/* ============================================= || Header | ============================================= */}
<Paper
elevation={0}
sx={{
mb: { xs: 1.5, md: 2 },
p: { xs: 1.5, sm: 2, md: 2.5 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: `linear-gradient(135deg, ${tint('#662582')} 0%, ${tint('#9255AB')} 100%)`,
boxShadow: DT.shadowMd
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1.5, sm: 2 }}
>
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
<Avatar
sx={{
width: { xs: 40, sm: 48 },
height: { xs: 40, sm: 48 },
bgcolor: '#662582',
color: '#fff',
boxShadow: '0 6px 18px rgba(99,102,241,0.32)'
}}
>
<MdPeopleAlt size={22} />
</Avatar>
<Stack>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' }
}}
>
Customers
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ mt: 0.5 }}>
<Box
sx={{
width: { xs: '100%', sm: 320 }
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: '#10b981',
boxShadow: '0 0 0 4px rgba(16,185,129,0.18)'
}}
/>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Live · {locaName || 'All Zones'}
</Typography>
</Stack>
</Stack>
</Stack>
}
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
pill
accentColor="#662582"
icon={<MdMyLocation size={14} />}
placeholder="Select Zone"
paperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
/>
</Stack>
</Paper>
{/* ============================================= || KPI Cards | ============================================= */}
<Grid container spacing={{ xs: 1.25, sm: 1.5, md: 2 }} sx={{ mt: '1px' }}>
{KPI_META.map((item) => {
const Icon = item.icon;
return (
<Grid item key={item.key} xs={12} sm={4}>
<Paper
elevation={0}
sx={{
position: 'relative',
overflow: 'hidden',
p: { xs: 1.25, sm: 1.75, md: 2.25 },
borderRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
background: '#fff',
transition: 'transform 0.2s, box-shadow 0.2s, border-color 0.2s',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: DT.shadowMd,
borderColor: edge(item.color)
}
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 3,
background: `linear-gradient(90deg, ${item.color} 0%, ${soft(item.color)} 100%)`
}}
/>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="caption"
sx={{
color: DT.textSecondary,
fontWeight: 700,
letterSpacing: 0.4,
textTransform: 'uppercase',
fontSize: { xs: 10, sm: 11 },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.label}
</Typography>
<Typography
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.value}
</Typography>
</Stack>
<Avatar
sx={{
width: { xs: 36, sm: 42, md: 48 },
height: { xs: 36, sm: 42, md: 48 },
bgcolor: soft(item.color),
color: item.color,
boxShadow: `inset 0 0 0 1px ${edge(item.color)}`,
flexShrink: 0
}}
>
<Icon size={20} />
</Avatar>
</Stack>
</Paper>
</Grid>
);
})}
</Grid>
{/* ============================================= || Search header | ============================================= */}
<Paper
elevation={0}
sx={{
mt: { xs: 1.5, md: 2 },
p: { xs: 1, md: 1.5 },
borderTopLeftRadius: DT.radiusCard / 8,
borderTopRightRadius: DT.radiusCard / 8,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
border: '1px solid',
borderColor: DT.borderSubtle,
borderBottom: 0,
background: '#fff'
}}
>
<TableContainer ref={containerRef} onScroll={handleScroll} sx={{ maxHeight: 'calc(100vh - 180px)' }}>
<Table stickyHeader>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="space-between"
spacing={1.25}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<AccentAvatar color="#662582" size={32}>
<MdGroups size={18} />
</AccentAvatar>
<Stack>
<Typography
variant="caption"
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
>
Directory
</Typography>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
{pageCount?.Total ?? 0} total · {rows.length} loaded
</Typography>
</Stack>
</Stack>
<Box sx={{ width: { xs: '100%', sm: 280, lg: 340 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder={`Search customers (ctrl+k)`}
sx={{
m: 0,
width: '100%',
borderRadius: 999,
bgcolor: tint('#662582'),
'& fieldset': { borderColor: edge('#662582'), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: '#662582' },
'&.Mui-focused fieldset': { borderColor: '#662582', borderWidth: 2 },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#662582')}` }
}}
/>
</Box>
</Stack>
</Paper>
{/* ============================================= || Table || ============================================= */}
<Paper
elevation={0}
sx={{
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomLeftRadius: DT.radiusCard / 8,
borderBottomRightRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
<TableContainer
ref={containerRef}
onScroll={handleScroll}
sx={{
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
'&::-webkit-scrollbar': { width: 10, height: 10 },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge('#662582'),
borderRadius: 8,
'&:hover': { backgroundColor: '#662582' }
},
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
}}
>
<Table stickyHeader sx={{ minWidth: { xs: 720, md: 960 } }}>
<TableHead>
<TableRow>
<TableCell sx={{ position: 'sticky !important' }}>#</TableCell>
<TableCell sx={{ position: 'sticky !important' }}>Name</TableCell>
<TableCell sx={{ position: 'sticky !important' }}>Contact</TableCell>
<TableCell sx={{ position: 'sticky !important' }}>Address</TableCell>
<TableCell sx={{ position: 'sticky !important' }}>Location</TableCell>
<TableCell sx={{ position: 'sticky !important' }}>Action</TableCell>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: { xs: 10, md: 11 },
fontWeight: 800,
letterSpacing: 0.6,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`,
py: { xs: 1, md: 1.25 },
px: { xs: 1, md: 2 }
}
}}
>
<TableCell>#</TableCell>
<TableCell>Customer</TableCell>
<TableCell>Contact</TableCell>
<TableCell>Address</TableCell>
<TableCell>Location</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{customersIsLoading && <OrdersTableSkeleton />}
{rows?.length == 0 && !customersIsLoading ? (
{rows?.length === 0 && !customersIsLoading ? (
<TableRow>
<TableCell colSpan={6}>
<Stack width={'100%'} direction={'row'} justifyContent={'center'}>
<Empty />
<TableCell colSpan={6} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdGroups size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No customers to show
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the directory.'}
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
rows?.map((row, index) => {
return (
<TableRow key={row.name}>
<TableCell padding="none">{index + 1 + page * rowsPerPage}</TableCell>
<TableCell align="left">
<Typography align="left" variant="subtitle1">
{row.firstname}
</Typography>
<Typography align="left" variant="caption" color="secondary">
Id : {row.customerid}
</Typography>
</TableCell>
<TableCell align="left">
<Typography align="left" variant="subtitle1">
{row.contactno}
</Typography>
<Typography align="left" variant="caption" color="secondary">
{row.email}
</Typography>
</TableCell>
<TableCell align="left">{row.address}</TableCell>
<TableCell align="left">{row.suburb}</TableCell>
<TableCell align="center">
<Tooltip title={'To Edit'}>
<IconButton
onClick={() => {
console.log('row', row);
setSelectedCustomer(row);
setTimeout(() => {
setOpen(true);
}, 0);
}}
rows?.map((row, index) => (
<TableRow
key={row.customerid || `${row.firstname}-${index}`}
sx={{
cursor: 'pointer',
transition: 'background-color 0.15s',
'& td': {
borderBottom: `1px solid ${DT.divider}`,
py: { xs: 1, md: 1.5 },
px: { xs: 1, md: 2 }
},
'&:hover': { backgroundColor: DT.surfaceAlt }
}}
>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
{String(index + 1 + page * rowsPerPage).padStart(2, '0')}
</Typography>
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color="#662582" size={36}>
<MdPersonPin size={18} />
</AccentAvatar>
<Stack>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
<RiEdit2Fill
fontSize={'large'}
style={{
cursor: 'pointer',
color: theme.palette.primary.main
}}
/>
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})
{row.firstname || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{row.customerid}
</Typography>
</Stack>
</Stack>
</TableCell>
<TableCell>
<Stack>
<Stack direction="row" alignItems="center" spacing={0.5}>
<MdPhone size={12} color={DT.textMuted} />
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
>
{row.contactno || '—'}
</Typography>
</Stack>
{row.email && (
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.email}
</Typography>
)}
</Stack>
</TableCell>
<TableCell sx={{ maxWidth: 280 }}>
<Tooltip title={row.address || ''} placement="top">
<Typography
variant="caption"
sx={{
color: DT.textSecondary,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}
>
{row.address || '—'}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
{row.suburb ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: tint('#10b981'),
border: `1px solid ${edge('#10b981')}`,
color: '#10b981',
fontSize: 11,
fontWeight: 800
}}
>
<MdLocationOn size={12} /> {row.suburb}
</Box>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
)}
</TableCell>
<TableCell align="right">
<Tooltip title="Edit customer" placement="top">
<IconButton
size="small"
onClick={() => {
setSelectedCustomer(row);
setTimeout(() => setOpen(true), 0);
}}
sx={{
bgcolor: soft('#8b5cf6'),
color: '#8b5cf6',
border: `1px solid ${edge('#8b5cf6')}`,
'&:hover': { bgcolor: '#8b5cf6', color: '#fff' }
}}
>
<MdEdit size={16} />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
{rows?.length != 0 && (
{rows?.length !== 0 && (
<TableRow>
<TableCell colSpan={15} rowSpan={3}>
<TableCell colSpan={6} sx={{ borderBottom: 'none' }}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage ? <LoaderWithImage /> : hasNextPage ? <LoaderWithImage /> : 'No More Orders'}
{isFetchingNextPage || hasNextPage ? (
<LoaderWithImage />
) : (
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 600 }}>
No more customers
</Typography>
)}
</div>
</TableCell>
</TableRow>
@@ -458,8 +820,7 @@ export default function Customers() {
</TableBody>
</Table>
</TableContainer>
<Divider />
</MainCard>
</Paper>
{/* ======================================== || Edit Dialog || ======================================== */}
<Dialog
open={open}
@@ -469,9 +830,26 @@ export default function Customers() {
maxWidth="lg"
fullWidth
>
<DialogTitle id="alert-dialog-title" sx={{ bgcolor: theme.palette.primary.main, color: 'white' }}>
<Stack direction={'row'} alignItems={'center'} spacing={1}>
<FaRegEdit style={{ fontSize: 25 }} /> <Typography variant="h3">Edit Customer</Typography>
<DialogTitle
id="alert-dialog-title"
sx={{
background: `linear-gradient(135deg, #662582 0%, #9255AB 100%)`,
color: '#fff',
py: 2
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'rgba(255,255,255,0.2)', color: '#fff' }}>
<FaRegEdit style={{ fontSize: 18 }} />
</Avatar>
<Stack>
<Typography sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }}>
Customer
</Typography>
<Typography sx={{ fontWeight: 800, fontSize: { xs: '1.05rem', sm: '1.2rem' }, lineHeight: 1.2, mt: 0.25 }}>
Edit {selectedCustomer?.firstname || 'Customer'}
</Typography>
</Stack>
</Stack>
</DialogTitle>
<DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
# CLAUDE.md — `src/pages/nearle/dispatch/`
Rules for editing `Dispatch.js`, `Preview.js`, `CompareDataPanel.js`, and `dispatchShared.js`.
**This is the most complex area of the codebase.** Dispatch.js alone is ~2500 lines, mixes Leaflet imperative APIs with React state, and shares its batch / time-bucket model with `deliveries.js`. Read this before touching anything here.
---
## 1. Batch / wave model
Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors them — **the two pages must agree on which batch a given row belongs to**, otherwise the same delivery shows up in one batch on one page and a different batch on the other.
```js
// BATCH_OPTIONS — half-open [startHour, endHour) in LOCAL time, not UTC
[
{ id: 'morning', startHour: 0, endHour: 8 }, // 12 AM 8 AM
{ id: 'afternoon', startHour: 9, endHour: 12 }, // 9 AM 12 PM
{ id: 'evening', startHour: 16, endHour: 19 } // 4 PM 7 PM
]
```
**Gaps are intentional** (89 AM, 12 PM4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one.
### Time-field selection (`selectedTimeField`)
Default `'assigned'` → bucket key is `['assigntime']`. Other options use other timestamp fields (`pickedtime`, `deliverytime`). If you add a new time field option, make sure `deliveries.js` is updated too — they read each other's bucketing.
### Don'ts
- Don't bucket in UTC. Use `dayjs(t)` (local), not `dayjs(t).utc()`. The original deliveries page had a UTC bucketing bug that hid orders mid-day; the multi-line comment above `getRowBatchId` in `deliveries.js` (search for `getRowBatchId`) explains it. Don't reintroduce.
- Don't bucket bare `YYYY-MM-DD` strings — they parse to midnight and mis-bucket into Morning. Skip them.
- Don't add a 4th batch without updating both pages and confirming with the backend what the new boundary means for assignments.
---
## 2. Leaflet integration
Dispatch.js uses `react-leaflet` for declarative tile/marker rendering, BUT a lot of marker behaviour is imperative:
- **Marker icons:** The default leaflet marker icons are loaded from a CDN at module top (line ~454) via `L.Icon.Default.mergeOptions`. Webpack can't bundle the default sprite, so don't remove this shim.
- **Polyline offset:** `import '../../../utils/leafletPolylineOffset'` adds a polyline-offset method to leaflet. Required for showing two riders on the same road segment with parallel polylines. Don't remove the import; the symbol it adds is used at the prototype level.
- **Imperative marker registry:** A `useRef` map holds Leaflet marker instances keyed by `orderid`. Opening/closing popups is done by calling `marker.openPopup()` / `closePopup()` directly — *not* by setting React state. This is intentional; flipping state caused full-list re-renders and dropped frames.
- **Popup overlay:** The "centered" popup the operator sees on hover is rendered outside Leaflet (it's a plain MUI dialog absolutely-positioned over the map). Leaflet's `<Popup>` attached to the marker is for click context only. Don't try to consolidate them.
---
## 3. The reconcile rule (re-stated because it's load-bearing)
After **any** manual edit on `/nearle/dispatch/preview` (drag-and-drop step reorder, swap rider, change delivery sequence), the page **must** call `POST routes.workolik.com/optimization/reconcile-steps` before `POST jupiter.nearle.app/deliveries/createdeliveries`.
Skipping reconcile corrupts route sequences in the database. This is the single biggest production bug to avoid in this area.
---
## 4. State that drives the live map
- `riders` — array of rider objects with latest GPS. Updated by polling `/utils/getriderperiodiclogs`.
- `selectedRider` — currently focused rider. Triggers polyline highlight + popup reveal.
- `batchCounts` — derived (via `useMemo`) from the loaded order list filtered by the active batch. Mirror this shape on `deliveries.js`.
- `selectedTimeField` — which timestamp drives bucketing. See §1.
---
## 5. Performance constraints
The dispatch page renders 100+ markers and polylines on every render. Watch for these regressions:
- Don't pass new object literals to memoised child components (`{ size: 12 }`-style props re-trigger re-render). Lift to refs or `useMemo`.
- Don't put `setSelectedRider` inside a `useEffect` that depends on `riders` — that causes polling-loop re-renders.
- Don't `console.log` inside a marker click handler in production paths.
---
## 6. Editing Preview.js specifically
`Preview.js` is the post-solver staging page. Receives the solver output, lets the operator drag-and-drop, then commits.
- Drag-and-drop uses `react-dnd` with `react-dnd-html5-backend`. Don't swap libraries.
- After every drop, debounce a call to `reconcileSteps` from `api.js`. Don't call it synchronously on every drag tick — the optimiser will rate-limit you.
- The "Assign" button calls `finalCreatedeliveries` → triggers `notifyRider` for each rider in the payload → redirects to `/nearle/deliveries`. Don't reorder these three steps.
---
## 7. File expectations for new work
- New solver mode? Add a new constant in `dispatchShared.js`, wire it through `orders.js` (the mode selector), and add the corresponding endpoint to `api.js`. Don't hardcode a new URL inside Dispatch.js or Preview.js.
- New rider attribute on the live map? Add it to the rider-popup component in `Dispatch.js`, not to a new component — Leaflet popup mounting is fragile across React tree changes.
- Don't break `CompareDataPanel.js` — it's used by the QA team to A/B different solver runs.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
# CLAUDE.md — `src/pages/nearle/orders/`
Rules for editing `orders.js`, `OrdersPreview.js`, `createorder1.js`, `newcreateOrder.js`, `multipleOrders.js`, `details.js`, and the optimised preview.
This is the **revenue-critical area** of the console — the path from "order received" to "rider assigned" runs through here. The optimiser hand-off and the assign-and-notify sequence are the two flows you must not break.
---
## 1. The three dispatch modes (Mode 0 / 1 / 2)
The orders page tracks the chosen mode in `aiModeRef` (a `useRef`, not state — it's set just before the mutation fires).
| Mode | Solver | Endpoint | When operator picks it |
|---|---|---|---|
| **0 · Manual** | `routes.workolik.com` | `POST /optimization/createdeliveries` | "Optimise selected orders" — gives a tentative route, operator can rearrange in preview |
| **1 · Bike** | `routes.workolik.com` | `POST /optimization/riderassign?hypertuning_params={...}` | Bike fleet with hyper-tuning (Balanced / Fuel Saver / Aggressive / Strict Zone) |
| **2 · Auto** | `routemate.workolik.com` | `POST /optimization/riderassign?strategy=multi_trip` | Auto-rickshaw fleet, hourly multi-trip |
The mutation function gets picked by mode:
```js
useMutation({
mutationFn: aiModeRef.current == 0 ? createOptimisationDeliveries : createAutomationDeliveries,
...
});
```
**`createAutomationDeliveries` covers both Mode 1 and Mode 2** — the difference is the URL it constructs and the `hypertuning_params` query string. Don't split them into separate functions.
---
## 2. The hand-off to `/orders/preview` (and then `/dispatch/preview`)
After the solver returns:
1. Solver response → stored in the orders page state.
2. Operator navigates to `OrdersPreview.js` (`/nearle/orders/preview`) for a first look.
3. From there → `/nearle/dispatch/preview` (`Preview.js` in the dispatch folder) for drag-and-drop adjustment.
4. `Preview.js` is the one that calls `finalCreatedeliveries` to commit.
Don't try to commit from `orders.js` or `OrdersPreview.js` — they are read-only / staging steps. The reconcile-then-commit dance only happens on the dispatch preview page (see `src/pages/nearle/dispatch/CLAUDE.md`).
---
## 3. Selection state
Multi-order optimisation uses a checkbox column. The selection lives in component state as an array of order objects (not just IDs) because the solver payload needs full order data (`pickup_lat`, `pickup_lng`, `drop_lat`, `drop_lng`, `weight`, `expecteddeliverytime`, etc.).
- "Select all" is implemented per visible page — not across all loaded infinite pages — to avoid accidental multi-thousand-order solver runs.
- Selection clears when `currentStatus`, `appId`, or date range changes. This is intentional — solver runs are scoped to a single tab.
- Don't add a "select across pages" affordance without confirming the optimiser's payload limits.
---
## 4. Cancellation paths
| Function | Use |
|---|---|
| `cancelOrder` (`PUT /orders/updateorder`) | Single-order cancel (per-row icon) |
| `cancelMultipleOrder` (`PUT /orders/updatemultipleorders`) | Bulk cancel of selected orders (toolbar button) |
Both record a cancel timestamp on the order. Neither triggers a rider FCM notification because the order was never assigned. **Do not call `notifyRider` after these.**
---
## 5. Filters & data
The orders list is a `useInfiniteQuery` keyed by `['fetchorders', appId, currentStatus, debouncedSearch, startdate, enddate, rowsPerPage, tenantid, locationid]`. The corresponding `api.js` function (`fetchOrders`) destructures in this same order — see `src/pages/api/CLAUDE.md` §1.
The summary endpoint (`fetchorderscount`) uses a slightly different key — `currentStatus` comes after the date range. Match the call site, don't normalise.
---
## 6. Don'ts specific to this folder
- **Don't introduce a 4th dispatch mode** without coordinating with the workolik backend team. The solver URL and `?hypertuning_params=` query are versioned.
- **Don't move solver URLs into env vars.** They are deliberately hardcoded because the optimiser is a separate, versioned service — pinning the URL is the version pin.
- **Don't fold `createorder1.js`, `newcreateOrder.js`, and `multipleOrders.js` into one component.** They serve different operator flows (existing customer vs new customer vs CSV bulk import).
- **Don't reorder `OrdersTableSkeleton`'s column count** without checking every page that imports it. It's used as a shared skeleton across orders, deliveries, tenants, and customers.

View File

@@ -157,9 +157,9 @@ const OrderMap = ({ startPoint, endPoint, appLocaLat, appLocaLng }) => {
{hasPick && <Marker position={pickCoords} icon={pickupIcon} />}
{hasDrop && <Marker position={dropCoords} icon={dropoffIcon} />}
{routePoints.length > 0 ? (
<Polyline positions={routePoints} color="#1890ff" weight={4} />
<Polyline positions={routePoints} color="#662582" weight={4} />
) : (
hasPick && hasDrop && <Polyline positions={[pickCoords, dropCoords]} color="#1890ff" weight={4} />
hasPick && hasDrop && <Polyline positions={[pickCoords, dropCoords]} color="#662582" weight={4} />
)}
<MapBoundsController startPoint={startPoint} endPoint={endPoint} />
</MapContainer>
@@ -1490,7 +1490,7 @@ const Createorder1 = () => {
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchOutlined style={{ fontSize: 15, color: '#1890ff' }} />
<SearchOutlined style={{ fontSize: 15, color: '#662582' }} />
</InputAdornment>
),
endAdornment: (
@@ -1526,7 +1526,7 @@ const Createorder1 = () => {
InputProps={{
startAdornment: (
<InputAdornment position="start">
<FaLocationDot style={{ fontSize: 14, color: '#1890ff' }} />
<FaLocationDot style={{ fontSize: 14, color: '#662582' }} />
</InputAdornment>
),
endAdornment: (
@@ -2268,7 +2268,7 @@ const Createorder1 = () => {
{/* Map Card */}
<Card className="orders-card" sx={{ p: 1.5, display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ fontWeight: 700, mb: 1.25, display: 'flex', alignItems: 'center', gap: 0.75, color: '#1e293b', fontSize: '14px', letterSpacing: '-0.01em' }}>
<MyLocationIcon sx={{ color: '#1890ff', fontSize: 16 }} />
<MyLocationIcon sx={{ color: '#662582', fontSize: 16 }} />
Live Route Preview
</Typography>
<div className="map-preview-wrapper">
@@ -2446,7 +2446,7 @@ const Createorder1 = () => {
}
}}
>
<DialogTitle sx={{ bgcolor: '#1890ff', color: 'white', py: 2.5 }}>
<DialogTitle sx={{ background: 'linear-gradient(135deg, #662582 0%, #9255AB 100%)', color: 'white', py: 2.5 }}>
<Stack spacing={1.5}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'white' }}>
{`Select Saved Address (${pickordrop === 1 ? 'Pickup' : 'Drop'})`}

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

File diff suppressed because it is too large Load Diff