diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b80aacf --- /dev/null +++ b/.claude/settings.json @@ -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:*)" + ] + } +} diff --git a/.claude/skills/nearlexpress-docs/SKILL.md b/.claude/skills/nearlexpress-docs/SKILL.md new file mode 100644 index 0000000..d1abb00 --- /dev/null +++ b/.claude/skills/nearlexpress-docs/SKILL.md @@ -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
src/index.js]):::entry + Providers["Providers wiring
Redux · QueryClient · Router · Theme · Notistack"]:::entry + App["src/App.js
localStorage('authname') gate"]:::auth + FCMInit["generateToken()
initFirebaseNotificationListener()"]:::auth + SW["public/firebase-messaging-sw.js"]:::auth + Login[/"/login
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
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
Live Map · Riders · Batches"/]:::core + Orders[/"/nearle/orders
Orders Dashboard"/]:::core + Deliveries[/"/nearle/deliveries
Dispatched Deliveries"/]:::core + Tenants[/"/nearle/tenants
Client/Tenant Management"/]:::core + Pricing[/"/nearle/pricing
Pricing Matrix (master-detail)"/]:::core + Customers[/"/nearle/customers
Customer Directory"/]:::core + Riders[/"/nearle/riders
Rider Pool"/]:::core + Invoice[/"/nearle/invoice
Billing"/]:::core + Requests[/"/nearle/requests
Expense Approvals"/]:::core + ReportsHub[/"/nearle/reports/*
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
POST routes.workolik.com
/optimization/riderassign"]:::ext + SolverAuto["Mode 2 · Auto
POST routemate.workolik.com
/optimization/riderassign"]:::ext + SolverManual["Mode 0 · Manual
POST routes.workolik.com
/optimization/createdeliveries"]:::ext + Reconcile["POST routes.workolik.com
/optimization/reconcile-steps"]:::ext + FinalAssign["POST jupiter.nearle.app
/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 `
` 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 0–8, Afternoon 9–12, Evening 16–19). 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 104–297. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..021bcad --- /dev/null +++ b/CLAUDE.md @@ -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 `` 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) => ( + +); + +// Colored avatar — flips between filled and soft based on `selected`. +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + {children} +); +``` + +### 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 ``** — `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 3–4 `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 ``** (optional) — pill-style `Autocomplete`s using `pillFieldSx(color)` + `SoftPaper`, each with an `AccentAvatar` start-adornment. +4. **Pill status tabs + pill search** — tabs as clickable `` 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 ``** with `` + sticky `` — 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 `` 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 `` for status/filter switching — they were replaced by the pill `` pattern on every redesigned page. Pages that still use `` (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 `
` placed at the bottom of the ``. 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//.js`. +2. Add the lazy import at the top of `src/routes/MainRoutes.js`: + ```js + const MyPage = Loadable(lazy(() => import('pages/nearle//'))); + ``` +3. Register the route inside the `nearle` children array. +4. Add a sidebar entry in `src/menu-items/nearle.js` (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`, ``) 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. diff --git a/FLOW.md b/FLOW.md new file mode 100644 index 0000000..b52ba66 --- /dev/null +++ b/FLOW.md @@ -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
src/index.js]):::entry + Providers["Providers wiring
Redux store · QueryClient · Router · ThemeCustomization · Notistack"]:::entry + App["src/App.js
localStorage('authname') gate"]:::auth + FCMInit["generateToken()
initFirebaseNotificationListener()"]:::auth + SW["public/firebase-messaging-sw.js
FCM Service Worker"]:::auth + Login[/"/login
pages/nearle/login1.js"/]:::auth + + Boot --> Providers --> App + App -- no authname --> Login + Login -- POST /users/console/login
(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
Sidebar + Header + Outlet"]:::nav + Sidebar["menu-items/nearle.js
(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
Live Map · Riders · Batches"/]:::core + Orders[/"/nearle/orders
Orders Dashboard"/]:::core + Deliveries[/"/nearle/deliveries
Dispatched Deliveries"/]:::core + Tenants[/"/nearle/tenants
Client/Tenant Management"/]:::core + Pricing[/"/nearle/pricing
Pricing Matrix (master-detail)"/]:::core + Customers[/"/nearle/customers
Customer Directory"/]:::core + Riders[/"/nearle/riders
Rider Pool"/]:::core + Invoice[/"/nearle/invoice
Billing"/]:::core + Requests[/"/nearle/requests
Expense Approvals"/]:::core + ReportsHub[/"/nearle/reports/*
BI Suite"/]:::core + ViewProfile[/"/viewprofile
Profile Manager"/]:::sub + + %% ============================ Sub-routes ============================ + OrdersCreate[/"/orders/create
Single Order Create"/]:::sub + OrdersMulti[/"/orders/createorders
Bulk Order Create"/]:::sub + OrdersDetails[/"/orders/details
Order Detail View"/]:::sub + OrdersPreview[/"/orders/preview
Optimised Preview"/]:::sub + DispatchPreview[/"/dispatch/preview
Dispatch Review & Adjust"/]:::sub + RidersCreate[/"/riders/create
Onboard Rider"/]:::sub + RidersEdit[/"/riders/edit
Edit Rider"/]:::sub + ClientsCreate[/"/clients/create
Onboard Tenant"/]:::sub + CustomerCreate[/"/customer/create
Onboard Customer"/]:::sub + InvoicePreview[/"/invoice/preview
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
(checkbox column)"]:::action + ChooseSolver{"Choose Dispatch Mode"}:::solver + SolverBike["Mode 1 · Bike
POST routes.workolik.com
/optimization/riderassign"]:::ext + SolverAuto["Mode 2 · Auto
POST routemate.workolik.com
/optimization/riderassign"]:::ext + SolverManual["Mode 0 · Manual
POST routes.workolik.com
/optimization/createdeliveries"]:::ext + Reconcile["POST routes.workolik.com
/optimization/reconcile-steps"]:::ext + FinalAssign["POST jupiter.nearle.app
/deliveries/createdeliveries"]:::ext + NotifyRider["POST /utils/notifyuser
(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
GET /orders/getorderdetails"]:::api + + %% ============================ Dispatch (live) ============================ + DispatchMap["Live Leaflet map
Riders · Routes · Batches"]:::action + RiderLogs["GET /utils/getriderperiodiclogs
(polled)"]:::api + BatchEff["POST routes.workolik.com
/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
+ 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
fcmSlice · loginUserSlice
menu · snackbar · toastSlice · auth"]:::state + QC["@tanstack/react-query
QueryClient (cache + infinite scroll)"]:::state + AxiosBase["axios (raw) +
utils/axios.js (401 → /login)"]:::state + Env["env vars: REACT_APP_URL ·
REACT_APP_URL2 · REACT_APP_GOOGLE_MAPS_API_KEY"]:::state + LS["localStorage:
authname · userid · roleid ·
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
(named async fns)"] + 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 (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`. diff --git a/src/components/nearle_components/CLAUDE.md b/src/components/nearle_components/CLAUDE.md new file mode 100644 index 0000000..fc347f8 --- /dev/null +++ b/src/components/nearle_components/CLAUDE.md @@ -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 `` header pattern documented in root `CLAUDE.md` §6, implemented in `deliveries.js` (search for the comment `Header` near the first `` 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 + + +// Pill variant — used by all redesigned pages (deliveries, tenants, pricing, customers) +} + 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 + +``` + +- **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 ``). 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 5–20 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. diff --git a/src/components/nearle_components/LocationAutocomplete.js b/src/components/nearle_components/LocationAutocomplete.js index 0468768..dc8eaca 100644 --- a/src/components/nearle_components/LocationAutocomplete.js +++ b/src/components/nearle_components/LocationAutocomplete.js @@ -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 ( - 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) => } - /> - ); -}); + : {}; + + const Adornment = pill && ( + + + {icon || } + + + ); + + return ( + 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 ? ( + + ) : ( + + ) + } + /> + ); + } +); export default LocationAutocomplete; diff --git a/src/pages/api/CLAUDE.md b/src/pages/api/CLAUDE.md new file mode 100644 index 0000000..3a835db --- /dev/null +++ b/src/pages/api/CLAUDE.md @@ -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. diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 988720f..8361e6a 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -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 }) => { diff --git a/src/pages/nearle/clientPricing/clientPricing.js b/src/pages/nearle/clientPricing/clientPricing.js index 1df45fb..1307f93 100644 --- a/src/pages/nearle/clientPricing/clientPricing.js +++ b/src/pages/nearle/clientPricing/clientPricing.js @@ -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) => ( + +); + +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + +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 }) => ( + + {icon} + {label} + +); + +// ==============================|| 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 && } - {/* ============================================= || Title Card || ============================================= */} - - - + {isLoading && } + + {/* ============================================= || Header || ============================================= */} + + + + + + + + + Pricing + + + + + Live · {locaName || 'All Zones'} + + + + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} /> - - {/* ============================================= || table || ============================================= */} - - - - - - S.No - Location - Pricing Id - Name + + + {/* ============================================= || KPI Cards || ============================================= */} + + {KPI_META.map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + + {item.value} + + + + + + + + + ); + })} + + + {/* ============================================= || Search Header || ============================================= */} + + + + + + + + + Pricing Catalog + + + {pricing.length} total · {rows.length} shown + + + + + + + + + + {/* ============================================= || Table || ============================================= */} + + +
+ + + # + Tenant + Zone Slab Base Price - MinKm - Price/Km - MaxKm - Min Orders + Min KM + Price / KM + Max KM + Min Orders + - {isLoader && } - {pricing?.length === 0 && !isLoader ? ( + {isLoading && } + {rows.length === 0 && !isLoading ? ( - - + + + + + + + No pricing to show + + + {searchword ? 'Try a different keyword.' : 'Pick a zone above to load the catalog.'} + + ) : ( - pricing?.map((data, index) => ( - - {index + 1} - - {data.applocation} - - - + rows.map((row, index) => ( + + + + {String(index + 1).padStart(2, '0')} + - {data.appname} + + + + + + + + {row.appname || '—'} + + + ID #{row.pricingid} + + + + - {data.slab} + + {row.applocation ? ( + + {row.applocation} + + ) : ( + + )} + - - + + + {row.slab || '—'} + - + } + label={formatRupees(row.baseprice)} + width={110} + /> - + } + label={`${formatDecimal(row.minkm)} km`} + width={90} + /> - + } + label={formatRupees(row.priceperkm)} + width={110} + /> - {data.minorder} + + } + label={`${formatDecimal(row.maxkm)} km`} + width={90} + /> + + + + + + + {row.minorder ?? '—'} + + + )) )}
-
+
); }; diff --git a/src/pages/nearle/clients/Tenants.js b/src/pages/nearle/clients/Tenants.js index 03fa425..1fd8c5a 100644 --- a/src/pages/nearle/clients/Tenants.js +++ b/src/pages/nearle/clients/Tenants.js @@ -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) => ( + +); + +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + // ==============================|| 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 ( -
+ <> {(getalltenantsIsLoading || summaryDataIsLoading || isloader) && } - {/* ========================================================== || Titlecard || ========================================================== */} - - - - {/* ========================================================== || MainCard (searchword) || ========================================================== */} - + + + + + + + + Tenants + + + + + Live · {locaName || 'All Zones'} + + + + + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} + /> + + + + {/* ============================================= || KPI Cards | ============================================= */} + + {KPI_META.map((item) => { + const Icon = item.icon; + const value = summaryData?.[item.countKey] ?? 0; + return ( + + { + const idx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey); + if (idx >= 0) handleChange(null, idx); + }} + > + + + + + {item.label} + + {summaryDataIsLoading ? ( + + ) : ( + + {value} + + )} + + + + + + + + ); + })} + + + {/* ============================================= || Status Tabs + Search || ============================================= */} + + - {/* ============================================= || Tabs || ================================================= */} - - - { - setstatus('active'); - }} - icon={ - : summaryData?.active || 0} - color="primary" - variant="light" - sx={{ minWidth: 32, justifyContent: 'center' }} - size="small" - /> - } - iconPosition="end" - /> - { - setstatus('pending'); - }} - icon={ - : summaryData?.pending || 0} - color="primary" - variant="light" - sx={{ minWidth: 32, justifyContent: 'center' }} - size="small" - /> - } - iconPosition="end" - /> - { - setstatus('inactive'); - }} - icon={ - : summaryData?.inactive || 0} - color="primary" - variant="light" - sx={{ minWidth: 32, justifyContent: 'center' }} - size="small" - /> - } - iconPosition="end" - /> - - - {/* ============================================= || 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 ( + { + 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)}` + } + }} + > + + + + + {meta.label} + + + {summaryDataIsLoading ? : count} + + + ); + })} + + {/* Search */} + - - } + + + + + {/* ============================================= || Table || ============================================= */} + - {/* ============================================= || TableContainer| ============================================= */} - - + +
- - S.No - Client - Contact - Address - - Actions - + + # + Status + Client + Contact + Address + Actions @@ -589,111 +927,220 @@ const Clients1 = () => { {tenantList?.length == 0 && !isloader ? ( - - - + + + + + + + No tenants to show + + + {`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`} + ) : ( - 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 ( + {/* ============================================ || tablerow 1 || ============================================ */} - - {index + 1 + page * rowsPerPage} + - - - {row.tenantname} + + {String(index + 1 + page * rowsPerPage).padStart(2, '0')} + + + + + + + + + {rowStatusMeta.label} - Id : {row.tenantid} - {row.primarycontact} - {row.primaryemail} + + + + + + + {row.tenantname} + + + ID #{row.tenantid} + + + - {row.address} - - - + + + {row.primarycontact || '—'} + + + {row.primaryemail || '—'} + + + + + + - - {value0 == 0 ? ( - { - setSelectedCustomer(row); - setSelectedtenid(row.tenantid); - setAppId(row.applocationid); - setClientstatus(row.approved); - setTimeout(() => { - tenantupdate(row.tenantid); - }, 100); - }} - /> - ) : value0 == 1 ? ( - { - setSelectedCustomer(row); - setDialogopen(true); - setSelectedtenid(row.tenantid); - setAppId(row.applocationid); - getAppPricing(row.applolcationid); - }} - /> - ) : ( - { - setSelectedCustomer(row); - setSelectedtenid(row.tenantid); - setAppId(row.applocationid); - setTimeout(() => { - tenantupdate(row.tenantid); - }, 100); - }} - /> - )} + {row.address || '—'} + + + + + + {value0 == 0 && ( + + { + setSelectedCustomer(row); + setSelectedtenid(row.tenantid); + setAppId(row.applocationid); + setTimeout(() => { + tenantupdate(row.tenantid); + }, 100); + }} + > + + - - { - 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 ? : } - - {value0 !== 1 && ( + )} + {value0 == 1 && ( + + { + setSelectedCustomer(row); + setDialogopen(true); + setSelectedtenid(row.tenantid); + setAppId(row.applocationid); + getAppPricing(row.applolcationid); + }} + > + + + + )} + {value0 == 2 && ( + + { + setSelectedCustomer(row); + setSelectedtenid(row.tenantid); + setAppId(row.applocationid); + setTimeout(() => { + tenantupdate(row.tenantid); + }, 100); + }} + > + + + + )} + { 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 }} > - - {openRowIndex2 === index ? : } - + {openRowIndex1 === index ? : } + + {value0 !== 1 && ( + + { + setSelectedCustomer(row); + handleCollapseToggle2(index); + setOpenRowIndex1(-1); + setSelectedtenid(row.tenantid); + setAppId(row.applocationid); + }} + > + {openRowIndex2 === index ? : } + + )} @@ -1532,21 +1979,20 @@ const Clients1 = () => { )} - - )) + + ); + }) )}
- {/* ============================================= || Pagination| ============================================= */} {!searchword && tenantList?.length > 0 && ( <> - + { /> )} -
+ {/* // ==============================||( Client Pricing ) dialog (dialogopen) ||============================== // */} { -
+ ); }; diff --git a/src/pages/nearle/customers/customers.js b/src/pages/nearle/customers/customers.js index 631b954..54bb707 100644 --- a/src/pages/nearle/customers/customers.js +++ b/src/pages/nearle/customers/customers.js @@ -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:109–162. +// ============================================================================ +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) => ( + +); + +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + // ==============================|| 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) && ( - <> - {/* */} - - - )} - {/* */} - - - {/* Left: Title */} - Customers - {/* Right: Controls */} - - + {(customerSummaryIsLoading || customersIsLoading) && } - + + + + + + + + Customers + + + + + Live · {locaName || 'All Zones'} + - } + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} + /> + + + + {/* ============================================= || KPI Cards | ============================================= */} + + {KPI_META.map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + + {item.value} + + + + + + + + + ); + })} + + + {/* ============================================= || Search header | ============================================= */} + - - + + + + + + + + Directory + + + {pageCount?.Total ?? 0} total · {rows.length} loaded + + + + + + + + + + {/* ============================================= || Table || ============================================= */} + + +
- - # - Name - Contact - Address - Location - Action + + # + Customer + Contact + Address + Location + Action {customersIsLoading && } - {rows?.length == 0 && !customersIsLoading ? ( + {rows?.length === 0 && !customersIsLoading ? ( - - - + + + + + + + No customers to show + + + {searchword ? 'Try a different keyword.' : 'Pick a zone above to load the directory.'} + ) : ( - rows?.map((row, index) => { - return ( - - {index + 1 + page * rowsPerPage} - - - {row.firstname} - - - Id : {row.customerid} - - - - - {row.contactno} - - - {row.email} - - - {row.address} - {row.suburb} - - - { - console.log('row', row); - setSelectedCustomer(row); - setTimeout(() => { - setOpen(true); - }, 0); - }} + rows?.map((row, index) => ( + + + + {String(index + 1 + page * rowsPerPage).padStart(2, '0')} + + + + + + + + + - - - - - - ); - }) + {row.firstname || '—'} + + + ID #{row.customerid} + + + + + + + + + + {row.contactno || '—'} + + + {row.email && ( + + {row.email} + + )} + + + + + + {row.address || '—'} + + + + + {row.suburb ? ( + + {row.suburb} + + ) : ( + + )} + + + + { + setSelectedCustomer(row); + setTimeout(() => setOpen(true), 0); + }} + sx={{ + bgcolor: soft('#8b5cf6'), + color: '#8b5cf6', + border: `1px solid ${edge('#8b5cf6')}`, + '&:hover': { bgcolor: '#8b5cf6', color: '#fff' } + }} + > + + + + + + )) )} - {rows?.length != 0 && ( + {rows?.length !== 0 && ( - +
- {isFetchingNextPage ? : hasNextPage ? : 'No More Orders'} + {isFetchingNextPage || hasNextPage ? ( + + ) : ( + + No more customers + + )}
@@ -458,8 +820,7 @@ export default function Customers() {
- - +
{/* ======================================== || Edit Dialog || ======================================== */} - - - Edit Customer + + + + + + + + Customer + + + Edit {selectedCustomer?.firstname || 'Customer'} + + diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index 031e27e..f931042 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -2,12 +2,10 @@ import logger from '../../../utils/logger'; import { enqueueSnackbar } from 'notistack'; import { DeleteFilled, EditOutlined } from '@ant-design/icons'; import { useState, useEffect, Fragment, useRef, useMemo } from 'react'; -import { Empty } from 'antd'; import dayjs from 'dayjs'; var utc = require('dayjs/plugin/utc'); dayjs.extend(utc); import axios from 'axios'; -import HoverSocialCard from 'components/cards/statistics/HoverSocialCard'; import { useTheme } from '@mui/material/styles'; import { MdOutlineDateRange, @@ -16,7 +14,25 @@ import { MdLightMode, MdWbSunny, MdNightsStay, - MdCheck + MdCheck, + MdTrendingUp, + MdTrendingDown, + MdTrendingFlat, + MdStorefront, + MdLocationOn, + MdDirectionsBike, + MdLocalShipping, + MdNotificationsActive, + MdPersonPin, + MdHistoryToggleOff, + MdCheckCircle, + MdCancel, + MdInventory2, + MdHourglassEmpty, + MdRoute, + MdSkipNext, + MdTune, + MdMyLocation } from 'react-icons/md'; import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; @@ -24,12 +40,10 @@ import { Avatar, Box, Button, + Chip, Grid, - Tabs, - Tab, IconButton, Stack, - Chip, Typography, Table, TableCell, @@ -42,48 +56,31 @@ import { DialogTitle, Tooltip, DialogActions, - Popper, - ClickAwayListener, Checkbox, Autocomplete, TextField, - FormLabel, - TablePagination, TableContainer, Skeleton, - useMediaQuery, - Divider, - CircularProgress, Backdrop, MenuItem, Menu, Paper } from '@mui/material'; -import MainCard from 'components/MainCard'; -import { - MoreOutlined, - CloseOutlined - // NotificationOutlined -} from '@ant-design/icons'; import { PopupTransition } from 'components/@extended/Transitions'; import { addDays, addMonths, addWeeks, - // addYears, endOfMonth, endOfWeek, - // endOfYear, startOfMonth, startOfWeek - // startOfYear, } from 'date-fns'; import { DateRangePicker } from 'mui-daterange-picker'; import * as React from 'react'; import Loader from 'components/Loader'; import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material'; -import CircularLoader from 'components/CircularLoader'; import { cancelDeliveryAPI, @@ -99,12 +96,95 @@ import { getTenants } from 'pages/api/api'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; -import TitleCard from 'components/nearle_components/TitleCard'; import { OpenToast } from 'components/third-party/OpenToast'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; +// ============================================================================ +// Design tokens — extracted from the polished "Batch" dropdown so every +// surface on this page (filters, KPIs, tabs, status badges, dialogs) shares +// the same visual language. All helpers take a color and emit MUI sx values. +// ============================================================================ +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' +}; + +// Quick alpha helpers (hex + percentage suffix). Mirrors the batch-dropdown +// pattern (`${color}08`, `${color}18`, `${color}55`, `${color}26`). +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 color +const edge = (c) => a(c, '55'); // resting border + +// Pill input sx — used by every filter Autocomplete/TextField on the page. +// Accepts the accent color and returns sx for the outer TextField. Width is +// driven by parent flex/grid so this helper stays width-agnostic. +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 tab pills, row status badges, dialogs. +const STATUS_META = { + pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty }, + accepted: { label: 'Accepted', color: '#6366f1', icon: MdPersonPin }, + arrived: { label: 'Arrived', color: '#06b6d4', icon: MdLocationOn }, + picked: { label: 'Picked', color: '#8b5cf6', icon: MdInventory2 }, + active: { label: 'Active', color: '#14b8a6', icon: MdRoute }, + skipped: { label: 'Skipped', color: '#f97316', icon: MdSkipNext }, + delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle }, + cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel } +}; + +// Ordered status list driving the tabs row (left → right). Each entry binds +// the visual meta above to the `currentStatus` key the queries use AND to the +// `batchCounts` key (so the chip count for the tab is one lookup). +const STATUS_TABS = [ + { status: 'pending', countKey: 'uncoveredLength' }, + { status: 'accepted', countKey: 'assignedLength' }, + { status: 'arrived', countKey: 'arrivedLength' }, + { status: 'picked', countKey: 'pickedLength' }, + { status: 'active', countKey: 'activeLength' }, + { status: 'skipped', countKey: 'skippedLength' }, + { status: 'delivered', countKey: 'coveredLength' }, + { status: 'cancelled', countKey: 'cancelLength' } +]; + +// KPI palette + icons — mirrors the four cards across the top of the page. +const KPI_META = [ + { key: 'created', label: 'Created Orders', color: '#0ea5e9', icon: MdLocalShipping }, + { key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdHourglassEmpty }, + { key: 'delivered', label: 'Delivered Orders', color: '#10b981', icon: MdCheckCircle }, + { key: 'cancelled', label: 'Cancelled Orders', color: '#ef4444', icon: MdCancel } +]; + // Batches mirror the dispatch page's slot definitions so an operator who // segments the day there sees the same buckets here. Hours are 24h, half-open // [startHour, endHour) — a delivery at exactly endHour falls into the *next* @@ -183,6 +263,39 @@ const getRowBatchId = (row) => { return null; }; +// Shared Paper used by every Autocomplete popup on the page. Matches the +// batch dropdown's soft elevated look. +const SoftPaper = (props) => ( + +); + +// Renders a colored avatar with an icon — used in pill input adornments and +// in dropdown rows. Tracks `selected` so it can flip between filled and +// soft variants identically to the batch dropdown. +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + // ================================================= || deliveries (initial point)|| ================================================= const Deliveries = () => { const userid = localStorage.getItem('userid'); @@ -788,60 +901,226 @@ const Deliveries = () => { {/* */} } - {/* ============================================= || TitleCard | ============================================= */} - - - + {/* ============================================= || Header | ============================================= */} + + + + + + + + + Deliveries + + + + + Live · {locaName || 'All Zones'} + + + + + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} + /> + + - {/* ============================================= || hoverCard | ============================================= */} - + {/* ============================================= || KPI Cards | ============================================= */} + {[ - { - label: 'Created orders', - value: percentageData?.uncoveredOrders, - percentage: percentageData?.percentage1, - color: theme.palette.info.main - }, - { - label: 'Pending orders', - value: percentageData?.assignedOrders, - percentage: percentageData?.percentage2, - color: theme.palette.warning.main - }, - { - label: 'Delivered orders', - value: percentageData?.pickedOrders, - percentage: percentageData?.percentage3, - color: theme.palette.success.main - }, - { - label: 'Cancelled Orders', - value: percentageData?.coveredOrders, - percentage: percentageData?.percentage4, - color: theme.palette.error.main - } - ].map((item, index) => ( - - : item.value} - percentage={item.percentage?.toString()} - color={item.color} - /> - - ))} + { ...KPI_META[0], value: percentageData?.uncoveredOrders, percentage: percentageData?.percentage1 }, + { ...KPI_META[1], value: percentageData?.assignedOrders, percentage: percentageData?.percentage2 }, + { ...KPI_META[2], value: percentageData?.pickedOrders, percentage: percentageData?.percentage3 }, + { ...KPI_META[3], value: percentageData?.coveredOrders, percentage: percentageData?.percentage4 } + ].map((item) => { + const Icon = item.icon; + const pct = typeof item.percentage === 'number' ? item.percentage : Number(item.percentage); + const hasPct = !Number.isNaN(pct) && item.percentage !== undefined && item.percentage !== null; + const Trend = !hasPct ? MdTrendingFlat : pct > 0 ? MdTrendingUp : pct < 0 ? MdTrendingDown : MdTrendingFlat; + const trendColor = !hasPct || pct === 0 ? DT.textMuted : pct > 0 ? '#10b981' : '#ef4444'; + return ( + + + + + + + {item.label} + + {fetchPercentageIsLoading ? ( + + ) : ( + + {item.value ?? 0} + + )} + {hasPct && ( + + + + {Math.abs(pct)}% + + + vs. yesterday + + + )} + + + + + + + + ); + })} - {/* ============================================= || orderFilter | ============================================= */} - - + {/* ============================================= || Filter Bar | ============================================= */} + + + + + + + Filters + + + { onChange={(e, val) => val && setSelectedBatch(val.id)} getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.id === value.id} - PaperComponent={(paperProps) => ( - - )} + PaperComponent={SoftPaper} ListboxProps={{ sx: { py: 0, maxHeight: 360 } }} renderOption={(props, option, { selected }) => { const Icon = BATCH_ICONS[option.iconKey] || MdAccessTime; @@ -997,7 +1264,7 @@ const Deliveries = () => { ) }} sx={{ - minWidth: 240, + minWidth: { xs: 180, sm: 220, md: 240 }, cursor: 'pointer', '& .MuiOutlinedInput-root': { borderRadius: '999px', @@ -1030,41 +1297,49 @@ const Deliveries = () => { ); })()} - {/* Date range chip — opens the date picker. Stays visible only when - a date range is set (matches prior behavior). */} + {/* Date range pill — opens the date picker. Stays visible only when + a date range is set. Uses the same pill aesthetic as Batch. */} {startdate && enddate && ( - - - - } - label={ - - {dayjs(startdate).format('DD/MM/YYYY')} - {dayjs(enddate).format('DD/MM/YYYY')} - - } + setOpen(true)} - variant="combined" - color="warning" - sx={{ maxWidth: '100%', cursor: 'pointer' }} - /> + sx={{ + pl: 0.5, + pr: 1.25, + py: 0.5, + borderRadius: 999, + cursor: 'pointer', + bgcolor: tint('#f59e0b'), + border: `1.5px solid ${edge('#f59e0b')}`, + transition: 'border-color 0.15s, box-shadow 0.15s', + '&:hover': { borderColor: '#f59e0b', boxShadow: `0 0 0 3px ${ring('#f59e0b')}` } + }} + > + + + + + {dayjs(startdate).format('DD MMM')} – {dayjs(enddate).format('DD MMM')} + + )} + {/* Tenant */} { if (!appId) { event.preventDefault(); - OpenToast('Please select a your location first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); + OpenToast('Please select your zone first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); } }} onChange={(e, val, reason) => { @@ -1080,27 +1355,40 @@ const Deliveries = () => { setLocationValue(null); } }} - renderInput={(params) => } + renderInput={(params) => ( + + + + ) + }} + sx={pillFieldSx('#0ea5e9')} + /> + )} /> + {/* Location */} `${option.locationname} (${option.suburb})` || ''} value={locationValue} - sx={{ minWidth: 200, flex: 1 }} + PaperComponent={SoftPaper} + sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }} onOpen={(event) => { if (!appId && !tenantid) { event.preventDefault(); - OpenToast('Please select a your Location and Tenant first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); + OpenToast('Please select your Zone and Tenant first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); } else if (!tenantid) { event.preventDefault(); - OpenToast('Please select a your Tenant first!', 'warning', 3000); - setTimeout(() => { - tenantRef.current?.focus(); - }, 0); + OpenToast('Please select your Tenant first!', 'warning', 3000); + setTimeout(() => tenantRef.current?.focus(), 0); } }} onChange={(e, val, reason) => { @@ -1112,206 +1400,241 @@ const Deliveries = () => { setLocationValue(val); } }} - renderInput={(params) => } + renderInput={(params) => ( + + + + ) + }} + sx={pillFieldSx('#14b8a6')} + /> + )} /> + {/* Rider */} `${option.firstname} ${option.lastname} (${option.contactno})`} - renderInput={(params) => } + sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }} onChange={(e, value, reason) => { - if (reason === 'clear') { - setRiderid(0); - } else { - setRiderid(value.userid); - console.log('selected rider', value); - } + if (reason === 'clear') setRiderid(0); + else setRiderid(value.userid); }} onOpen={(event) => { if (!appId) { event.preventDefault(); - OpenToast('Please select a your location first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); + OpenToast('Please select your zone first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); } }} + renderInput={(params) => ( + + + + ) + }} + sx={pillFieldSx('#8b5cf6')} + /> + )} /> - {/* ============================================= || Tabs || ============================================= */} - + {/* ============================================= || Status Tabs + Search || ============================================= */} + - - - } - iconPosition="end" - /> - - } - iconPosition="end" - /> - - } - iconPosition="end" - /> + + {STATUS_TABS.map((t, idx) => { + const meta = STATUS_META[t.status]; + const Icon = meta.icon; + const active = tabvalue === idx; + const count = batchCounts[t.countKey] ?? 0; + return ( + handleChangetab(e, 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)}` + } + }} + > + + + + + {meta.label} + + + {count} + + + ); + })} + - - } - iconPosition="end" - /> - - } - iconPosition="end" - /> - - } - iconPosition="end" - /> - - - } - iconPosition="end" - /> - - - } - iconPosition="end" - /> - - - {/* ============================================= || searchOutlined | ============================================= */} - - - {/* ============================================= || MainCard || ============================================= */} - + {/* ============================================= || Search | ============================================= */} + + + + +
+ {/* ============================================= || Table || ============================================= */} + - + {(() => { + const showAction = tabstatus !== 'Cancelled' && tabstatus !== 'Delivered'; + const showSelect = tabstatus == 'Created'; + const totalCols = 15 + (showAction ? 1 : 0) + (showSelect ? 1 : 0); + return ( +
- - {tabstatus == 'Created' && ( + + {showSelect && ( 0 && deliverylist.length != filteredRows.length} @@ -1326,56 +1649,48 @@ const Deliveries = () => { /> )} - S.No - Tenant - - Order Location{' '} - - Pickup - Drop - Rider - Est. Delivery Time - Transit - Kms - Amount - Notes - Steps - Qty - COD - {tabstatus !== 'Cancelled' && tabstatus !== 'Delivered' && ( - - Action - - )} + # + Status + Tenant + Order / Location + Pickup + Drop + Rider + ETA + Transit + Kms + Amount + Notes + Step + Qty + COD + {showAction && Action} {(loading1 || countSourceIsLoading) && } {filteredRows.length == 0 && !loading1 && !countSourceLoading && ( - <> - - b.id === selectedBatch)?.label || 'this batch'}` - } - styles={{ description: { color: theme.palette.error.main } }} - /> + + + + + + + + No deliveries to show + + + {selectedBatch === 'all' + ? `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders for this filter.` + : `No ${(STATUS_META[currentStatus]?.label || tabstatus).toLowerCase()} orders in ${BATCH_OPTIONS.find((b) => b.id === selectedBatch)?.label || 'this batch'}.`} + + - + )} {filteredRows.length == 0 && countSourceLoading && ( - + Loading deliveries… @@ -1384,17 +1699,29 @@ const Deliveries = () => { )} {filteredRows.map((row, index) => { + const rowStatusMeta = STATUS_META[String(row.orderstatus || '').toLowerCase()] || { + label: row.orderstatus || '—', + color: '#94a3b8', + icon: MdHistoryToggleOff + }; + const RowStatusIcon = rowStatusMeta.icon; + const isSelected = !!deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); return ( - <> + res1.orderheaderid == row.orderheaderid) ? '#e1bee7' : '', + transition: 'background-color 0.15s', + '& td': { + borderBottom: `1px solid ${DT.divider}`, + py: { xs: 1, md: 1.5 }, + px: { xs: 1, md: 2 } + }, + backgroundColor: isSelected ? soft('#8b5cf6') : 'transparent', '&:hover': { - backgroundColor: deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid) ? '#e1bee7 !important' : '' + backgroundColor: isSelected ? soft('#8b5cf6') : DT.surfaceAlt } }} > @@ -1425,22 +1752,49 @@ const Deliveries = () => { )} - {/* {row.sno} */} - {page * rowsPerPage + index + 1} + + {String(page * rowsPerPage + index + 1).padStart(2, '0')} + + + {/* Status badge */} + + + + + + + {rowStatusMeta.label} + + {/* Tenants */} - - {row.tenantname} - - - {row.tenantsuburb} - - - - {row.applocation} - + + + {row.tenantname} + + + {row.tenantsuburb} + + + {row.applocation} + + {/* order details */} @@ -1488,172 +1842,237 @@ const Deliveries = () => { {/* pickup */} - - - - {row.pickupcustomer} + + + {row.pickupcustomer} + + {row.pickupcontactno} + + + {row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')} - {row.pickupcontactno} - - {row.pickuplocation || row.Pickupaddress.slice(0, 12) + '...'} - - + {/* drop */} - - - - {row.deliverycustomer} + + + {row.deliverycustomer} + + {row.deliverycontactno} + + + {row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')} - {row.deliverycontactno} - - - {`${row.deliverylocation || row.deliveryaddress.slice(0, 12) + '...'}`} - - - + {/* rider */} - - {row.ridername} - - {`ID: ${row.userid}`} - {row.ridercontact} + {row.ridername ? ( + + + + + + + {row.ridername} + + + ID #{row.userid} · {row.ridercontact || '—'} + + + + ) : ( + + Unassigned + + )} {/* Estimated Delivery Time */} - - {row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : 'N/A'} + + {row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : '—'} {/* Transit Minutes */} - - {row.transitminutes || 0} min - + + {row.transitminutes || 0}m + {/* kms */} - - - + + + + {row.kms || 0} km + - - + + + {row.cumulativekms || 0} km + {/* amount */} - - - + + + + ₹ {row.deliverycharges?.toFixed(2) ?? '0.00'} + - - + + + ₹ {row.deliveryamt?.toFixed(2) ?? '0.00'} + {/* notes */} - {row.notes} + {row.notes ? ( + + + {row.notes} + + + ) : ( + + )} {/* step */} {row.step ? ( - + + {row.step} + ) : ( - - — - + )} {/* qty */} - {row.Quantity ? ( - - {row.Quantity} - - ) : ( - - {' '} - {row.Quantity} - - )} + + {row.Quantity || '—'} + - {/* {cash} */} + {/* COD */} - {row.collectionamt ? ( - - {' '} - {`₹ ${row.collectionamt.toFixed(2)}`}{' '} - - ) : ( - - {' '} - {`₹ ${row.collectionamt.toFixed(2)}`}{' '} - - )} + + {row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'} + {/* Action */} {tabstatus !== 'Cancelled' && tabstatus !== 'Delivered' && ( - - + + {row.deliverytype == 'C' && ( { if (productCollapse?.orderid === row.orderid) { - setProductCollapse(null); // Collapse if already open + setProductCollapse(null); setOrderHeaderId(null); } else { - setProductCollapse(row); // Open if different - setOrderHeaderId(row.orderheaderid); // Open if different + setProductCollapse(row); + setOrderHeaderId(row.orderheaderid); } }} + sx={{ + borderRadius: 999, + bgcolor: tint('#06b6d4'), + color: '#06b6d4', + border: `1px solid ${edge('#06b6d4')}`, + '&:hover': { bgcolor: soft('#06b6d4') } + }} > - {productCollapse?.orderid === row.orderid ? : } + {productCollapse?.orderid === row.orderid ? : } )} - handleMenuOpen(e, row)} - > - - + + handleMenuOpen(e, row)} + sx={{ + borderRadius: 999, + bgcolor: tint('#6366f1'), + color: '#6366f1', + border: `1px solid ${edge('#6366f1')}`, + '&:hover': { bgcolor: soft('#6366f1') } + }} + > + + + @@ -1664,6 +2083,7 @@ const Deliveries = () => { handleMenuClose(); }} > + Notify Rider )} @@ -1679,6 +2099,7 @@ const Deliveries = () => { handleMenuClose(); }} > + Change Rider )} @@ -1697,17 +2118,19 @@ const Deliveries = () => { handleMenuClose(); }} > - Update Delivery Status + + Update Status )} {selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && ( { setCancelDeliveryOpen(true); handleMenuClose(); }} > + Cancel Delivery )} @@ -1719,230 +2142,311 @@ const Deliveries = () => { {productCollapse?.orderid === row?.orderid && ( - -
- - - S.No - Product Name - Description - - Quantity - - - Cost - - - Price - - - Tax - - - Amount - - - - - - {orderdetails?.details?.map((product, index) => ( + + + + + + Product Details + + +
+ - {index + 1} - - - {product?.productname} - - {product?.productname || 'Unnamed'} - - + # + Product + Description + Qty + Cost + Price + Tax + Amount + + + + {orderdetails?.details?.map((product, idx2) => ( + + {String(idx2 + 1).padStart(2, '0')} + + + + + {product?.productname || 'Unnamed'} + + + + {product?.productdescription || '-'} + {product?.orderqty || 0} + ₹ {product?.price || 0} + ₹ {(product?.productsumprice ?? 0).toFixed(2)} + ₹ {(product?.taxamount ?? 0).toFixed(2)} + + ₹ {(product?.productsumprice + product?.taxamount).toFixed(2) || 0} + + + ))} + + + Total Amount - {product?.productdescription || '-'} - {product?.orderqty || 0} - ₹ {product?.price || 0} - ₹ {(product?.productsumprice ?? 0).toFixed(2)} - ₹ {(product?.taxamount ?? 0).toFixed(2)} - - ₹ {(product?.productsumprice + product?.taxamount).toFixed(2) || 0} + + ₹ {orderdetails?.pricedetails?.orderamount?.toFixed(2)} - ))} - - - Total Amount : - - - ₹ {orderdetails?.pricedetails?.orderamount.toFixed(2)} - - - -
+ + +
)} - + ); })} {countSourceRows?.length != 0 && ( - +
- {countIsFetchingNext ? : countHasNext ? : 'No More Deliveries'} + {countIsFetchingNext ? : countHasNext ? : ( + + · End of list · + + )}
)} + ); + })()}
- + {/* =============================== || cancel dialog || =============================== */} - setCancelDeliveryOpen(false)} maxWidth="xs"> - - - - + setCancelDeliveryOpen(false)} + maxWidth="xs" + fullWidth + PaperProps={{ + elevation: 0, + sx: { + borderRadius: 3, + border: '1px solid', + borderColor: DT.borderSubtle, + boxShadow: DT.shadowPop, + overflow: 'hidden' + } + }} + > + + + + + - - - - Are you sure you want to cancel this delivery? + + + Cancel Delivery? + + + This action will cancel the delivery and notify the rider. Please share a brief reason. { - setCancelFeed(e.target.value); + onChange={(e) => setCancelFeed(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: DT.surfaceAlt, + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: '#ef4444' }, + '&.Mui-focused fieldset': { borderColor: '#ef4444', borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#ef4444')}` } + } }} /> - + + - - {/* =============================== || change dialog || =============================== */} + {/* =============================== || change rider dialog || =============================== */} setChangeDialogOpen(false)} maxWidth="sm" fullWidth TransitionComponent={PopupTransition} + PaperProps={{ + elevation: 0, + sx: { + borderRadius: 3, + border: '1px solid', + borderColor: DT.borderSubtle, + boxShadow: DT.shadowPop, + overflow: 'hidden' + } + }} > - - - Change Rider - + + + + + + + + + Change Rider + + + Assign this delivery to a different rider + + + - - - - `${option.firstname} ${option.lastname} | ${option.contactno}`} - renderInput={(params) => } - onChange={(e, value) => { - setSelectedRider(value); - logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None'); + + `${option.firstname} ${option.lastname} (${option.contactno})`} + onChange={(e, value) => { + setSelectedRider(value); + logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None'); + }} + renderInput={(params) => ( + + + + ) }} + sx={pillFieldSx('#8b5cf6')} /> - - + )} + /> - - + + @@ -1951,9 +2455,35 @@ const Deliveries = () => { {/* =============================== || Date filter Dialog || =============================== */} - - - Select Filter Options + setOpen(false)} + PaperProps={{ + elevation: 0, + sx: { + borderRadius: 3, + border: '1px solid', + borderColor: DT.borderSubtle, + boxShadow: DT.shadowPop, + overflow: 'hidden' + } + }} + > + + + + + + + + + Date Range + + + Pick a window to filter the deliveries + + + { ]} /> - - {/* =============================== || Update Delivery Dialog || =============================== */} - - - Update Delivery Status + + + + + + + + + + Update Delivery Status + + + {currentorder?.orderid ? `Order #${currentorder.orderid}` : 'Modify delivery details'} + + + - - - - - - Kms + + + {[ + { label: 'KMs', value: kms, set: (v) => setKms(v), color: '#ef4444' }, + { label: 'Actual KMs', value: cumulativekms, set: (v) => setCumulativeKms(+v), color: '#10b981' }, + { label: 'Delivery Latitude', value: deliverylat, set: (v) => setDeliverylat(v), color: '#06b6d4' }, + { label: 'Delivery Longitude', value: deliverylong, set: (v) => setDeliverylong(v), color: '#06b6d4' } + ].map((f) => ( + + + {f.label} + { - setKms(e.target.value); - console.log(e); - }} - /> - - - Actual Kms - setCumulativeKms(+e.target.value)} fullWidth /> - - - Delivery lat - { - setDeliverylat(e.target.value); - }} - /> - - - Delivery Long - { - setDeliverylong(e.target.value); - }} - fullWidth - /> - - - - Amount - - setDeliveryamount(+e.target.value)} /> - - - Status - { - setUpdateStatus(e.target.value); - }} - > - Pending - Accepted - Started - Arrived - Delivered - Cancelled - - - - Notes - setNotes(e.target.value)} /> - - - - - - - - - {' '} - - + /> + + ))} + + + Amount + + setDeliveryamount(+e.target.value)} + sx={{ + mt: 0.5, + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: '#fff', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: '#10b981' }, + '&.Mui-focused fieldset': { borderColor: '#10b981', borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#10b981')}` } + } + }} + /> + + + + Status + + setUpdateStatus(e.target.value)} + SelectProps={{ MenuProps: { PaperProps: { sx: { borderRadius: 2, mt: 0.5, boxShadow: DT.shadowPop } } } }} + sx={{ + mt: 0.5, + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: '#fff', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: '#6366f1' }, + '&.Mui-focused fieldset': { borderColor: '#6366f1', borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#6366f1')}` } + } + }} + > + {['pending','accepted','started','arrived','delivered','cancelled'].map((s) => { + const m = STATUS_META[s] || { label: s, color: '#6366f1', icon: MdHistoryToggleOff }; + const Ic = m.icon; + return ( + + + {m.label} + + ); + })} + + + + + Notes + + setNotes(e.target.value)} + sx={{ + mt: 0.5, + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: '#fff', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: '#6366f1' }, + '&.Mui-focused fieldset': { borderColor: '#6366f1', borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#6366f1')}` } + } + }} + /> + + + + + + diff --git a/src/pages/nearle/dispatch/CLAUDE.md b/src/pages/nearle/dispatch/CLAUDE.md new file mode 100644 index 0000000..ad96718 --- /dev/null +++ b/src/pages/nearle/dispatch/CLAUDE.md @@ -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** (8–9 AM, 12 PM–4 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 `` 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. diff --git a/src/pages/nearle/invoice/invoice.js b/src/pages/nearle/invoice/invoice.js index ab37afe..7397035 100644 --- a/src/pages/nearle/invoice/invoice.js +++ b/src/pages/nearle/invoice/invoice.js @@ -1,21 +1,18 @@ -import React from 'react'; -import { useState } from 'react'; -import TitleCard from 'pages/titleCard'; -import HoverSocialCard from 'components/cards/statistics/HoverSocialCard'; -import { useTheme } from '@mui/material/styles'; +import React, { useState, useMemo } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; var utc = require('dayjs/plugin/utc'); dayjs.extend(utc); -import { DashboardOutlined } from '@ant-design/icons'; -import { HiHandThumbDown, HiHandThumbUp } from 'react-icons/hi2'; -import LoadingIcons from 'react-loading-icons'; -import { useQuery } from '@tanstack/react-query'; -import { fetchinvoiceinsight, fetchdeliverylist } from 'pages/api/api'; -import VisibilityIcon from '@mui/icons-material/Visibility'; + import { - Grid, + Avatar, + Box, Divider, + Grid, + IconButton, + Paper, + Stack, Table, TableBody, TableCell, @@ -23,393 +20,776 @@ import { TableHead, TablePagination, TableRow, - Tabs, - Tab, - Typography, - Box, - IconButton, Tooltip, - Stack + Typography } from '@mui/material'; +import { + MdReceiptLong, + MdDashboard, + MdHourglassEmpty, + MdReportProblem, + MdCheckCircle, + MdGroups, + MdEventNote, + MdCurrencyRupee, + MdVisibility, + MdInventory2 +} from 'react-icons/md'; + +import { fetchinvoiceinsight, fetchdeliverylist } from 'pages/api/api'; import Loader from 'components/Loader'; -import MainCard from 'components/MainCard'; -import { Empty } from 'antd'; -import DateFilterDialog from 'components/DateFilterDialog'; +import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; -const columns = [ - { id: 'sno', label: 'sno' }, - { id: 'client', label: 'client' }, - { id: 'invoiceid', label: 'Invoice Id' }, +// ============================================================================ +// Design tokens — shared with deliveries / tenants / customers / pricing / +// orders-details / riders-summary pages. +// ============================================================================ +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'); - { - id: 'invoice date', - label: 'invoice date', - align: 'left' - // format: (value) => value.toLocaleString("en-US"), - }, - { - id: 'due date', - label: 'due date', - align: 'left' - // format: (value) => value.toLocaleString("en-US"), - }, - { - id: 'itemcount', - label: 'Count', - align: 'left' - // format: (value) => typeof value === "number" && value.toFixed(2), - }, +const BRAND = '#662582'; +const BRAND_LIGHT = '#9255AB'; - { id: 'amount', label: 'amount', align: 'right' }, - { id: 'action', label: 'action', align: 'center' } -]; +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); -// table data +// Bill status → tab visual meta (semantic colours; brand purple reserved for "All"). +const STATUS_META = { + 0: { key: 'all', label: 'All', color: BRAND, icon: MdDashboard, countKey: 'totalcount' }, + 1: { key: 'open', label: 'Open', color: '#ef4444', icon: MdHourglassEmpty, countKey: 'pendingcount' }, + 2: { key: 'overdue', label: 'Overdue', color: '#f59e0b', icon: MdReportProblem, countKey: 'overduecount' }, + 3: { key: 'paid', label: 'Paid', color: '#10b981', icon: MdCheckCircle, countKey: 'paidcount' } +}; +const STATUS_TABS = [0, 1, 2, 3]; -function CustomTabPanel(props) { - const { children, value, index, ...other } = props; - - return ( - - ); -} - -function a11yProps(index) { - return { - id: `simple-tab-${index}`, - 'aria-controls': `simple-tabpanel-${index}` - }; -} function formatNumberToRupees(value) { return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 - }).format(value); + }).format(Number(value) || 0); } + const Invoice = () => { - const theme = useTheme(); const navigate = useNavigate(); - const [page, setPage] = React.useState(0); - const [rowsPerPage, setRowsPerPage] = React.useState(10); - const [value, setValue] = React.useState(0); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); const [billStatus, setBillStatus] = useState(0); - let [isloader, setIsLoader] = useState(false); - const [open, setOpen] = useState(false); + const [isloader, setIsLoader] = useState(false); + const [searchword, setSearchword] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(+event?.target?.value); + const handleDebouncedSearch = React.useCallback((val) => { + setDebouncedSearch(val); setPage(0); - }; + }, []); - const handleChange = (event, newValue) => { - setValue(newValue); - }; - - /* ============================================= || fetchinvoiceinsight| ============================================= */ + // ============================================= || fetchinvoiceinsight || const { data: insightdata, isLoading: isInsightLoading, isError: isInsightError, error: insightError } = useQuery({ - queryKey: ['invoiceInsight'], // Meaningful query key + queryKey: ['invoiceInsight'], queryFn: fetchinvoiceinsight, - refetchInterval: 300000 // Auto-fetch every 5 minutes + refetchInterval: 300000 }); - /* ============================================= || fetchdeliverylist| ============================================= */ + // ============================================= || fetchdeliverylist || + // NOTE: queryKey shape MUST stay `[billStatus]` — `fetchdeliverylist` + // destructures `const [billStatus] = queryKey`. const { data: deliveryList, isLoading: isDeliveryLoading, isError: isDeliveryError, error: deliveryError } = useQuery({ - queryKey: [billStatus], // Include billStatus in query key + queryKey: [billStatus], queryFn: fetchdeliverylist, - refetchInterval: 300000 // Auto-fetch every 5 minutes + refetchInterval: 300000 }); + const isLoading = isInsightLoading || isDeliveryLoading; const isError = isInsightError || isDeliveryError; const errorMessage = insightError?.message || deliveryError?.message; + // Client-side filter across tenant name, contact person, invoice number. + const filteredList = useMemo(() => { + if (!deliveryList) return []; + if (!debouncedSearch) return deliveryList; + const q = debouncedSearch.toLowerCase().trim(); + return deliveryList.filter((row) => + [row.tenantname, row.contactperson, String(row.invoiceno)] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(q)) + ); + }, [deliveryList, debouncedSearch]); + + const activePage = useMemo(() => { + const maxPage = Math.max(0, Math.ceil(filteredList.length / rowsPerPage) - 1); + return Math.min(page, maxPage); + }, [filteredList.length, page, rowsPerPage]); + + // Keep page state in sync when filters or data updates shrink the list below current page + React.useEffect(() => { + if (page !== activePage) { + setPage(activePage); + } + }, [page, activePage]); + + const pagedList = useMemo( + () => filteredList.slice(activePage * rowsPerPage, activePage * rowsPerPage + rowsPerPage), + [filteredList, activePage, rowsPerPage] + ); + + const grandTotal = useMemo( + () => filteredList.reduce((sum, r) => sum + (Number(r.totalamount) || 0), 0), + [filteredList] + ); + + const pageTotal = useMemo( + () => pagedList.reduce((sum, r) => sum + (Number(r.totalamount) || 0), 0), + [pagedList] + ); + + const handleChangePage = (event, newPage) => setPage(newPage); + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(+event?.target?.value); + setPage(0); + }; + if (isError) { return errorMessage; } + const KPI_META = [ + { idx: 0, label: 'All Invoices', color: BRAND, icon: MdDashboard, value: insightdata?.totalcount ?? 0 }, + { idx: 1, label: 'Open', color: '#ef4444', icon: MdHourglassEmpty, value: insightdata?.pendingcount ?? 0 }, + { idx: 2, label: 'Overdue', color: '#f59e0b', icon: MdReportProblem, value: insightdata?.overduecount ?? 0 }, + { idx: 3, label: 'Paid', color: '#10b981', icon: MdCheckCircle, value: insightdata?.paidcount ?? 0 } + ]; + + const activeMeta = STATUS_META[billStatus]; + return ( <> {(isloader || isLoading) && } - {/* ============================================= || TitleCard || ============================================= */} - - {/* ============================================= || Hover Social Card || ============================================= */} - - { - setValue(0); - setBillStatus(0); - }} + {/* ============================================= || Header || ============================================= */} + + - - - { - setValue(1); - setBillStatus(1); - }} - > - - - { - setValue(2); - setBillStatus(2); - }} - > - - - { - setValue(3); - setBillStatus(3); - }} - > - - - - {/* ============================================= || Invoice Table || ============================================= */} - - - - - { - setValue(0); - setBillStatus(0); + + + + + + - { - setValue(1); - setBillStatus(1); - }} - /> - { - setValue(2); - setBillStatus(2); - }} - /> - { - setValue(3); - setBillStatus(3); - }} - /> - - - - - - - + Invoices + + + + + Live · Viewing {activeMeta.label.toLowerCase()} invoices + + + + + - - + + Grand Total + + + {formatNumberToRupees(grandTotal)} + + + + + + {/* ============================================= || KPI Cards (clickable filter) || ============================================= */} + + {KPI_META.map((item) => { + const Icon = item.icon; + const active = billStatus === item.idx; + return ( + + { + setBillStatus(item.idx); + setPage(0); + }} sx={{ - '& th': { - borderTop: `1px solid ${theme.palette.divider}`, - borderBottom: `2px solid ${theme.palette.divider} !important` + position: 'relative', + overflow: 'hidden', + cursor: 'pointer', + p: { xs: 1.25, sm: 1.75, md: 2.25 }, + borderRadius: DT.radiusCard / 8, + border: '1px solid', + borderColor: active ? edge(item.color) : DT.borderSubtle, + background: active ? tint(item.color) : '#fff', + boxShadow: active ? `0 0 0 3px ${ring(item.color)}` : 'none', + transition: 'transform 0.2s, box-shadow 0.2s, border-color 0.2s, background 0.2s', + '&:hover': { + transform: 'translateY(-3px)', + boxShadow: DT.shadowMd, + borderColor: edge(item.color) } }} > - - {columns.map((column) => ( - + + + - {column.label} - - ))} - - - - {isDeliveryLoading && } - {deliveryList?.length == 0 ? ( - - - - - - ) : ( - deliveryList?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((item, index) => ( - - {page * rowsPerPage + index + 1} - - {item.tenantname} + {item.label} + + + {item.value} + + + + + + + + + ); + })} + - - {item.contactperson}{' '} + {/* ============================================= || Status Tabs + Search || ============================================= */} + + + + {STATUS_TABS.map((idx) => { + const meta = STATUS_META[idx]; + const Icon = meta.icon; + const active = billStatus === idx; + const count = insightdata?.[meta.countKey] ?? 0; + return ( + { + setBillStatus(idx); + setPage(0); + }} + 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)}` + } + }} + > + + + + + {meta.label} + + + {count} + + + ); + })} + + + + + + + + + {/* ============================================= || Table || ============================================= */} + + +
+ + + # + Client + Invoice ID + Invoice Date + Due Date + Items + Amount + Action + + + + + {isDeliveryLoading && } + {!isDeliveryLoading && pagedList.length === 0 ? ( + + + + + + + + No invoices to show + + + {searchword + ? 'Try a different keyword.' + : `No ${activeMeta.label.toLowerCase()} invoices for this filter.`} + + + + + ) : ( + pagedList.map((item, index) => { + const overdue = billStatus === 2 || (item.duedate && dayjs(item.duedate).isBefore(dayjs(), 'day') && billStatus !== 3); + return ( + + + + {String(activePage * rowsPerPage + index + 1).padStart(2, '0')} + - {item.invoiceno} - - - - {dayjs(item.transactiondate).format('DD-MM-YYYY')} -
- {dayjs(item.transactiondate).utc().format('hh:mm a')} -
-
- - - {dayjs(item.duedate).format('DD-MM-YYYY')} -
- {dayjs(item.duedate).utc().format('hh:mm a')} -
+ + + + + + + {item.tenantname || '—'} + + {item.contactperson && ( + + {item.contactperson} + + )} + +
- {' '} - {item.itemcount} + + {item.invoiceno || '—'} + - - {formatNumberToRupees(item.totalamount)} + + + + + + + {item.transactiondate ? dayjs(item.transactiondate).format('DD/MM/YYYY') : '—'} + + + + {item.transactiondate ? dayjs(item.transactiondate).utc().format('hh:mm A') : ''} + + + + + + + + + {item.duedate ? dayjs(item.duedate).format('DD/MM/YYYY') : '—'} + + + + {item.duedate ? dayjs(item.duedate).utc().format('hh:mm A') : ''} + + + + - + + {item.itemcount ?? 0} + + + + + + + {formatNumberToRupees(item.totalamount).replace('₹', '').trim()} + + + + + { setIsLoader(true); - console.log('selected', item); - setTimeout(() => { setIsLoader(false); - - navigate('/nearle/invoice/preview', { - state: item - }); + navigate('/nearle/invoice/preview', { state: item }); }, 500); }} + sx={{ + bgcolor: soft(BRAND), + color: BRAND, + border: `1px solid ${edge(BRAND)}`, + '&:hover': { bgcolor: BRAND, color: '#fff' } + }} > - +
- )) - )} -
-
-
-
+ ); + }) + )} + + + - {/* table pagination */} - -
- {/* ============================================= || Date Dialog|| ============================================= */} - setOpen(false)} - onSelect={(range) => { - setStartdate(range.startDate); - setEnddate(range.endDate); - setDatestatus(range.label); - console.log('Selected Date Range:', range); - }} - /> + + + Page total · {formatNumberToRupees(pageTotal)} + + + + diff --git a/src/pages/nearle/orders/CLAUDE.md b/src/pages/nearle/orders/CLAUDE.md new file mode 100644 index 0000000..08c4486 --- /dev/null +++ b/src/pages/nearle/orders/CLAUDE.md @@ -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. diff --git a/src/pages/nearle/orders/createorder1.js b/src/pages/nearle/orders/createorder1.js index 503fd10..8f6a549 100644 --- a/src/pages/nearle/orders/createorder1.js +++ b/src/pages/nearle/orders/createorder1.js @@ -157,9 +157,9 @@ const OrderMap = ({ startPoint, endPoint, appLocaLat, appLocaLng }) => { {hasPick && } {hasDrop && } {routePoints.length > 0 ? ( - + ) : ( - hasPick && hasDrop && + hasPick && hasDrop && )} @@ -1490,7 +1490,7 @@ const Createorder1 = () => { InputProps={{ startAdornment: ( - + ), endAdornment: ( @@ -1526,7 +1526,7 @@ const Createorder1 = () => { InputProps={{ startAdornment: ( - + ), endAdornment: ( @@ -2268,7 +2268,7 @@ const Createorder1 = () => { {/* Map Card */} - + Live Route Preview
@@ -2446,7 +2446,7 @@ const Createorder1 = () => { } }} > - + {`Select Saved Address (${pickordrop === 1 ? 'Pickup' : 'Drop'})`} diff --git a/src/pages/nearle/orders/orders.js b/src/pages/nearle/orders/orders.js index 4fd0d08..7162080 100644 --- a/src/pages/nearle/orders/orders.js +++ b/src/pages/nearle/orders/orders.js @@ -13,7 +13,24 @@ import Loader from 'components/Loader'; import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material'; import { PiMapPinLineDuotone } from 'react-icons/pi'; -import { MdOutlineDateRange, MdPersonOff, MdEventBusy } from 'react-icons/md'; +import { + MdOutlineDateRange, + MdPersonOff, + MdEventBusy, + MdLocalShipping, + MdHourglassEmpty, + MdCheckCircle, + MdCancel, + MdMyLocation, + MdGroups, + MdPlace, + MdStraighten, + MdCurrencyRupee, + MdInventory2, + MdReceiptLong, + MdHistoryToggleOff, + MdCalendarMonth +} from 'react-icons/md'; import { VscArchive } from 'react-icons/vsc'; import DateFilterDialog from 'components/DateFilterDialog'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; @@ -71,7 +88,8 @@ import { AccordionDetails, AccordionSummary, Accordion, - Paper + Paper, + Box } from '@mui/material'; import { @@ -97,6 +115,93 @@ import CSVExport from 'components/third-party/ReactTable'; import Dispatch from '../dispatch/Dispatch'; // import usePreventReload from 'hooks/usePreventReload'; +// ============================================================================ +// Design tokens — shared with deliveries / tenants / customers / pricing / +// invoice / orders-details / riders-summary pages. +// ============================================================================ +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 dtA = (c, suffix) => `${c}${suffix}`; +const dtTint = (c) => dtA(c, '08'); +const dtSoft = (c) => dtA(c, '18'); +const dtRing = (c) => dtA(c, '26'); +const dtEdge = (c) => dtA(c, '55'); + +const BRAND = '#662582'; +const BRAND_LIGHT = '#9255AB'; + +const SoftPaper = (props) => ( + +); + +const DTAccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + +const pillFieldSx = (color) => ({ + '& .MuiOutlinedInput-root': { + borderRadius: DT.radiusPill + 'px', + bgcolor: dtTint(color), + fontWeight: 600, + '& fieldset': { borderColor: dtEdge(color), borderWidth: 1.5 }, + '&:hover fieldset': { borderColor: color }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${dtRing(color)}` }, + '&.Mui-focused fieldset': { borderColor: color, borderWidth: 2 } + } +}); + +// Order row status meta — semantic palette for the per-row status badge. +const ROW_STATUS_META = { + created: { label: 'Created', color: '#0ea5e9', icon: MdLocalShipping }, + pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty }, + processing:{ label: 'Processing',color: '#0ea5e9', icon: MdHistoryToggleOff }, + modified: { label: 'Modified', color: '#06b6d4', icon: MdHistoryToggleOff }, + confirmed: { label: 'Confirmed', color: '#10b981', icon: MdCheckCircle }, + accepted: { label: 'Accepted', color: '#6366f1', icon: MdCheckCircle }, + delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle }, + cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel } +}; + +// Tab pill meta — controls the 4 top-level status tabs. +const ORDERS_STATUS_TABS = [ + { idx: 0, status: 'created', label: 'Created', color: BRAND, icon: MdLocalShipping, countKey: 'created' }, + { idx: 1, status: 'pending', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty, countKey: 'pending' }, + { idx: 2, status: 'delivered', label: 'Delivered', color: '#10b981', icon: MdCheckCircle, countKey: 'delivered' }, + { idx: 3, status: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel, countKey: 'cancelled' } +]; + const Orders = () => { const theme = useTheme(); const navigate = useNavigate(); @@ -821,320 +926,522 @@ const Orders = () => { /> )} - {/* ============================================= || titlecard | ============================================= */} - - - - {/* ============================================= || Hovercard | ============================================= */} - - {[ - { - label: 'Created orders', - value: percentageData?.created, - percentage: percentageData?.percentage1, - color: theme.palette.info.main - }, - { - label: 'Pending orders', - value: percentageData?.uncoveredOrders, - percentage: percentageData?.percentage2, - color: theme.palette.warning.main - }, - { - label: 'Delivered orders', - value: percentageData?.coveredOrders, - percentage: percentageData?.percentage3, - color: theme.palette.success.main - }, - { - label: 'Cancelled Orders', - value: percentageData?.cancelled, - percentage: percentageData?.percentage4, - color: theme.palette.error.main - } - ].map((item, index) => ( - - : item.value} - percentage={item.percentage?.toString()} - color={item.color} - /> - - ))} - - {/* ============================================= || orderFilter | ============================================= */} - - {/* Order Status Chips */} - - {startdate && enddate ? ( - - } - label={`Orders-${datestatus}`} - color="error" - variant="combined" - /> - - - - - } - label={ - - {`${dayjs(startdate).format('DD/MM/YYYY')} - ${dayjs(enddate).format('DD/MM/YYYY')}`} - - } - color="warning" - variant="combined" - onClick={() => setOpen(true)} - sx={{ cursor: 'pointer' }} - /> - - - - - } - label={locaName} - color="info" - variant="combined" - /> - - ) : ( - - )} - - - - {/* ==================================================== || Tenant Autocomplete || ==================================================== */} - { - if (!appId) { - event.preventDefault(); - OpenToast('Please select location first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); - } - }} - onChange={(e, val, reason) => { - if (reason === 'clear') { - setTenantid(0); - setTenantValue(null); - setLocationid(0); - setLocationValue(null); - } else { - setTenantid(val?.tenantid || 0); - setTenantValue(val); - setLocationid(0); - setLocationValue(null); - } - }} - renderInput={(params) => } - /> - {/* ==================================================== || Location Autocomplete || ==================================================== */} - `${option.locationname} (${option.suburb})` || ''} - value={locationValue} - sx={{ minWidth: 250, maxWidth: 600, flex: 1 }} - onOpen={(event) => { - if (!appId && !tenantid) { - event.preventDefault(); - OpenToast('Please select a your Location and Tenant first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); - } else if (!tenantid) { - event.preventDefault(); - OpenToast('Please select Tenant first!', 'warning', 3000); - setTimeout(() => { - tenantRef.current?.focus(); - }, 0); - } - }} - onChange={(e, val, reason) => { - if (reason === 'clear') { - setLocationid(0); - setLocationValue(null); - } else { - setLocationid(val.locationid || 0); - setLocationValue(val); - } - }} - renderInput={(params) => } - /> - - {/* ============================================= || filter Dialog | ============================================= */} - setOpen(false)} - onSelect={(range) => { - setStartdate(range.startDate); - setEnddate(range.endDate); - setDatestatus(range.label); - console.log('Selected Date Range:', range); - }} - /> - - - {/* ============================================= || Tabs || ============================================= */} - - - - } - iconPosition="end" + + + + + + + Orders + + + + + Live · {locaName || 'All Zones'} · {datestatus} + + + + + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} /> - - } - iconPosition="end" - /> - - } - iconPosition="end" - /> - - } - iconPosition="end" - /> - - + + + {/* ============================================= || KPI Cards || ============================================= */} + + {[ + { key: 'created', label: 'Created Orders', color: '#0ea5e9', icon: MdLocalShipping, value: percentageData?.created, percentage: percentageData?.percentage1 }, + { key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdHourglassEmpty, value: percentageData?.uncoveredOrders, percentage: percentageData?.percentage2 }, + { key: 'delivered', label: 'Delivered Orders', color: '#10b981', icon: MdCheckCircle, value: percentageData?.coveredOrders, percentage: percentageData?.percentage3 }, + { key: 'cancelled', label: 'Cancelled Orders', color: '#ef4444', icon: MdCancel, value: percentageData?.cancelled, percentage: percentageData?.percentage4 } + ].map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + + {fetchpercentageIsLoading ? : (item.value ?? 0)} + + {item.percentage != null && ( + + {item.percentage}% + + )} + + + + + + + + ); + })} + + + {/* ============================================= || Filter Bar (tenant, location, date) || ============================================= */} + + + + option?.tenantname || ''} + PaperComponent={SoftPaper} + onOpen={(event) => { + if (!appId) { + event.preventDefault(); + OpenToast('Please select location first!', 'warning', 3000); + setTimeout(() => { + locationRef.current?.focus(); + }, 0); + } + }} + onChange={(e, val, reason) => { + if (reason === 'clear') { + setTenantid(0); + setTenantValue(null); + setLocationid(0); + setLocationValue(null); + } else { + setTenantid(val?.tenantid || 0); + setTenantValue(val); + setLocationid(0); + setLocationValue(null); + } + }} + renderInput={(params) => ( + + + + + + ) + }} + /> + )} + /> + + + + (option ? `${option.locationname} (${option.suburb})` : '')} + value={locationValue} + PaperComponent={SoftPaper} + onOpen={(event) => { + if (!appId && !tenantid) { + event.preventDefault(); + OpenToast('Please select a your Location and Tenant first!', 'warning', 3000); + setTimeout(() => { + locationRef.current?.focus(); + }, 0); + } else if (!tenantid) { + event.preventDefault(); + OpenToast('Please select Tenant first!', 'warning', 3000); + setTimeout(() => { + tenantRef.current?.focus(); + }, 0); + } + }} + onChange={(e, val, reason) => { + if (reason === 'clear') { + setLocationid(0); + setLocationValue(null); + } else { + setLocationid(val.locationid || 0); + setLocationValue(val); + } + }} + renderInput={(params) => ( + + + + + + ) + }} + /> + )} + /> + + + + + + setOpen(true)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + px: 1.25, + py: 0.75, + borderRadius: 999, + cursor: 'pointer', + bgcolor: dtTint('#f59e0b'), + border: `1.5px solid ${dtEdge('#f59e0b')}`, + color: '#f59e0b', + fontWeight: 800, + fontSize: 12, + transition: 'all 0.18s', + '&:hover': { borderColor: '#f59e0b', boxShadow: `0 0 0 3px ${dtRing('#f59e0b')}` } + }} + > + + {dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')} + + + + {datestatus} + + + + + {/* ============================================= || filter Dialog | ============================================= */} + setOpen(false)} + onSelect={(range) => { + setStartdate(range.startDate); + setEnddate(range.endDate); + setDatestatus(range.label); + console.log('Selected Date Range:', range); + }} /> - + + + {/* ============================================= || Status Tabs + Search || ============================================= */} + + + + {ORDERS_STATUS_TABS.map((t) => { + const Icon = t.icon; + const active = tabvalue === t.idx; + const count = ordersCountData?.[t.countKey] ?? 0; + return ( + handleChangetab(e, t.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 ? t.color : dtEdge(t.color)}`, + bgcolor: active ? t.color : dtTint(t.color), + color: active ? '#fff' : t.color, + fontWeight: 700, + boxShadow: active ? `0 6px 18px ${dtRing(t.color)}` : 'none', + transition: 'all 0.18s', + '&:hover': { + borderColor: t.color, + boxShadow: active ? `0 6px 18px ${dtRing(t.color)}` : `0 0 0 3px ${dtRing(t.color)}` + } + }} + > + + + + + {t.label} + + + {count} + + + ); + })} + + + + + + + {/* ============================================= || Table || ============================================= */} - + - +
- + {currentStatus == 'created' && ( - + 0 && deliverylist.length !== rows?.length} checked={deliverylist.length === rows?.length} onChange={(e) => { @@ -1156,125 +1463,34 @@ const Orders = () => { /> )} - - #{' '} - - - Tenant{' '} - - - {' '} - Location - - - {' '} - Pickup{' '} - - - {' '} - Drop{' '} - - - {' '} - QTY{' '} - - - COD - - - KMS - - - Charges - - - {' '} - Notes{' '} - - - status{' '} - - {currentStatus == 'created' && ( - - Actions - - )} + # + Tenant + Location + Pickup + Drop + QTY + COD + KMS + Charges + Notes + Status + {currentStatus == 'created' && Actions} {rows?.length === 0 && !fetchOrdersIsLoading && ( - - - + + + + + + + No {currentStatus} orders + + + {searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'} + @@ -1304,21 +1520,23 @@ const Orders = () => { tabIndex={-1} sx={{ cursor: 'pointer', - backgroundColor: isItemSelected ? '#e1bee7' : '', + transition: 'background-color 0.15s', + backgroundColor: isItemSelected ? dtTint(BRAND) : 'transparent', + '& td': { + borderBottom: `1px solid ${DT.divider}`, + py: { xs: 1, md: 1.25 }, + px: { xs: 1, md: 1.5 } + }, '&:hover': { - backgroundColor: isItemSelected ? '#e1bee7 !important' : '' + backgroundColor: isItemSelected ? dtSoft(BRAND) + ' !important' : DT.surfaceAlt } }} > {currentStatus === 'created' && ( @@ -1460,25 +1678,34 @@ const Orders = () => { {/* Order Status */} - + {(() => { + const meta = ROW_STATUS_META[String(row.orderstatus || '').toLowerCase()] || { + label: row.orderstatus || '—', + color: BRAND, + icon: MdHistoryToggleOff + }; + const StatusIcon = meta.icon; + return ( + + {meta.label} + + ); + })()} {/* Actions */} @@ -1526,9 +1753,17 @@ const Orders = () => { })} {rows?.length != 0 && ( - +
- {isFetchingNextPage ? : hasNextPage ? : 'No More Orders'} + {isFetchingNextPage ? ( + + ) : hasNextPage ? ( + + ) : ( + + No more orders + + )}
@@ -1536,7 +1771,7 @@ const Orders = () => {
-
+ {/* ============================================= || Orders Preview Dialog | ============================================= */} { diff --git a/src/pages/nearle/reports/ordersDetails.js b/src/pages/nearle/reports/ordersDetails.js index fa2f4a9..1337199 100644 --- a/src/pages/nearle/reports/ordersDetails.js +++ b/src/pages/nearle/reports/ordersDetails.js @@ -1,44 +1,66 @@ import { React, useState, useEffect, useRef } from 'react'; -import TitleCard from 'pages/titleCard'; import axios from 'axios'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { Empty } from 'antd'; -import { useTheme } from '@mui/material/styles'; -import MapWithRoute from './mapWithRoute'; -import { FaCircleCheck } from 'react-icons/fa6'; -import CircularLoader from 'components/CircularLoader'; -import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; // material-ui import { + Avatar, + Backdrop, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Divider, + Grid, + IconButton, + List, + ListItem, + Paper, + Skeleton, + Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Dialog, - Typography, - Stack, - Tooltip, - Chip, - Autocomplete, - Badge, TextField, - Backdrop, - Button, - DialogContent, - DialogActions, - List, - ListItem, - DialogTitle, - Skeleton + Tooltip, + Typography, + Autocomplete } from '@mui/material'; -import { CloseCircleFilled, EnvironmentFilled, EnvironmentOutlined } from '@ant-design/icons'; -import { CalendarMonth } from '@mui/icons-material'; +import { + MdAssignment, + MdMyLocation, + MdGroups, + MdPlace, + MdDirectionsBike, + MdCalendarMonth, + MdFileDownload, + MdHourglassEmpty, + MdPersonPin, + MdLocationOn, + MdInventory2, + MdRoute, + MdSkipNext, + MdCheckCircle, + MdCancel, + MdList, + MdLocalShipping, + MdStraighten, + MdCurrencyRupee, + MdMap, + MdNoteAlt, + MdClose +} from 'react-icons/md'; +import { FaCircleCheck } from 'react-icons/fa6'; + +import MapWithRoute from './mapWithRoute'; +import CircularLoader from 'components/CircularLoader'; import { fetchDeliveries, getriderbydelivery, gettenantlocations, getTenants } from 'pages/api/api'; -import MainCard from 'components/MainCard'; import { CSVExport } from 'components/third-party/ReactTable'; import Loader from 'components/Loader'; import { enqueueSnackbar } from 'notistack'; @@ -60,100 +82,143 @@ const opentoast = (message, variant, time) => { }); }; -const headCells = [ - { - id: 'sno', - disablePadding: false, - label: '#' - }, - { - id: 'map', - disablePadding: false, - label: '^' - }, - { - id: 'tenantname', - disablePadding: false, - label: 'Client' - }, +// ============================================================================ +// Design tokens — shared with deliveries / tenants / customers / pricing pages. +// ============================================================================ +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'); - { - id: 'Pickup loco', - disablePadding: false, - label: 'Pickup' - }, - { - id: 'Delivery loco', - disablePadding: false, - label: 'Drop' - }, - { - id: 'order status', - disablePadding: false, - label: 'status' - }, - { - id: 'assigned', - disablePadding: false, - label: 'assigned' - }, - { - id: 'Accepted', - disablePadding: false, - label: 'Accepted' - }, - { - id: 'arrived', - disablePadding: false, - label: 'arrived' - }, - { - id: 'picked', - disablePadding: false, - label: 'picked' - }, - { - id: 'active', - disablePadding: false, - label: 'active' - }, +const BRAND = '#662582'; +const BRAND_LIGHT = '#9255AB'; - { - id: 'Delivered', - disablePadding: false, - label: 'Delivered' - }, - { - id: 'cancelled', - disablePadding: false, - label: 'cancelled' - }, +const SoftPaper = (props) => ( + +); - { - id: ' notes', - disablePadding: false, - label: 'NOTES' - }, +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); - { - id: 'kms', - disablePadding: false, - label: 'KMS', - numeric: 'center' - }, - - { - id: 'charges', - numeric: 'center', - label: 'Charges', - disablePadding: false +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 } } -]; +}); -// ==============================|| MUI TABLE - ENHANCED ||============================== // +// Status visual meta — semantic colours, NOT brand. Each lifecycle state has +// its own colour so operators can recognise it at a glance. +const STATUS_META = { + all: { label: 'All', color: BRAND, icon: MdList }, + pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty }, + accepted: { label: 'Accepted', color: '#6366f1', icon: MdPersonPin }, + arrived: { label: 'Arrived', color: '#06b6d4', icon: MdLocationOn }, + picked: { label: 'Picked', color: '#8b5cf6', icon: MdInventory2 }, + active: { label: 'Active', color: '#14b8a6', icon: MdRoute }, + delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle }, + skipped: { label: 'Skipped', color: '#f97316', icon: MdSkipNext }, + cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel } +}; + +const STATUS_TABS = ['all', 'pending', 'accepted', 'arrived', 'picked', 'active', 'delivered', 'skipped', 'cancelled']; + +// Soft pill used for metric cells (km, charges) inside the table. +const MetricPill = ({ color, icon, label, tooltip }) => ( + + + {icon} + {label} + + +); + +// Stamp cell — date + time stack with skeleton fallback for empty timestamps. +const StampCell = ({ value, formatDate, formatTime, success }) => { + if (!value) { + return ( + + + + + ); + } + return ( + + + {formatDate(value)} + + + + {formatTime(value)} + + {success && } + + + ); +}; + +// ==============================|| Orders Details ||============================== // export default function OrdersDetails() { - const theme = useTheme(); const loadMoreRef = useRef(); const containerRef = useRef(); const locationRef = useRef(null); @@ -184,7 +249,6 @@ export default function OrdersDetails() { let [activeLenght, setActiveLenght] = useState(0); let [arrivesLenght, setArrivedLenght] = useState(0); let [skippedLenght, setSkippedLenght] = useState(0); - const [statusCount, setStatusCount] = useState(0); const [currentStatus, setCurrentStatus] = useState('All'); const [locationid, setLocationid] = useState(0); const [tenantid, setTenantid] = useState(0); @@ -195,19 +259,20 @@ export default function OrdersDetails() { const [reportDialog, setReportDialog] = useState(false); const [logsLoading, setLogsLoading] = useState(false); - const status = [ - { id: 0, status: 'All', statusLow: 'All', count: total }, - { id: 1, status: 'Pending', statusLow: 'pending', count: pendingLenght }, - { id: 2, status: 'Accepted', statusLow: 'accepted', count: assignLenght }, - { id: 3, status: 'Arrived', statusLow: 'arrived', count: arrivesLenght }, - { id: 4, status: 'Picked', statusLow: 'picked', count: pickedLenght }, - { id: 5, status: 'Active', statusLow: 'active', count: activeLenght }, - { id: 6, status: 'Delivered', statusLow: 'delivered', count: deliveredLenght }, - { id: 7, status: 'Skipped', statusLow: 'skipped', count: skippedLenght }, - { id: 8, status: 'Cancelled', statusLow: 'cancelled', count: cancelLenght } - ]; + // Map status key (lowercase) → count value from the summary endpoint. + const statusCountByKey = { + all: total, + pending: pendingLenght, + accepted: assignLenght, + arrived: arrivesLenght, + picked: pickedLenght, + active: activeLenght, + delivered: deliveredLenght, + skipped: skippedLenght, + cancelled: cancelLenght + }; - // to clear the tenant and location and rider autocomplete + // Cascading clears so changing a parent filter resets its children. useEffect(() => { setTenantid(0); setTenantValue(null); @@ -216,61 +281,50 @@ export default function OrdersDetails() { setSelectedRider(null); setRiderValue(null); }, [appId]); - // to clear the location and rider autocomplete + useEffect(() => { setLocationid(0); setLocationValue(null); - setRiderValue(null); }, [tenantid]); - // to clear the rider autocomplete - useEffect(() => { + useEffect(() => { setRiderValue(null); }, [locationid]); - // Function to calculate distance between two coordinates using Haversine formula + // ============== Haversine distance calculation for the map route ============== function calculateDistance(lat1, lon1, lat2, lon2) { - const R = 6371; // Radius of the Earth in kilometers + const R = 6371; const dLat = (lat2 - lat1) * (Math.PI / 180); const dLon = (lon2 - lon1) * (Math.PI / 180); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - const distance = R * c; // Distance in kilometers - return distance; + return R * c; } - // Function to calculate total distance traveled along the route function calculateTotalDistance(routeCoordinates) { let totalDistance = 0; for (let i = 0; i < routeCoordinates.length - 1; i++) { const { lat: lat1, lng: lon1 } = routeCoordinates[i]; const { lat: lat2, lng: lon2 } = routeCoordinates[i + 1]; - const distance = calculateDistance(lat1, lon1, lat2, lon2); - console.log('distance', distance); - totalDistance += distance; + totalDistance += calculateDistance(lat1, lon1, lat2, lon2); } return totalDistance; } const getdeliverylogs = async (id) => { setLogsLoading(true); - console.log('deliveryid', id); try { const res = await axios.get(`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${id}`); - console.log('getdeliverylogs', res.data.details); const datas = res.data.details; if (datas.length != 0) { setRiderStart(datas[0].logdate); setRiderEnd(datas[datas.length - 1].logdate); const coData = datas.map((data) => ({ lat: data.latitude, lng: data.longitude })); - console.log('coData', coData); setRiderCoordinates(coData); - const totalDistance = calculateTotalDistance(coData); - // setRiderKms(totalDistance); - console.log('Total distance traveled:', totalDistance, 'km'); + calculateTotalDistance(coData); setMapOpen(true); } else if (datas == null || !datas) { opentoast('No Logs Found ', 'error', 2000); @@ -282,44 +336,7 @@ export default function OrdersDetails() { } }; - // ==============================|| fetchorderdetails (orders)||============================== // - // const fetchorderdetails = async () => { - // setIsLoading(true); - // try { - // const response = await axios.get( - // appId == 0 - // ? currentStatus == 'All' - // ? `${process.env.REACT_APP_URL2}/deliveries/getdeliveries/?appuserid=${userid}&fromdate=${startdate}&todate=${enddate}&pageno=${ - // page + 1 - // }&pagesize=${rowsPerPage}&keyword=${debouncedSearch}` - // : `${ - // process.env.REACT_APP_URL2 - // }/deliveries/getdeliveries/?appuserid=${userid}&status=${currentStatus}&fromdate=${startdate}&todate=${enddate}&pageno=${ - // page + 1 - // }&pagesize=${rowsPerPage}&keyword=${debouncedSearch}` - // : currentStatus == 'All' - // ? `${ - // process.env.REACT_APP_URL2 - // }/deliveries/getdeliveries/?fromdate=${startdate}&todate=${enddate}&applocationid=${appId}&pageno=${ - // page + 1 - // }&pagesize=${rowsPerPage}&keyword=${debouncedSearch}` - // : `${ - // process.env.REACT_APP_URL2 - // }/deliveries/getdeliveries/?fromdate=${startdate}&todate=${enddate}&applocationid=${appId}&status=${currentStatus}&pageno=${ - // page + 1 - // }&pagesize=${rowsPerPage}&keyword=${debouncedSearch}` - // ); - // setRows(response.data.details || []); - // setIsLoading(false); - // } catch (error) { - // console.log('fetchorderdetails', error.message); - // setIsLoading(false); - // } - // }; - - // useEffect(() => { - // fetchorderdetails(); - // }, [appId, startdate, enddate, page, rowsPerPage, currentStatus]); + // ==============================|| fetchDeliveries (infinite) ||============================== // const { data: deliveriesData, @@ -346,7 +363,7 @@ export default function OrdersDetails() { queryFn: fetchDeliveries, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, refetchOnWindowFocus: true, - refetchOnMount: true, // 👈 ensures API triggers on page navigation + refetchOnMount: true, refetchOnReconnect: true }); const rows = deliveriesData?.pages.flatMap((page) => page.rows) || []; @@ -360,7 +377,7 @@ export default function OrdersDetails() { } }, { - root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer + root: document.querySelector('.MuiTableContainer-root'), rootMargin: '0px', threshold: 1.0 } @@ -379,7 +396,8 @@ export default function OrdersDetails() { } } }; - // ==============================|| getTenants ||============================== // + + // ==============================|| Tenant / Location / Rider lookups ||============================== // const { data: tenantlist, @@ -388,10 +406,9 @@ export default function OrdersDetails() { error: fetchtenantsError } = useQuery({ queryKey: ['tenantlist', appId], - queryFn: () => getTenants(appId), // Ensure appId is passed - enabled: appId !== 0 // Ensures query runs only when appId is valid + queryFn: () => getTenants(appId), + enabled: appId !== 0 }); - // ==============================|| getRiders ||============================== // const { data: ridersList, @@ -403,7 +420,6 @@ export default function OrdersDetails() { queryFn: () => getriderbydelivery(startdate, enddate, appId, tenantid, locationid), enabled: appId != 0 }); - // ==============================|| gettenantlocations ||============================== // const { data: locationlist, @@ -412,10 +428,11 @@ export default function OrdersDetails() { error: fetchlocationsError } = useQuery({ queryKey: ['gettenantlocations', tenantid], - queryFn: () => gettenantlocations(tenantid), // Ensure appId is passed - enabled: tenantid !== 0 // Ensures query runs only when appId is valid + queryFn: () => gettenantlocations(tenantid), + enabled: tenantid !== 0 }); - /* ============================================= || fetchcount | ============================================= */ + + // ==============================|| status summary counts ||============================== // const fetchcount = async () => { setIsLoading(true); try { @@ -430,9 +447,7 @@ export default function OrdersDetails() { }` ) .then((res) => { - console.log('fetchcountres', res.data.details); settotal(res.data.details.total); - currentStatus == 'All' && setStatusCount(res.data.details.total); setPendingLenght(res.data.details.pending); setAssignLenght(res.data.details.accepted); setArrivedLenght(res.data.details.arrived); @@ -443,7 +458,6 @@ export default function OrdersDetails() { setCancelLenght(res.data.details.cancelled); }) .catch((err) => { - console.log(err); enqueueSnackbar(err.message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, @@ -460,7 +474,7 @@ export default function OrdersDetails() { fetchcount(); }, [appId, startdate, enddate, currentStatus, tenantid, locationid, selectedRider]); - // to download ex format filtered data + // CSV export payload — flat schema preserved for backwards compatibility. const csvData = rows?.map((order) => ({ tenantname: order.tenantname, tenantcity: order.tenantcity, @@ -499,17 +513,12 @@ export default function OrdersDetails() { deliverylocation: order.deliverylocation, locationcontactno: order.locationcontactno })); - console.log('csvData', csvData); function formatDate(dateString) { - const date = dayjs(dateString); - const formattedDate = date.format('DD/MM/YYYY '); - return formattedDate; + return dayjs(dateString).format('DD/MM/YYYY '); } function formatTime(dateString) { - const date = dayjs(dateString); - const formattedDate = date.format(' hh:mm A'); - return formattedDate; + return dayjs(dateString).format(' hh:mm A'); } const errormessage = fetchDeliveriesIsError @@ -524,10 +533,17 @@ export default function OrdersDetails() { useEffect(() => { if (errormessage) { - console.log('errormessage', errormessage); opentoast(errormessage, 'warning', 2000); } }, [errormessage]); + + const KPI_META = [ + { key: 'total', label: 'Total Orders', color: BRAND, icon: MdLocalShipping, value: total }, + { key: 'delivered', label: 'Delivered', color: '#10b981', icon: MdCheckCircle, value: deliveredLenght }, + { key: 'pending', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty, value: pendingLenght }, + { key: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel, value: cancelLenght } + ]; + return ( <> {(isLoading || @@ -538,7 +554,6 @@ export default function OrdersDetails() { isFetchingNextPage) && (
- {/* */}
)} {fetchDeliveriesIsLoading && ( @@ -547,27 +562,184 @@ export default function OrdersDetails() { color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} - open={fetchDeliveriesIsLoading} // when loader = true, backdrop covers the page - > - {/* */} - + open={fetchDeliveriesIsLoading} + /> )} - - + + {/* ============================================= || Header || ============================================= */} + + + + + + + + + Orders Details + + + + + Live · {locaName || 'All Zones'} · {datestatus} + + + + + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} + /> + + + + {/* ============================================= || KPI Cards || ============================================= */} + + {KPI_META.map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + + {item.value} + + + + + + + + + ); + })} + + + {/* ============================================= || Filter Bar (tenant, location, rider, date, export) || ============================================= */} + + + option?.tenantname || ''} + PaperComponent={SoftPaper} onOpen={(event) => { if (!appId) { event.preventDefault(); @@ -590,14 +762,34 @@ export default function OrdersDetails() { setLocationValue(null); } }} - renderInput={(params) => } + renderInput={(params) => ( + + + + + + ) + }} + /> + )} /> - {/* ==================================================== || Location Autocomplete || ==================================================== */} + + + `${option.locationname} (${option.suburb})` || ''} + getOptionLabel={(option) => (option ? `${option.locationname} (${option.suburb})` : '')} value={locationValue} - sx={{ minWidth: 250, maxWidth: 1000, flex: 1 }} + PaperComponent={SoftPaper} onOpen={(event) => { if (!appId && !tenantid) { event.preventDefault(); @@ -622,501 +814,623 @@ export default function OrdersDetails() { setLocationValue(val); } }} - renderInput={(params) => } - /> - - } - /> - - - {startdate && enddate && ( - - {`Orders-${datestatus}`} - - - {dayjs(startdate).format('DD/MM/YYYY')} - {dayjs(enddate).format('DD/MM/YYYY')} - - } - variant="combined" - color="warning" - size="small" - deleteIcon={} - onDelete={() => { - setOpen(true); - }} - onClick={() => setOpen(true)} - sx={{ cursor: 'pointer' }} - /> - - + renderInput={(params) => ( + + + + + + ) + }} + /> )} + /> + + + + `${option.firstname} ${option.lastname}`} + PaperComponent={SoftPaper} + onOpen={() => { + if (!appId) { + OpenToast('Select App Location First', 'warning', 2000); + } + }} + onChange={(event, value, reason) => { + if (reason === 'clear') { + setSelectedRider(null); + setRiderValue(null); + } else { + setSelectedRider(value); + setRiderValue(value); + } + }} + renderInput={(params) => ( + + + + + + ) + }} + /> + )} + /> + + + + + + setOpen(true)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + px: 1.25, + py: 0.75, + borderRadius: 999, + cursor: 'pointer', + bgcolor: tint('#f59e0b'), + border: `1.5px solid ${edge('#f59e0b')}`, + color: '#f59e0b', + fontWeight: 800, + fontSize: 12, + transition: 'all 0.18s', + '&:hover': { borderColor: '#f59e0b', boxShadow: `0 0 0 3px ${ring('#f59e0b')}` } + }} + > + + {dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')} + + - - `${option.status}`} - sx={{ minWidth: 250, maxWidth: 1000, flex: 1 }} - onChange={(event, value, reason) => { - if (reason === 'clear') { - setCurrentStatus('All'); - setStatusCount(status[0].count); - } else { - console.log('status', value); - setCurrentStatus(value.statusLow); - setStatusCount(value.count); - } - }} - renderInput={(params) => } - /> - `${option.firstname} ${option.lastname}`} - sx={{ minWidth: 250, maxWidth: 1000, flex: 1 }} - onOpen={() => { - if (!appId) { - OpenToast('Select App Location First', 'warning', 2000); - } - }} - onChange={(event, value, reason) => { - if (reason === 'clear') { - setSelectedRider(null); - setRiderValue(null); - } else { - setSelectedRider(value); - setRiderValue(value); - } - }} - renderInput={(params) => } - /> - - + + + + + {/* ============================================= || Status Tabs + Search || ============================================= */} + + + + {STATUS_TABS.map((key) => { + const meta = STATUS_META[key]; + const Icon = meta.icon; + const active = currentStatus.toLowerCase() === key; + const count = statusCountByKey[key] ?? 0; + return ( + setCurrentStatus(key === 'all' ? 'All' : key)} + 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)}` + } + }} + > + + + + + {meta.label} + + + {count} + + + ); + })} - } + + + + + + + + {/* ============================================= || Table || ============================================= */} + - - - - {headCells.map((column) => ( - - {column.label} - - ))} +
+ + + # + Map + Client + Pickup + Drop + Status / Rider + Assigned + Accepted + Arrived + Picked + Active + Delivered + Cancelled + Notes + KMS + Charges + {fetchDeliveriesIsLoading ? ( - + ) : rows?.length == 0 ? ( - - + + + + + + + No orders to show + + + {searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'} + + ) : ( rows?.map((row, index) => { + const statusKey = String(row.orderstatus || '').toLowerCase(); + const rowStatusMeta = STATUS_META[statusKey] || { + label: row.orderstatus || '—', + color: BRAND, + icon: MdAssignment + }; + const StatusIcon = rowStatusMeta.icon; + const cancelled = statusKey === 'cancelled'; return ( - - {page * rowsPerPage + index + 1} - { - if (row.orderstatus == 'delivered') { - console.log('row', row); - getdeliverylogs(row.deliveryid); - setMapTenant(row); - setIsLoading(true); - setTimeout(() => { - setIsLoading(false); - }, 500); - } else { - return null; - } - }} - > - {row.orderstatus == 'delivered' ? ( - + + + + {String(page * rowsPerPage + index + 1).padStart(2, '0')} + + + + {/* ====================== Map button ====================== */} + + + + { + if (row.orderstatus === 'delivered') { + getdeliverylogs(row.deliveryid); + setMapTenant(row); + } + }} + sx={{ + bgcolor: row.orderstatus === 'delivered' ? soft(BRAND) : soft('#94a3b8'), + color: row.orderstatus === 'delivered' ? BRAND : DT.textMuted, + border: `1px solid ${row.orderstatus === 'delivered' ? edge(BRAND) : edge('#94a3b8')}`, + '&:hover': { + bgcolor: row.orderstatus === 'delivered' ? BRAND : soft('#94a3b8'), + color: row.orderstatus === 'delivered' ? '#fff' : DT.textMuted + } + }} + > + + + + + + + {/* ====================== Client ====================== */} + + + + + + + + {row.tenantname} + + + + #{row.orderid} + + + + {dayjs(row.deliverydate).utc().format('DD/MM/YYYY · hh:mm A')} + + + + + + {/* ====================== Pickup ====================== */} + + + + {row.pickupcustomer || '—'} + + + {row.pickupcontactno} + + + + {row.pickupsuburb || (row.Pickupaddress ? row.Pickupaddress.slice(0, 22) + '…' : '')} + + + {row.applocation && ( + + {row.applocation} + + )} + + + + {/* ====================== Drop ====================== */} + + + + {row.deliverycustomer || '—'} + + + {row.deliverycontactno} + + + + {row.deliverysuburb || (row.deliveryaddress ? row.deliveryaddress.slice(0, 22) + '…' : '')} + + + + + + {/* ====================== Status / Rider ====================== */} + + + + {rowStatusMeta.label} + + {row.ridername && ( + + + + + {row.ridername} + + + + )} + + + + {/* ====================== Timestamps ====================== */} + + + + + + + + + + + + + + + + + + + + + + + {/* ====================== Notes ====================== */} + + {row.ordernotes ? ( + + + + {row.ordernotes} + + ) : ( - + )} - {/* ================================================ || Client || ================================================ */} - - {row.tenantname} - - - {row.orderid} - - - - {dayjs(row.deliverydate).utc().format('DD/MM/YYYY')} - - - {dayjs(row.deliverydate).utc().format('hh:mm A')} - - - {/* ================================================ || Pickup || ================================================ */} - - - - - {row.pickupcustomer} - - {row.pickupcontactno} - - - - {` ${row.pickupsuburb || row.Pickupaddress.slice(0, 20) + '...'}`} - - - - {row.applocation} - - - - - {/* ================================================ || Drop || ================================================ */} - - - - - {row.deliverycustomer} - - {row.deliverycontactno} - - - - {/* {row.pickupaddress.slice(0, 20)} */} - {`${row.deliverysuburb || row.deliveryaddress.slice(0, 20) + '...'}`} - - - - - - {/* ================================================ || Status || ================================================ */} - - - - {row.orderstatus === 'created' && } - {row.orderstatus === 'pending' && } - {row.orderstatus === 'accepted' && ( - - )} - {row.orderstatus === 'arrived' && ( - - )} - {row.orderstatus === 'picked' && } - {row.orderstatus === 'active' && } - {row.orderstatus === 'delivered' && } - {row.orderstatus === 'skipped' && } - {row.orderstatus === 'cancelled' && } - - - - {row.ridername} - - - ID : {row.userid} - - - - {/* ================================================ || Assigned || ================================================ */} - - - - {row.assigntime === '' ? ( - - ) : ( - formatDate(row.assigntime) - )} - - - {row.assigntime === '' ? ( - - ) : ( - formatTime(row.assigntime) - )} - - - {/* ================================================ || Accepted || ================================================ */} - - - - {row.acceptedtime === '' ? ( - - ) : ( - formatDate(row.acceptedtime) - )} - - - {row.acceptedtime === '' ? ( - - ) : ( - formatTime(row.acceptedtime) - )} - - - - {/* ================================================ || Arrived || ================================================ */} - - - - {row.arrivaltime === '' ? ( - - ) : ( - formatDate(row.arrivaltime) - )} - - - {row.arrivaltime === '' ? ( - - ) : ( - formatTime(row.arrivaltime) - )} - - - {/* ================================================ || Picked || ================================================ */} - - - - {row.pickuptime === '' ? ( - - ) : ( - formatDate(row.pickuptime) - )} - - - - {row.pickuptime === '' ? ( - - ) : ( - formatTime(row.pickuptime) - )} - - {row.deliverystatus === 'active' && ( - - )} - - - - {/* ================================================ || Active || ================================================ */} - - - - {row.starttime === '' ? ( - - ) : ( - formatDate(row.starttime) - )} - - - {row.starttime === '' ? ( - - ) : ( - formatTime(row.starttime) - )} - - - {/* ================================================ || Delivered || ================================================ */} - - - - {row.deliverytime === '' ? ( - - ) : ( - formatDate(row.deliverytime) - )} - - - {row.deliverytime === '' ? ( - - ) : ( - formatTime(row.deliverytime) - )} - - - {/* ================================================ || Cancelled || ================================================ */} - - - - {row.canceltime === '' ? ( - - ) : ( - formatDate(row.canceltime) - )} - - - {row.canceltime === '' ? ( - - ) : ( - formatTime(row.canceltime) - )} - - - {/* ================================================ || Notes || ================================================ */} - - - - {row.ordernotes || } - - - {/* ================================================ || Kms || ================================================ */} + {/* ====================== KMS ====================== */} - - - - - - {}} - sx={{ - minWidth: 80, - border: '1px solid #76ff03 ', - cursor: 'pointer' - }} - /> - - - {}} - sx={{ - minWidth: 80, - border: '1px solid #03a9f4 ', - cursor: 'pointer' - }} - /> - + + } + label={cancelled || row.kms == '' ? '0 km' : `${row.kms} km`} + tooltip="KMS" + /> + } + label={`${row.cumulativekms ?? 0} km`} + tooltip="Actual KMS" + /> + } + label={`${row.previouskms || (cancelled ? '0.00' : row.kms) || 0} km`} + tooltip="Rider KMS" + /> - {/* ================================================ || Charges || ================================================ */} + {/* ====================== Charges ====================== */} - - - - - - - + + } + label={cancelled || row.deliverycharges == '' ? `0.00` : `${row.deliverycharges}.00`} + tooltip="Delivery Charge" + /> + } + label={row.deliveryamt == '' ? `0.00` : `${row.deliveryamt}.00`} + tooltip="Delivery Amount" + /> @@ -1129,125 +1443,119 @@ export default function OrdersDetails() { {rows?.length !== 0 && ( - {isFetchingNextPage ? : hasNextPage ? : 'No More Orders Details'} + {isFetchingNextPage || hasNextPage ? ( + + ) : ( + + No more orders + + )} )} - - {/* ============================================= || Report Download Dialog | ============================================= */} - setReportDialog(false)} fullWidth maxWidth={'sm'}> - - Export Report + + + {/* ============================================= || Export Dialog || ============================================= */} + setReportDialog(false)} + fullWidth + maxWidth="sm" + PaperProps={{ sx: { borderRadius: 2.5, overflow: 'hidden' } }} + > + + + + + + + + + Report + + + Export Orders + + + + setReportDialog(false)} + sx={{ color: '#fff', bgcolor: 'rgba(255,255,255,0.18)', '&:hover': { bgcolor: 'rgba(255,255,255,0.3)' } }} + > + + + - + {fetchDeliveriesIsLoading && } - - } + {[ + { label: 'App Location', value: locaName, color: BRAND }, + { label: 'Tenant', value: tenantValue?.tenantname, color: '#0ea5e9' }, + { label: 'Business Location', value: locationValue?.locationname, color: '#10b981' }, + { label: 'Status', value: currentStatus, color: '#f59e0b' }, + { label: 'Rider', value: riderValue ? `${riderValue.firstname} ${riderValue.lastname}` : null, color: '#8b5cf6' }, + { label: 'Keyword', value: searchword, color: '#06b6d4' }, + { label: 'Start Date', value: startdate, color: '#14b8a6' }, + { label: 'End Date', value: enddate, color: '#ef4444' } + ].map((item, idx) => ( + - - App Location + + {item.label} - - - - } - > - - Tenant - - - - - } - > - - Business Location - - - - } - > - - Status - - - - - } - > - - Rider - - - - } - > - - Keyword - - - - } - > - - Start Date - - - - } - > - - End Date - - - + + + ))} - - {/* Visible Button */} + { setTimeout(() => setReportDialog(false), 0); @@ -1255,7 +1563,8 @@ export default function OrdersDetails() { /> - {/* ============================================= || filter Dialog | ============================================= */} + + {/* ============================================= || Date Filter Dialog || ============================================= */} setOpen(false)} @@ -1263,10 +1572,10 @@ export default function OrdersDetails() { setStartdate(range.startDate); setEnddate(range.endDate); setDatestatus(range.label); - console.log('Selected Date Range:', range); }} /> - {/* ============================================= || map dialog | ============================================= */} + + {/* ============================================= || Map Dialog || ============================================= */} { @@ -1285,16 +1594,6 @@ export default function OrdersDetails() { /> )} - {/* {riderCoordinates && ( -
- -
- )} */}
); diff --git a/src/pages/nearle/reports/ridersSummary.js b/src/pages/nearle/reports/ridersSummary.js index 6dcb16f..6da7ab0 100644 --- a/src/pages/nearle/reports/ridersSummary.js +++ b/src/pages/nearle/reports/ridersSummary.js @@ -1,58 +1,185 @@ -import { React, useState, useEffect } from 'react'; -import TitleCard from 'pages/titleCard'; +import React, { useState, useMemo } from 'react'; import axios from 'axios'; import { useQuery } from '@tanstack/react-query'; -import { fetchRidersSummary } from 'pages/api/api'; -import { Empty } from 'antd'; // material-ui import { + Avatar, + Box, + Chip, + Collapse, + Dialog, + DialogContent, Divider, + Grid, + IconButton, + Paper, + Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - Typography, - Stack, - IconButton, Tooltip, - Chip, - Collapse, - Dialog, - DialogContent + Typography } from '@mui/material'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import { IoLocationOutline } from 'react-icons/io5'; +import { + MdDirectionsBike, + MdMyLocation, + MdCalendarMonth, + MdLocalShipping, + MdCheckCircle, + MdHourglassEmpty, + MdCancel, + MdCurrencyRupee, + MdStraighten, + MdMap, + MdExpandMore, + MdExpandLess, + MdGroups, + MdPerson +} from 'react-icons/md'; import dayjs from 'dayjs'; var utc = require('dayjs/plugin/utc'); dayjs.extend(utc); -import { CalendarMonth } from '@mui/icons-material'; -import MainCard from 'components/MainCard'; + +import { fetchRidersSummary } from 'pages/api/api'; import Loader from 'components/Loader'; import DateFilterDialog from 'components/DateFilterDialog'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; +import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import RidersRoutes from './RidersRoutes'; import { OpenToast } from 'components/third-party/OpenToast'; +// ============================================================================ +// Design tokens — shared with deliveries / tenants / customers / pricing / +// orders-details pages. +// ============================================================================ +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) => ( + +); + +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + +// Pill used in cells for numeric / km / amount metrics. +const MetricPill = ({ color, icon, label, tooltip, minWidth = 80 }) => ( + + + {icon} + {label} + + +); + +// Coloured numeric cell — non-zero values get a soft red badge to draw the eye, +// preserving the legacy "red when present" affordance. +const CountCell = ({ value, color = '#ef4444', icon }) => { + const n = Number(value) || 0; + if (n === 0) { + return ( + + 0 + + ); + } + return ( + + {icon} + {n} + + ); +}; + function formatNumberToRupees(value) { return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 - }).format(value); + }).format(Number(value) || 0); } -const getColorByValue = (value) => { - return Number(value) !== 0 ? 'red' : 'inherit'; -}; -const coloredCell = (value) => {value}; - -// ==============================|| MUI TABLE - ENHANCED ||============================== // +// ==============================|| Riders Summary ||============================== // export default function RidersSummary() { const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD')); @@ -60,29 +187,55 @@ export default function RidersSummary() { const [locaName, setLocoName] = useState('All'); const [open, setOpen] = useState(false); const [datestatus, setDatestatus] = useState('Today'); - const [total, settotal] = useState(0); const [tenantData, setTenantData] = useState([]); - const [openRow, setOpenRow] = useState(null); // Initially no row is open - // const [appId, setAppId] = useState(localStorage.getItem('applocationid')); + const [openRow, setOpenRow] = useState(null); const [appId, setAppId] = useState(0); const [loading, setLoading] = useState(false); const [mapOpen, setMapOpen] = useState(false); const [logDetails, setLogDetails] = useState(null); + const [searchword, setSearchword] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); - // ==============================|| fetchRidersSummary (riders summary)||============================== // + // ==============================|| fetchRidersSummary ||============================== // const { isLoading: isLoadingReports, data: rows } = useQuery({ queryKey: ['ridersummary', appId, startdate, enddate], queryFn: fetchRidersSummary }); - // ==============================|| fetchTenantSummary by rider (rider summary)||============================== // + // Client-side filter across rider name + id. + const filteredRows = useMemo(() => { + if (!rows) return []; + if (!debouncedSearch) return rows; + const q = debouncedSearch.toLowerCase().trim(); + return rows.filter((r) => + [`${r.firstname || ''} ${r.lastname || ''}`, String(r.userid)] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(q)) + ); + }, [rows, debouncedSearch]); + + // Aggregate KPIs from the loaded summary. + const stats = useMemo(() => { + if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 }; + return rows.reduce( + (acc, r) => { + acc.riders += 1; + acc.orders += Number(r.totalorders) || 0; + acc.delivered += Number(r.delivered) || 0; + acc.amount += Math.max(Number(r.charges) || 0, Number(r.deliveryamt) || 0); + return acc; + }, + { riders: 0, orders: 0, delivered: 0, amount: 0 } + ); + }, [rows]); + + // ==============================|| per-rider tenant breakdown ||============================== // const fetchTenantSummary = async (riderUserid) => { setLoading(true); try { const tenantRes = await axios.get( `${process.env.REACT_APP_URL}/deliveries/getreportsummary/?&fromdate=${startdate}&todate=${enddate}&userid=${riderUserid}` ); - console.log('tenantRes', tenantRes.data.details); setTenantData(tenantRes.data.details); } catch (error) { console.log('tenantRes', error); @@ -91,8 +244,7 @@ export default function RidersSummary() { } }; - // ==============================|| getuserdeliverylogs (rider summary)||============================== // - + // ==============================|| rider delivery logs (for map) ||============================== // const getuserdeliverylogs = async (userid) => { try { const response = await axios.get( @@ -101,335 +253,670 @@ export default function RidersSummary() { setLogDetails(response.data.details); } catch (err) { OpenToast(err?.message, 'error', 2000); - console.log('getuserdeliverylogs', err.message); } }; - // ==============================|| calculate||============================== // - const calculate = async () => { - let calculatedTotal = 0; - rows && - rows.forEach((row, index) => { - console.log(index, row.deliveryamt); - calculatedTotal += row.deliveryamt; - }); - // Update the state after the calculation is done - settotal(calculatedTotal); - console.log('calculatedTotal', calculatedTotal); - }; - useEffect(() => { - calculate(); + // Total Amount sum (preserved from legacy bottom bar). + const total = useMemo(() => { + if (!rows) return 0; + return rows.reduce((sum, row) => sum + (Number(row.deliveryamt) || 0), 0); }, [rows]); + const KPI_META = [ + { key: 'riders', label: 'Active Riders', color: BRAND, icon: MdDirectionsBike, value: stats.riders }, + { key: 'orders', label: 'Total Orders', color: '#0ea5e9', icon: MdLocalShipping, value: stats.orders }, + { key: 'delivered', label: 'Delivered', color: '#10b981', icon: MdCheckCircle, value: stats.delivered }, + { key: 'amount', label: 'Total Amount', color: '#f59e0b', icon: MdCurrencyRupee, value: formatNumberToRupees(total) } + ]; + return ( <> - {(isLoadingReports || loading) && ( - <> - - {/* */} - - )} - + {(isLoadingReports || loading) && } - - - {startdate && enddate && ( - - - - {dayjs(startdate).format('DD/MM/YYYY')} - {dayjs(enddate).format('DD/MM/YYYY')} - - } - variant="combined" - color="warning" - deleteIcon={} - onDelete={() => { - setOpen(true); - }} - onClick={() => setOpen(true)} - sx={{ cursor: 'pointer' }} - /> - - )} - {(!startdate || !enddate) && ( - <> - - - {/* ALL} variant="combined" color='warning' size='small' /> */} - - - )} - - - + {/* ============================================= || Header || ============================================= */} + + + + + + + + + Riders Summary + + + + + Live · {locaName || 'All Zones'} · {datestatus} + + - } - > - {/* table */} - -
- - # - Rider - Orders - Pending - {/* Assigned - Accepted - Arrived - Picked - Active - Skipped */} - Cancelled - Delivered - KMS - Amount - Action - - {/* ============================================ || TableBody || ============================================ */} + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} + /> + + - {isLoadingReports && } + {/* ============================================= || KPI Cards || ============================================= */} + + {KPI_META.map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + + {item.value} + + + + + + + + + ); + })} + + + {/* ============================================= || Filter Bar (date + search) || ============================================= */} + + + + + + + + + Orders · {datestatus} + + + {filteredRows.length} riders · {stats.orders} orders + + + + setOpen(true)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + px: 1.25, + py: 0.75, + borderRadius: 999, + cursor: 'pointer', + bgcolor: tint('#f59e0b'), + border: `1.5px solid ${edge('#f59e0b')}`, + color: '#f59e0b', + fontWeight: 800, + fontSize: 12, + ml: 1, + transition: 'all 0.18s', + '&:hover': { borderColor: '#f59e0b', boxShadow: `0 0 0 3px ${ring('#f59e0b')}` } + }} + > + + {dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')} + + + + + + + + + + {/* ============================================= || Table || ============================================= */} + + +
+ + + # + Rider + Orders + Pending + Cancelled + Delivered + KMS + Amount + Action + + - {rows?.length === 0 && ( + {isLoadingReports && } + {(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? ( - - - + + + + + + + No riders to show + + + {searchword ? 'Try a different keyword.' : 'Pick a zone or date range to load the summary.'} + - )} - - {rows?.length != 0 && - rows?.map((row, index) => ( - <> - {/* // ============================================ || tablerow 1 || ============================================ */} - - {index + 1} - - - {` ${row?.firstname} ${row?.lastname}`} - Id : {row.userid} - - - - {coloredCell(row.totalorders)} - {coloredCell(row.pending)} - {/* {coloredCell(row.assigned)} - {coloredCell(row.accepted)} - {coloredCell(row.arrived)} - {coloredCell(row.picked)} - {coloredCell(row.active)} - {coloredCell(row.skipped)} */} - {coloredCell(row.cancelled)} - {coloredCell(row.delivered)} - - - - - - - - - - - - - - - - = row.deliveryamt ? row.charges : row.deliveryamt)} - variant="combined" - color={row.deliveryamt ? 'primary' : 'secondary'} - sx={{ - cursor: 'pointer', - minWidth: 100 - }} - /> - - - - - { - const isOpening = openRow !== row.userid; - - // toggle row - setOpenRow(isOpening ? row.userid : null); - - // ❌ closing → no API - if (!isOpening) return; - - // ✅ opening → call API - fetchTenantSummary(row.userid); - }} - sx={{ - bgcolor: openRow === row.userid ? 'primary.main' : null, - color: openRow === row.userid ? 'white' : null, - '&:hover': { - bgcolor: openRow === row.userid ? 'primary.main' : '#e1bee7' - } - }} - > - {openRow === row.userid ? : } - - - { - setMapOpen(true); - getuserdeliverylogs(row?.userid); - }} - /> - - - - - {/* // ============================================ || collapsive row || ============================================ */} - {openRow === row.userid && ( + ) : ( + filteredRows.map((row, index) => { + const isOpen = openRow === row.userid; + const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0); + return ( + - - - -
- - - # - Client - All - Pending - Completed - Cancelled - Kms - Amount - - - - {loading && } - {tenantData?.map((row, index) => ( - - {index + 1} - - - {row.tenantname} - - - {coloredCell(row.totalorders)} - {coloredCell(row.deliveriespending)} - {coloredCell(row.deliveriescompleted)} - {coloredCell(row.deliveriescancelled)} + + + {String(index + 1).padStart(2, '0')} + + - - - - + + + + + + + + {`${row?.firstname || ''} ${row?.lastname || ''}`.trim() || '—'} + + + ID #{row.userid} + + + + - - = row.deliveryamt ? row.charges : row.deliveryamt)} - sx={{ - color: 'primary.main', - bgcolor: '#e1bee7', - border: '1px solid #662582 ', - minWidth: 100 - }} - /> - - - ))} - -
- - + + } /> + + + } /> + + + } /> + + + } /> + + + + + } + label={`${Number(row.kms || 0).toFixed(2)} km`} + tooltip="KMS" + /> + } + label={`${Number(row.cumulativekms || 0).toFixed(2)} km`} + tooltip="Actual KMS" + /> + + + + + } + label={formatNumberToRupees(amount).replace('₹', '').trim()} + tooltip="Total Amount" + minWidth={100} + /> + + + + + + { + setMapOpen(true); + getuserdeliverylogs(row?.userid); + }} + sx={{ + bgcolor: soft('#0ea5e9'), + color: '#0ea5e9', + border: `1px solid ${edge('#0ea5e9')}`, + '&:hover': { bgcolor: '#0ea5e9', color: '#fff' } + }} + > + + + + + { + const isOpening = !isOpen; + setOpenRow(isOpening ? row.userid : null); + if (isOpening) fetchTenantSummary(row.userid); + }} + sx={{ + bgcolor: isOpen ? BRAND : soft(BRAND), + color: isOpen ? '#fff' : BRAND, + border: `1px solid ${edge(BRAND)}`, + '&:hover': { bgcolor: BRAND, color: '#fff' } + }} + > + {isOpen ? : } + + + - )} - - ))} + + {/* ============================================= || Collapsible per-tenant breakdown || ============================================= */} + {isOpen && ( + + + + + + + + + + + Tenant Breakdown · {`${row?.firstname || ''} ${row?.lastname || ''}`.trim()} + + + + + + + # + Client + All + Pending + Completed + Cancelled + KMS + Amount + + + + {loading && } + {!loading && (!tenantData || tenantData.length === 0) ? ( + + + + No tenant breakdown available. + + + + ) : ( + tenantData?.map((sub, sidx) => ( + + + + {String(sidx + 1).padStart(2, '0')} + + + + + + + + + {sub.tenantname || '—'} + + + + + } /> + + + } /> + + + } /> + + + } /> + + + + } + label={`${Number(sub.kms || 0).toFixed(2)} km`} + tooltip="KMS" + /> + } + label={`${Number(sub.cumulativekms || 0).toFixed(2)} km`} + tooltip="Actual KMS" + /> + + + + } + label={formatNumberToRupees( + Math.max(Number(sub.charges) || 0, Number(sub.deliveryamt) || 0) + ) + .replace('₹', '') + .trim()} + tooltip="Total Amount" + minWidth={100} + /> + + + )) + )} + +
+
+
+
+
+
+ )} + + ); + }) + )}
- - {rows?.length != 0 && ( - - Total : - - {formatNumberToRupees(total)} - - + + {/* ============================================= || Total Bar || ============================================= */} + {filteredRows.length > 0 && ( + <> + + + + Grand Total + + + + )} - +
+ + {/* ============================================= || Map Dialog || ============================================= */} { @@ -438,7 +925,8 @@ export default function RidersSummary() { > {logDetails && } - {/* ============================================= || filter Dialog | ============================================= */} + + {/* ============================================= || Date Filter Dialog || ============================================= */} setOpen(false)} @@ -446,7 +934,6 @@ export default function RidersSummary() { setStartdate(range.startDate); setEnddate(range.endDate); setDatestatus(range.label); - console.log('Selected Date Range:', range); }} /> diff --git a/src/pages/nearle/riders/riders.js b/src/pages/nearle/riders/riders.js index 64fea33..d8d9d78 100644 --- a/src/pages/nearle/riders/riders.js +++ b/src/pages/nearle/riders/riders.js @@ -1,35 +1,49 @@ import * as React from 'react'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, Fragment } from 'react'; import Geocode from 'react-geocode'; -import { Empty } from 'antd'; import { useNavigate } from 'react-router-dom'; -import { FaRegEdit } from 'react-icons/fa'; import { Avatar, + Paper, Stack, - Chip, Typography, Table, TableCell, TableBody, TableHead, IconButton, - Tabs, - Tab, TableRow, Tooltip, TableContainer, - Divider, Backdrop, - Collapse + Collapse, + Grid, + Box, + Skeleton, + Divider } from '@mui/material'; var utc = require('dayjs/plugin/utc'); -import { useTheme } from '@mui/material/styles'; -import { DownOutlined, UpOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; dayjs.extend(utc); -import MainCard from 'components/MainCard'; -import TitleCard from 'components/nearle_components/TitleCard'; +import { + MdDirectionsBike, + MdMyLocation, + MdPersonPin, + MdCheckCircle, + MdCancel, + MdGroups, + MdEdit, + MdKeyboardArrowDown, + MdKeyboardArrowUp, + MdLocationOn, + MdBatteryStd, + MdPowerSettingsNew, + MdSpeed, + MdGpsFixed, + MdAccessTime, + MdInventory2, + MdTwoWheeler +} from 'react-icons/md'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import CircularLoader from 'components/CircularLoader'; @@ -39,15 +53,89 @@ import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import { OpenToast } from 'components/third-party/OpenToast'; import axios from 'axios'; -import LocationOnIcon from '@mui/icons-material/LocationOn'; -import BatteryStdIcon from '@mui/icons-material/BatteryStd'; -import SpeedIcon from '@mui/icons-material/Speed'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import PowerIcon from '@mui/icons-material/Power'; -import GpsFixedIcon from '@mui/icons-material/GpsFixed'; + +// ============================================================================ +// Design tokens — shared with the deliveries / tenants / customers pages so +// every surface (header, KPI tiles, table, badges, dialog) speaks the same +// visual language. Brand purple `#662582` is the canonical primary; status +// colours are semantic and distinct from the brand. +// ============================================================================ +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) => ( + +); + +const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + +); + +// Status palette — semantic only (do NOT swap for brand purple). Used by the +// per-row status badges and the lifecycle tabs (ALL, Active). +const STATUS_META = { + active: { label: 'Active', color: '#10b981', icon: MdCheckCircle }, + inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel }, + online: { label: 'Online', color: '#10b981', icon: MdCheckCircle }, + offline: { label: 'Offline', color: '#ef4444', icon: MdCancel }, + idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime }, + unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2 } +}; + +// Pill-tab definitions for the rider listing tabs. Keeps brand purple for the +// "ALL" view and emerald for "Active" so the colour matches the count's meaning. +const TAB_META = [ + { key: 0, label: 'All Riders', color: BRAND, icon: MdGroups, countKey: 'total' }, + { key: 1, label: 'Active', color: '#10b981', icon: MdCheckCircle, countKey: 'active' } +]; + +const KPI_META = (summary) => [ + { key: 'total', label: 'Total Riders', color: BRAND, icon: MdGroups, value: summary?.total ?? 0 }, + { key: 'active', label: 'Active Riders', color: '#10b981', icon: MdCheckCircle, value: summary?.active ?? 0 }, + { key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdCancel, value: summary?.inactive ?? 0 } +]; const Riders = () => { - const theme = useTheme(); const navigate = useNavigate(); const loadMoreRef = useRef(); const containerRef = useRef(); @@ -62,19 +150,17 @@ const Riders = () => { Geocode.setApiKey(process.env.REACT_APP_GOOGLE_MAPS_API_KEY); - - - const handleChangetab = (e, i) => { + const handleChangetab = (i) => { setTabvalue(i); setLogsRow(null); }; // ==============================|| getallridersummary||============================== // - const { data: allRidersSummary, isLoading: riderSummarysLoading } = useQuery({ queryKey: ['allriders', appId, tabvalue], queryFn: getallridersummary }); + // ==============================|| getRiderLogs (riders)||============================== // const getRiderLogs = async (userid) => { try { @@ -89,6 +175,7 @@ const Riders = () => { OpenToast(err.message, 'error', 2000); } }; + // ==============================|| getriderstatus||============================== // const { data: ridersStatus, @@ -99,11 +186,7 @@ const Riders = () => { queryKey: ['ridersStatus'], queryFn: getriderstatus }); - useEffect(() => { - if (ridersStatus) { - console.log('Success:', ridersStatus); - } - }, [ridersStatus]); + // ==============================|| fetchAllRiders||============================== // const { data: allRidersData, @@ -118,6 +201,7 @@ const Riders = () => { }); const rows = allRidersData?.pages.flatMap((page) => page.details) || []; + useEffect(() => { if (!hasNextPage) return; const observer = new IntersectionObserver( @@ -127,7 +211,7 @@ const Riders = () => { } }, { - root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer + root: document.querySelector('.MuiTableContainer-root'), rootMargin: '0px', threshold: 1.0 } @@ -146,346 +230,732 @@ const Riders = () => { } } }; + const errMessage = riderstatusIsError ? riderStatusError : null; useEffect(() => { if (errMessage) { OpenToast(errMessage, 'error', 2000); } }, [errMessage]); + + // Per-row status meta — falls back to "unknown" if the rider's state key + // isn't in the palette (e.g. brand-new status string from the backend). + const getRowStatusMeta = (row) => { + if (tabvalue == 0) { + const key = (row.status || '').toLowerCase() === 'active' ? 'active' : 'inactive'; + return STATUS_META[key]; + } + const state = ridersStatus?.find((s) => s.userid === row.userid); + const key = (state?.status || 'unknown').toLowerCase(); + return STATUS_META[key] || STATUS_META.unknown; + }; + return ( <> - { - theme.zIndex.drawer + 1 - }} - open={allRidersLoading || riderSummarysLoading || riderStatusLoading} // when loader = true, backdrop covers the page - > - - - } - {/* ============================================= || titlecard | ============================================= */} + theme.zIndex.drawer + 1 }} + open={allRidersLoading || riderSummarysLoading || riderStatusLoading} + > + + - - - - - - } + + + + + + + + Riders + + + + + Live · {locaName || 'All Zones'} + + + + + } + placeholder="Select Zone" + paperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }} /> - } - /> - - - - + + + + {/* ============================================= || KPI Cards | ============================================= */} + + {KPI_META(allRidersSummary).map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.label} + + {riderSummarysLoading ? ( + + ) : ( + + {item.value} + + )} + + + + + + + + ); + })} + + + {/* ============================================= || Status Tabs + Search || ============================================= */} + + + + {TAB_META.map((t) => { + const Icon = t.icon; + const active = tabvalue === t.key; + const count = allRidersSummary?.[t.countKey] ?? 0; + return ( + handleChangetab(t.key)} + 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 ? t.color : edge(t.color)}`, + bgcolor: active ? t.color : tint(t.color), + color: active ? '#fff' : t.color, + fontWeight: 700, + boxShadow: active ? `0 6px 18px ${ring(t.color)}` : 'none', + transition: 'all 0.18s', + '&:hover': { + borderColor: t.color, + boxShadow: active ? `0 6px 18px ${ring(t.color)}` : `0 0 0 3px ${ring(t.color)}` + } + }} + > + + + + + {t.label} + + + {riderSummarysLoading ? : count} + + + ); + })} + + + + + + + + + {/* ============================================= || Table || ============================================= */} + - +
- - S.NO - User ID - Rider - Address - Vehicle - Shift - - Time - - Fare - Fuel - - Status - - {roleid == 1 && ( - - Action{' '} - - )} + + # + ID + Rider + Address + Vehicle + Shift + Time + Fare + Fuel + Status + {roleid == 1 && Action} - {allRidersLoading && } - {rows?.length == 0 && !allRidersLoading && ( - <> - - - + + {rows?.length === 0 && !allRidersLoading && ( + + + + + + + + No riders to show + + + {searchword + ? 'Try a different keyword.' + : `No ${tabvalue === 0 ? '' : 'active '}riders for this zone.`} + - + )} - {rows?.length != 0 && + + {rows?.length !== 0 && rows?.map((row, index) => { + const statusMeta = getRowStatusMeta(row); + const StatusIcon = statusMeta.icon; + const expanded = logsRow === row.userid; return ( - <> - - {index + 1} + + - + + {String(index + 1).padStart(2, '0')} + - - + + + #{row?.userid} + + + + - {row.fullname?.charAt(0).toUpperCase()} + {(row.fullname || row.username || '?').charAt(0).toUpperCase()} - - {`${row.username}`} - {row.contactno} + + + {row.username || '—'} + + + {row.contactno || '—'} + - - - - {row.suburb || row.address.slice(0, 20)} - {row.city} + + + + + {row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')} + + + {row.city || ''} + - {row.vehicleno} - {row.shiftid} - - - - + + + + + + + {row.vehicleno || '—'} + + + + + + #{row.shiftid ?? '—'} + + + + + + {row.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.starttime}`).format('hh:mm A') : '—'} + + + {row.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.endtime}`).format('hh:mm A') : '—'} + + + + + + {row.basefare ?? '—'} + + + + + {row.fuelcharge ?? '—'} + + + + + + + + + {statusMeta.label} + - - {row.basefare} - {row.fuelcharge} - {tabvalue == 0 ? ( - - {row.status == 'Active' && ( - - )} - {row.status == 'InActive' && ( - - )} - - ) : ( - - {(() => { - const state = ridersStatus?.find((status) => status.userid === row.userid); - const statusText = state?.status; - console.log('statusText', state); - return ( - - ); - })()} - - )} - {roleid == 1 && ( - - - + + + { navigate('/nearle/riders/edit', { state: { riderdata: row } }); }} > - + {tabvalue != 0 && ( - { - if (row.userid == logsRow) { - setLogsRow(null); - } else { - setLogsRow(row.userid); - getRiderLogs(row.userid); - } - }} - > - {row.userid == logsRow ? : } - + + { + if (row.userid == logsRow) { + setLogsRow(null); + } else { + setLogsRow(row.userid); + getRiderLogs(row.userid); + } + }} + > + {expanded ? : } + + )} )} - {logsRow === row.userid && tabvalue !== 0 && ( + + {/* ============ Collapsible row — live rider logs ============ */} + {expanded && tabvalue !== 0 && ( - - - -
- {/* Header */} - - - Location - Battery - Charging - Speed - Accuracy - Time - Order - Status - - - - {/* Body */} - - - - - - - {riderLogsdata?.latitude}, {riderLogsdata?.longitude} - - - - - - - - {riderLogsdata?.battery || 'N/A'} - - - - - - - {riderLogsdata?.is_charging ? 'Charging' : 'Not Charging'} - - - - - - - {riderLogsdata?.speed} km/h - - - - - - - {riderLogsdata?.accuracy} m - - - - - - - {riderLogsdata?.logdate} - - - - {riderLogsdata?.orderid || 'N/A'} - - - - - - -
- + + + + + + + + + Live telemetry — {row.username || `Rider #${row.userid}`} + + + + + + + + + + + + + )} - + ); })} - {rows?.length != 0 && ( + + {rows?.length !== 0 && ( - +
- {isFetchingNextPage ? : hasNextPage ? : 'No More Riders'} + {isFetchingNextPage || hasNextPage ? ( + + ) : ( + + No more riders + + )}
@@ -493,9 +963,61 @@ const Riders = () => {
- +
); }; +// Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip +// pattern from the pricing page so the telemetry block reads at a glance. +const LogChip = ({ color, icon: Icon, label, value }) => ( + + + + + + + + {label} + + + {value} + + + + +); + export default Riders;