diff --git a/CLAUDE.md b/CLAUDE.md index 78df439..dfdf6de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,8 @@ For the per-page API map and architectural flow chart, see the project skill **` ## 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. +- **React 19.2** + **react-app-rewired 2.2** (CRA, not Next.js — no `pages/` file-system routing; routes live in `src/routes/MainRoutes.js`). +- **Astryx 0.1.9** (`@astryxdesign/core`) is the UI library — see §6. **MUI 5.12** (`@mui/material`) and Ant Design (`antd 5.11`) are **legacy**, present only on the pages not yet converted, and are being removed. Never write new UI against them. - **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. @@ -115,93 +115,102 @@ src/ --- -## 6. Design system (the `DT` token block) +## 6. Design system (Astryx + the `DT` tokens) -**The design system lives in two shared modules. Import from them — never redeclare.** +The UI is **Astryx** (`@astryxdesign/core@0.1.9`). MUI is legacy and on its way out — see §6.6. Discover components with the CLI, don't guess: -```js -import { DT, a, tint, soft, ring, edge, pillFieldSx, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/tokens'; -import { SoftPaper, AccentAvatar } from 'themes/dt/primitives'; +```bash +npx -y @astryxdesign/cli component ``` -| Export | From | What it is | -|---|---|---| -| `DT` | `themes/dt/tokens` | Radii (`radiusPill/Card/Inner/Field`), the three shadows, text/border/surface colours, `DT.brand` (`#C01227`) | -| `a` `tint` `soft` `ring` `edge` | `themes/dt/tokens` | Alpha-suffix helpers — append hex transparency to any accent (`08` / `18` / `26` / `55`) | -| `pillFieldSx` | `themes/dt/tokens` | Filter `Autocomplete`/`TextField` styling. Takes no arguments — the field is neutral white with a brand focus ring on every page. Legacy call sites pass a colour; it's ignored. | -| `tableScrollSx` | `themes/dt/tokens` | Brand-red scrollbar for ``. Spread it: `sx={{ maxHeight: ..., ...tableScrollSx }}` | -| `tableHeadSx` | `themes/dt/tokens` | Uppercase muted header row. Use as `` | -| `tableRowSx` | `themes/dt/tokens` | Body row hairline divider + hover tint. Use as `` | -| `SoftPaper` | `themes/dt/primitives` | `PaperComponent` for every Autocomplete popup | -| `AccentAvatar` | `themes/dt/primitives` | Tinted icon avatar; fills solid when `selected` | +(The root README's `yarn dlx` form does not work here — `yarn` is not on PATH.) -> **Do not paste a local `const DT = {…}` into a page.** Seventeen pages used to -> carry their own copy and they drifted — `orders.js` sat on `radiusCard: 16` -> and the old heavy shadows while every other page had moved to `14` and the -> restrained set, which is exactly why pages stopped matching. They now all -> import. If a token needs to change, change it in `themes/dt/tokens.js` so -> every page moves together. +### 6.1 Where the design system lives -### Page anatomy (every operator page should follow this order) +| Module | What it owns | +|---|---| +| `themes/astryx.js` | **The theme.** `doormileTheme` — brand accent, card/input radii, component overrides. Mounted by `layout/MainLayout` and `login.js` via ``. | +| `themes/dt/status.js` | **The status registry.** `STATUS_META`, `STATUS_ALIASES`, `getStatusMeta()` — every lifecycle state's label, hex, Astryx Badge variant, StatusDot variant, and icon. | +| `themes/dt/tokens.js` | `DT` (radii, shadows, surfaces, `DT.brand`), `STATUS` raw hexes, alpha helpers `a`/`tint`/`soft`/`ring`/`edge`. Its `*Sx` exports are **legacy MUI only**. | +| `themes/dt/primitives.js` | `TableScroll` (sticky-header + brand scrollbar wrapper), `AccentAvatar`, `softPopupStyle`. | +| `components/nearle_components/` | `PageHeader`, `StatCard`, `StatusBadge`, `StatusTabs`, `DebounceSearchBar`, `TableLoader`, … | -> **Use brand red `#C01227` (with light variant `#D25463`) for every brand surface below.** Don't use indigo `#6366f1` — that's reserved for the "Accepted" status badge only. +**Never redeclare these locally.** Seventeen pages once carried private `DT` copies and drifted (`orders.js` sat on `radiusCard: 16` and the old heavy shadows while everything else had moved to `14`); five pages carried private `STATUS_META` copies that disagreed on labels for the same order. Change the shared module so every page moves together. -1. **Gradient header ``** — `linear-gradient(135deg, ${tint('#C01227')} 0%, ${tint('#D25463')} 100%)`, 48px filled `#C01227` 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="#C01227" 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 red `#C01227`; 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 (inactive = `tint` bg + `edge` border, using each tab's **semantic status colour** — pending → amber, delivered → emerald, etc.). The **selected** tab always fills with **brand red `#C01227`** (border, bg, glow ring, hover), regardless of which status it represents — status colour is a resting-state accent only, not a selection colour. See `deliveries.js`'s `STATUS_TABS.map` (canonical) for the exact `active ? '#C01227' : ...` ternary shape; `orders.js`, `riders.js`, `Tenants.js`, and `reports/ordersDetails.js` all follow the same pattern. Search via `DebounceSearchBar` styled with brand red `#C01227` (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 red `edge('#C01227')`. -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 red `#C01227` (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. +### 6.2 Brand colour -### Universal brand colour +**The brand is black (`#000000`)** — `DT.brand`, pinned into the theme as `--color-accent`. The palette is deliberately quiet: chrome is neutral, and colour carries *meaning only* (status). Do not introduce a second brand accent. -**`#C01227` — Doormile Express brand red.** 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. +> Astryx's accent generator derives a ramp from the accent's hue and lightness. Black has neither, so feeding it `#000000` made the generator invent a hue and render every primary button magenta. That's why `themes/astryx.js` pins the accent tokens explicitly instead of using `color: { accent }`. Don't "simplify" it back. -Variants (also from `theme/default.js`): +Anything genuinely brand-toned should read `var(--color-accent)` / `DT.brand`, never a literal `#000000`. -| Token | Hex | Use | -|---|---|---| -| `primary.lighter` | `#F5DBDE` | Very subtle wash bg | -| `primary.light` / `primary.400` | `#D25463` | Gradient pair with main | -| `primary.main` | `#C01227` | Brand primary — default for all brand surfaces | -| `primary.dark` | `#910E1D` | Hover / pressed states | -| `primary.darker` | `#48070F` | Deep contrast text on light bg | +### 6.3 Status palette (semantic — the only place colour is allowed to shout) -**Page header / dialog header gradient:** `linear-gradient(135deg, ${tint('#C01227')} 0%, ${tint('#D25463')} 100%)` (subtle wash) or `linear-gradient(135deg, #C01227 0%, #D25463 100%)` (solid, for dialog titles). +Never render a status by picking a colour by hand. Resolve it: -> **Off-brand accents are gone.** `deliveries.js`'s old indigo `#6366f1` brand -> accent, `Tenants.js`'s purple `#662582`, and `multipleOrders.js`'s antd blue -> `#1890ff` / purple `#65387a` have all been retired — brand surfaces are -> `#C01227` and metric accents come from the status palette below. Indigo -> `#6366f1` now appears only as the "Accepted" status colour. +```jsx +import StatusBadge from 'components/nearle_components/StatusBadge'; -### Status palette (semantic — distinct from brand) + // handles casing, backend enums, unknowns +``` -These colour-code lifecycle states. Do **not** swap them for brand red on status **badges** (table-row status pills, `AccentAvatar` dropdown icons, KPI tiles) — operators rely on the colour to identify status at a glance in those read-only contexts. +`getStatusMeta()` accepts a canonical key (`pending`), a raw backend enum (`miler_assigned`, `converted_to_consignment`), or any casing, and falls back to a neutral badge showing the raw string so an unmapped state is visible rather than blank. -**Exception — selected status tabs:** the one deliberate place brand red *does* replace the status colour is a **selected** pill tab (§4 above) — clicking "Pending"/"Active"/etc. fills that tab red, not amber/green, so the clicked state always reads as "this is the active filter" in the one consistent brand colour. The status colour still shows on every *unselected* tab (as a tint) and on the row badges underneath, so at-a-glance status identification in the table itself is unaffected. +| Meaning | Hex | Astryx `Badge` | `StatusDot` | +|---|---|---|---| +| Pending / waiting | `#f59e0b` | `yellow` | `warning` | +| Accepted / assigned | `#6366f1` | `blue` | `accent` | +| Arrived | `#06b6d4` | `cyan` | `accent` | +| Picked up | `#8b5cf6` | `purple` | `accent` | +| Active / in-transit | `#14b8a6` | `teal` | `success` | +| Delivered / success | `#10b981` | `green` | `success` | +| Skipped | `#f97316` | `orange` | `warning` | +| Cancelled / error | `#ef4444` | `error` | `error` | +| Neutral / unknown | `#94a3b8` | `neutral` | `neutral` | -| Meaning | Colour | -|--------------------------|-----------| -| Pending / waiting | `#f59e0b` (amber) | -| Accepted / assigned | `#6366f1` (indigo — semantically distinct from brand red) | -| 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` | +Badges use the **tinted** non-semantic variants on purpose. Astryx reserves the solid `success`/`warning`/`error` variants for states that demand attention, and a table where every row shouts is a table where nothing does — `cancelled` is the one lifecycle state that keeps a loud variant. -### Don'ts for the design system +The hexes in `STATUS` (tokens.js) are the raw layer, for surfaces taking an arbitrary accent (`StatCard color=`, `AccentAvatar`, chart series). Use `status.js` to *render*; use `STATUS` only when you need a bare colour. -- 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. +### 6.4 Page anatomy + +Follow this order. **`pages/nearle/hubs/hubs.js` is the reference implementation** — copy its structure. + +1. **``** — title, `live` subtitle with pulsing `StatusDot`, and an `action` slot (primary `Button`, zone picker). Plain and un-boxed; the old gradient header Paper is gone. +2. **KPI row** — `` of ``. `color` is a status hex, or `DT.brand` for the primary tile. No top-stripes, no rainbow. +3. **``** — the status filter strip. Supply the tab *order* and where each count comes from; labels and icons resolve from the registry. +4. **Filter + search bar** — a `` holding an `HStack justify="between"` with the row count on the left and `` on the right. +5. **Table** — `` → `` → ``. `TableScroll` supplies the sticky header and scrollbar that MUI's `TableContainer` used to. +6. **Status cells** — ``. Never a hand-rolled tinted `Box`. +7. **Row actions** — ``, `variant="destructive"` for delete. Always pass `label` **and** `tooltip`. +8. **Empty state** — `` inside a full-width `TableCell colSpan={n}`. Never antd ``. +9. **Loading** — `` for rows, `` for a blocking full-page backdrop. These are not interchangeable. + +### 6.5 Astryx rules and known gotchas + +- **No `
` for layout.** `HStack` / `VStack` / `Grid` / `Center` / `Stack` do spacing and alignment. No raw `` wrappers either. +- **No raw values.** Colours and spacing come from tokens (`var(--color-*)`, `var(--spacing-*)`, `var(--radius-*)`). An inline `style` is the escape hatch *only* for a caller-supplied status hex, which the token system genuinely can't express (see `StatCard`). +- **No `sx` prop** — it doesn't exist. Restyle through `themes/astryx.js` so every call site moves at once. +- **`Grid` takes `columns={{ minWidth: N }}`**, not `{ base, sm }` breakpoints. +- **`Table` children-mode cells have no `align` prop** — use `style`. +- **There is no `Tabs` and no `Stepper`** in 0.1.9. Use `TabList`/`Tab` (with `endContent` for counts, `TabMenu` for overflow) and `SegmentedControl`/`ProgressBar` for wizard steps. +- **`Spinner` maxes out at 18px** — large loaders are CSS rings. +- **`Selector` doesn't forward `ref`**; **`Typeahead` has no freeSolo**, which is why `AddressAutocomplete` is a `TextInput` plus a hand-rolled list. +- **Astryx has 153 components** — search before hand-rolling: `npx -y @astryxdesign/cli search ""`. + +### 6.6 Migration status + +Converted: the app shell, `login.js`, the maintenance pages, the entire shared-component layer, and the pages `hubs`, `vehicles`, `reports/{profitability, RidersRoutes, mapWithRoute}`. + +Still MUI: 25 operator pages and 9 `src/themes/*` files. Four pages still import antd ``. + +Rules while both stacks coexist: + +- A page is converted **whole**, never half. Mixed-stack pages are how the palette drifts. +- `ThemeCustomization` must stay mounted in `App.js` until the final page converts; the MUI theme files and the `@mui/*` / `@emotion/*` / `antd` dependencies are removed only after that. +- `OrdersTableSkeleton` renders rows *into* a page's table, so it can only convert alongside its last consumer. Converted pages use `TableLoader` instead. +- Don't add a call site for anything marked ⛔ LEGACY in `tokens.js`. --- @@ -275,7 +284,7 @@ These colour-code lifecycle states. Do **not** swap them for brand red on status ## 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 `src/themes/dt/tokens.js` (values, `pillFieldSx`, the three `table*Sx` helpers) and `src/themes/dt/primitives.js` (`SoftPaper`, `AccentAvatar`). Import from them, don't redesign and don't re-declare locally. `src/pages/nearle/deliveries/deliveries.js` remains the reference for how those tokens are *composed* into a page (filter bar → tabs → table), but it is no longer where the values live. +- **For shared design patterns** between pages, the sources of truth are `src/themes/astryx.js` (the theme), `src/themes/dt/status.js` (lifecycle rendering), `src/themes/dt/tokens.js` (radii, shadows, raw hexes), and `src/themes/dt/primitives.js` (`TableScroll`, `AccentAvatar`). Import from them; don't redesign and don't re-declare locally. `src/pages/nearle/hubs/hubs.js` is the reference for how they *compose* into a page (header → KPIs → tabs → filter → table). Astryx v0.1.9 · 153 components diff --git a/src/components/nearle_components/CLAUDE.md b/src/components/nearle_components/CLAUDE.md index 6575a6f..3752def 100644 --- a/src/components/nearle_components/CLAUDE.md +++ b/src/components/nearle_components/CLAUDE.md @@ -19,55 +19,49 @@ These components are imported across every page. Changes here have **fan-out imp |---|---|---| | `PageHeader.js` | Page title + live subtitle + action slot | ✅ **Astryx** — `Heading`/`Text`/`HStack`/`StatusDot` | | `StatCard.js` | KPI / metric tile | ✅ **Astryx** — `Card`/`Text`/`Center`/`Skeleton` | +| `StatusBadge.js` | Per-row lifecycle badge | ✅ **Astryx** — `Badge` driven by `themes/dt/status.js` | +| `StatusTabs.js` | Status filter strip with counts | ✅ **Astryx** — `TabList`/`Tab`/`Badge` | | `DebounceSearchBar.js` | 500ms-debounced search with ⌘/Ctrl+K focus | ✅ **Astryx** — `TextInput`; **no `sx` prop** (see §4) | -| `LocationAutocomplete.js` | Zone picker on every operator page | MUI — has `pill` variant matching DT system | -| `LoaderWithImage.js` | Inline branded spinner for "loading more" rows | MUI | +| `TableLoader.js` | Inline table loading rows | ✅ **Astryx** — use this, not `OrdersTableSkeleton` | +| `LocationAutocomplete.js` | Zone picker on every operator page | ✅ **Astryx** | +| `AddressAutocomplete.js` | Nominatim address search | ✅ **Astryx** — `TextInput` + hand-rolled list (`Typeahead` has no freeSolo) | +| `LoaderWithImage.js` | Inline branded spinner for "loading more" rows | ✅ **Astryx** | | `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 `PageHeader.js`. | + +Every component in this folder is on Astryx. `SearchBar.js` and `TitleCard.js` were deleted during the migration — if you find a reference to either, it's stale: use `DebounceSearchBar` and `PageHeader`. --- -## 2. Design system token discipline +## 2. Design system 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 = {`). +The design system is documented in the root `CLAUDE.md` §6. Read it before editing anything here. -**Hard rules when editing components here:** +**Hard rules when editing components in this folder:** -- **Universal brand colour is `#C01227`** (Doormile Express red — 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: `#C01227 → #D25463`. -- **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`, cyan `#06b6d4`, teal `#14b8a6`, orange `#f97316`, muted `#94a3b8`, indigo `#6366f1` (Accepted status). Don't replace these with brand red — 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`) 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 `#C01227`. When you next edit one of the legacy pages, migrate it to brand red in the same PR. +- **The brand is black** (`DT.brand`, pinned as `--color-accent` in `themes/astryx.js`). Colour carries *meaning only* — chrome stays neutral. Read the accent as `var(--color-accent)`, never a literal `#000000`. +- **Never render a status by picking a colour by hand.** Use ``, or `getStatusMeta()` from `themes/dt/status.js` if you need the parts. It resolves backend enums, aliases, and casing, and degrades to a neutral badge for unknown states. +- **A raw hex is acceptable in exactly one situation**: a caller-supplied status accent that the token system can't express — `StatCard`'s `color` prop and `AccentAvatar`. Everything else uses `var(--color-*)`, `var(--spacing-*)`, `var(--radius-*)`. +- **No `
`/`` for layout.** `HStack`, `VStack`, `Grid`, `Center`, `Stack`. +- **No `sx` prop** — it doesn't exist in Astryx. Restyle through `themes/astryx.js` so every call site moves together. +- **Check the real API before using a component**: `npx -y @astryxdesign/cli component `. (`yarn dlx` from the generated block does not work — `yarn` is not on PATH.) --- -## 3. `LocationAutocomplete` — the `pill` prop is opt-in - -The component supports two visual modes, controlled by the `pill` prop: +## 3. `LocationAutocomplete` — the zone picker ```jsx -// Default (legacy, "Select Zones" label, outlined TextField) — used by old pages - - -// Pill variant — used by all redesigned pages (deliveries, tenants, pricing, customers) } + locaName={locaName} + setAppId={setAppId} + setLocoName={setLocoName} + setPage={setPage} // optional — resets pagination on zone change 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). +- The old `pill` / `accentColor` / `icon` / `paperComponent` props are **gone**. There is one visual mode now: appearance comes from the Astryx theme, so the picker looks identical on every page. If you find a call site passing them, it's stale. +- The list is derived from `GET /admin/hubs`, not a real zones endpoint — the `applocationid` "zone" concept was jupiter-only and most list endpoints on the new backend don't accept it as a filter. Don't assume selecting a zone actually filters a page you haven't checked (root `CLAUDE.md` §11). + --- @@ -93,10 +87,10 @@ The component supports two visual modes, controlled by the `pill` prop: Only when: 1. Used by **two or more pages** in production code. -2. Has its own internal state or shortcuts (otherwise just use an `AccentAvatar` + `Box` inline). +2. Has its own internal state or shortcuts (otherwise compose Astryx primitives inline). 3. The component encapsulates a non-trivial pattern that's been duplicated more than twice. -Don't add a wrapper that just renames an MUI primitive (e.g. ``). Don't add a component that's a single instance of a styled `Paper`. +Don't add a wrapper that just renames an Astryx primitive (e.g. ``) — restyle it in the theme instead. Don't add a component that's a single instance of a styled `Card`. --- @@ -109,10 +103,19 @@ Don't add a wrapper that just renames an MUI primitive (e.g. ``). > to `14` and the restrained set. That drift is what made pages stop looking > alike. -`DT`, the alpha helpers (`a`/`tint`/`soft`/`ring`/`edge`), `pillFieldSx`, and -the table chrome (`tableScrollSx`/`tableHeadSx`/`tableRowSx`) live in -`src/themes/dt/tokens.js`. `SoftPaper` and `AccentAvatar` live in -`src/themes/dt/primitives.js`. **Import them. Never re-declare them in a page.** +The same failure repeated with status: five pages each grew a private +`STATUS_META`, and they disagreed on what to call the same order — that is why +`themes/dt/status.js` now exists. + +`DT`, the raw `STATUS` hexes, and the alpha helpers (`a`/`tint`/`soft`/`ring`/`edge`) +live in `src/themes/dt/tokens.js`. Lifecycle rendering lives in +`src/themes/dt/status.js`. `TableScroll` and `AccentAvatar` live in +`src/themes/dt/primitives.js`. The theme itself lives in `src/themes/astryx.js`. +**Import them. Never re-declare them in a page.** + +The `pillFieldSx` / `table*Sx` exports in `tokens.js` are MUI-only and marked +⛔ LEGACY — they exist solely for the pages awaiting conversion and are deleted +with the MUI dependency. Don't add a call site. If a page genuinely needs a different value, it should be a new named export in the shared module, not a local override — that way the divergence is visible in diff --git a/src/components/nearle_components/StatusBadge.js b/src/components/nearle_components/StatusBadge.js new file mode 100644 index 0000000..fbf5511 --- /dev/null +++ b/src/components/nearle_components/StatusBadge.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; + +import { Badge } from '@astryxdesign/core/Badge'; + +import { getStatusMeta } from 'themes/dt/status'; + +// ==============================|| STATUS BADGE ||============================== // +// The per-row lifecycle indicator for every operator table. +// +// This replaces the hand-rolled "soft pill" that each page used to build out of +// an plus a tinted — six near-identical copies that drifted +// on padding, radius, and icon size. Astryx's Badge already is that pill, so the +// only thing worth sharing is the status → variant lookup, which now lives in +// themes/dt/status.js. +// +// +// +// `status` accepts a canonical key (`pending`), a raw backend enum +// (`miler_assigned`), or any casing — getStatusMeta resolves all three and +// falls back to a neutral badge showing the raw string, so an unmapped status +// is visible rather than silently blank. +// +// Pass `label` only to override the registry's wording for a page-specific +// nuance; the default keeps every page calling the same state the same thing, +// which is the whole point of the registry. + +export default function StatusBadge({ status, label, showIcon = true }) { + const meta = getStatusMeta(status); + const Icon = meta.icon; + + return : undefined} />; +} + +StatusBadge.propTypes = { + status: PropTypes.string, + label: PropTypes.node, + showIcon: PropTypes.bool +}; diff --git a/src/components/nearle_components/StatusTabs.js b/src/components/nearle_components/StatusTabs.js new file mode 100644 index 0000000..d787de5 --- /dev/null +++ b/src/components/nearle_components/StatusTabs.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; + +import { TabList, Tab } from '@astryxdesign/core/TabList'; +import { Badge } from '@astryxdesign/core/Badge'; + +import { getStatusMeta } from 'themes/dt/status'; + +// ==============================|| STATUS TABS ||============================== // +// The top-level status filter strip on every list page. +// +// Under MUI these were hand-rolled clickable pills, because MUI's +// couldn't carry a count chip or a per-tab accent. Astryx's Tab has an +// `endContent` slot and a `selectedIcon`, so the pills are now real tabs — +// which also means arrow-key navigation and correct `role="tab"` semantics +// come for free instead of being missing (the old pills were plain divs with +// onClick, which is half of Dispatch.js's outstanding a11y lint errors). +// +// +// +// Each tab's `status` resolves through the shared registry for its label and +// icon, so a page only supplies the ORDER of the tabs and where each count +// comes from. Pass `label` on an entry to override the registry wording. +// +// Counts render as a neutral Badge — informational, not urgent. Astryx's Badge +// guidance reserves the loud `error` variant for counts that demand action, so +// a page that wants that (an exceptions queue, say) passes `isUrgent` on the +// entry rather than every count screaming by default. + +export default function StatusTabs({ value, onChange, tabs, size = 'md', hasDivider = true }) { + return ( + + {tabs.map((tab) => { + const meta = getStatusMeta(tab.status); + const Icon = meta.icon; + const hasCount = tab.count !== undefined && tab.count !== null; + + return ( + } + endContent={hasCount ? : undefined} + /> + ); + })} + + ); +} + +StatusTabs.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + tabs: PropTypes.arrayOf( + PropTypes.shape({ + status: PropTypes.string.isRequired, + label: PropTypes.node, + count: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + isUrgent: PropTypes.bool + }) + ).isRequired, + size: PropTypes.oneOf(['sm', 'md', 'lg']), + hasDivider: PropTypes.bool +}; diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 3c4154d..e8c2e24 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -611,6 +611,20 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { deliverytype: customer ? 'B' : 'C', orderdate: b.createdat, deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat, + // ⚠ NOT a real assignment time. The Doormile bookings feed has no + // assignment timestamp (the true one lives on `bookingassignments`, + // reachable only per-booking via GET /admin/bookings/:id/track), so this + // is the booking's last-modified column. It moves every time ANYTHING + // touches the row — status change, parcel scan, payment, pickup-complete. + // + // **Never bucket or group by this field.** Dispatch.js and deliveries.js + // used to bucket their Morning/Afternoon/Evening batches on it, which + // meant an order re-stamped during the evening silently jumped out of the + // batch it was actually assigned to and into whichever window contained + // the current clock time — the same orders appearing under Afternoon and + // then Evening on the same day. Both now bucket on + // `expecteddeliverytime`, which is stable. It remains fine to DISPLAY + // this as a "last updated" stamp, which is all the reports use it for. assigntime: b.updatedat, orderstatus: mapBookingStatusToDeliveryStatus(b.status), droplat: b.deliverylatitude, diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index 40e2536..a57bced 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -1,3068 +1,3040 @@ -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 dayjs from 'dayjs'; -var utc = require('dayjs/plugin/utc'); -dayjs.extend(utc); -import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi'; -import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared'; -import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; -import { useTheme } from '@mui/material/styles'; -import { - MdOutlineDateRange, - MdAccessTime, - MdAllInclusive, - MdLightMode, - MdWbSunny, - MdNightsStay, - MdCheck, - MdStorefront, - MdLocationOn, - MdDirectionsBike, - MdNotificationsActive, - MdPersonPin, - MdHistoryToggleOff, - MdCheckCircle, - MdCancel, - MdInventory2, - MdHourglassEmpty, - MdRoute, - MdSkipNext, - MdTune, - MdOutlineLocalShipping, - MdOutlinePendingActions, - MdOutlineCheckCircle, - MdOutlineCancel -} from 'react-icons/md'; -import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; - -import { - Avatar, - Box, - Button, - Chip, - Grid, - IconButton, - Stack, - Typography, - Table, - TableCell, - TableBody, - TableHead, - Collapse, - Dialog, - TableRow, - DialogContent, - DialogTitle, - Tooltip, - DialogActions, - Checkbox, - Autocomplete, - TextField, - TableContainer, - Backdrop, - MenuItem, - Menu, - Paper, - useMediaQuery -} from '@mui/material'; - -import { addDays, addMonths, addWeeks, endOfMonth, endOfWeek, startOfMonth, startOfWeek } from 'date-fns'; -import { DateRangePicker } from 'components/nearle_components/DateRangePicker'; -import * as React from 'react'; -import Loader from 'components/Loader'; -import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material'; - -import { - cancelDeliveryAPI, - changeRiderAPI, - fetchCountAPI, - fetchDeliveries, - fetchRidersList, - notifyRider, - updateDeliveryAPI, - getorderdetails, - gettenantlocations, - getTenants -} from 'pages/api/api'; -import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; -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'; -import PageHeader from 'components/nearle_components/PageHeader'; -import StatCard from 'components/nearle_components/StatCard'; -import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; - -// ============================================================================ -// Design tokens — shared across every DT-styled operator page so filters, -// KPIs, tabs, status badges, and dialogs stay visually consistent. -// See src/themes/dt/tokens.js for the canonical source. -// ============================================================================ -import { DT, tint, soft, ring, edge, pillFieldSx } from 'themes/dt/tokens'; - -// 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: MdOutlineLocalShipping }, - { key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdOutlinePendingActions }, - { key: 'delivered', label: 'Delivered Orders', color: '#10b981', icon: MdOutlineCheckCircle }, - { key: 'cancelled', label: 'Cancelled Orders', color: '#ef4444', icon: MdOutlineCancel } -]; - -// 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* -// batch (or none, if the gap isn't covered). -const BATCH_OPTIONS = [ - { id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' }, - { id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 }, - { - id: 'afternoon', - label: 'Afternoon Batch', - range: '9 AM to 12:30 PM', - color: '#f59e0b', - iconKey: 'afternoon', - startHour: 9, - endHour: 12.5 - }, - { id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 } -]; - -// Per-batch icon components — kept separate from BATCH_OPTIONS so the option -// objects stay serializable / printable. Looked up at render time by iconKey. -const BATCH_ICONS = { - all: MdAllInclusive, - morning: MdLightMode, - afternoon: MdWbSunny, - evening: MdNightsStay -}; - -// Auto-pick the batch matching the operator's LOCAL wall-clock hour so the -// page lands them on the slot they're most likely curious about. Falls back -// to 'all' when the current hour lies in a configured gap (8–9 AM, 12 PM–4 -// PM, after 7 PM). Local time, not UTC, to match the dispatch page's -// bucketing — both pages must agree on which batch a given row belongs to. -const detectInitialBatchId = () => { - const now = dayjs(); - const h = now.hour() + now.minute() / 60; - for (const b of BATCH_OPTIONS) { - if (b.id === 'all') continue; - if (h >= b.startHour && h < b.endHour) return b.id; - } - return 'all'; -}; - -// Bucket by `assigntime` only — matches the spec ("bucketed by assigntime") -// and the dispatch page's default time-field selection (`Dispatch.js:763` -// initialises `selectedTimeField` to `'assigned'`, whose `keys` is just -// `['assigntime']`). The previous priority cascade started with -// `deliverytime`, which mis-bucketed delivered orders into the wave they -// were *completed* in rather than the wave they were *assigned to* — so an -// order assigned at 07:00 (Morning) but delivered at 11:00 fell into the -// 8–9 AM / 12 PM–4 PM gap and silently disappeared from every batch on this -// page while still showing up on dispatch's Morning Batch. Rows without an -// `assigntime` (unassigned pending orders) return null and intentionally -// don't bucket — matches dispatch. -const BATCH_TIME_KEYS = ['assigntime']; - -// Returns one of the batch ids in BATCH_OPTIONS (excluding 'all'), or null -// when the row has no usable timestamp / falls in a gap between batches. -const getRowBatchId = (row) => { - let t = null; - for (const k of BATCH_TIME_KEYS) { - if (row?.[k]) { - t = row[k]; - break; - } - } - if (!t) return null; - const str = String(t).trim(); - // Bare YYYY-MM-DD with no time component would always parse to midnight and - // get mis-bucketed into Morning — skip those. - if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; - // Parse in LOCAL time to match the dispatch page's bucketing exactly - // (`Dispatch.js:218`). Dispatch never uses `.utc()` for batch assignment, - // so a row that lands in Evening Batch on the dispatch page must also land - // in Evening Batch here — otherwise an operator running in IST/PST/etc. - // sees different totals on the two pages for the same underlying rows. - // parseDoormileTimestamp (not bare dayjs()) strips a false trailing Z some - // Doormile timestamps carry (see utils/doormileTimestamp.js) — without - // this, a booking assigned "just now" could get shifted +5:30 (IST) past - // every batch window into a gap, silently vanishing from the table even - // though it was correctly assigned server-side. - const d = parseDoormileTimestamp(t); - if (!d.isValid()) return null; - const h = d.hour() + d.minute() / 60; - for (const b of BATCH_OPTIONS) { - if (b.id === 'all') continue; - if (h >= b.startHour && h < b.endHour) return b.id; - } - 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'); - const theme = useTheme(); - // Below `md` we swap the wide data table for an app-style card list. Desktop - // (md and up) keeps the exact same table — no behaviour change either way. - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - const loadMoreRef = useRef(); - const containerRef = useRef(); - const [deliverylist, setDeliverylist] = useState([]); - const [dialogopen, setDialogopen] = useState(false); - const [locaName, setLocoName] = useState('All'); - const [appId, setAppId] = useState(0); - const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD')); - const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD')); - const [tabstatus, setTabstatus] = useState('Pending'); - const [tabvalue, setTabvalue] = useState(0); - const [open, setOpen] = useState(false); - const [datestatus, setDatestatus] = useState('Today'); - const [kms, setKms] = useState(''); - const [cumulativekms, setCumulativeKms] = useState(); - const [deliveryamount, setDeliveryamount] = useState(); - const [notes, setNotes] = useState(''); - const [currentorder, setCurrentorder] = useState({}); - - // Real Actual KMs for the Update Status dialog, computed from real GPS - // ping history instead of leaving the field permanently blank (there is - // no backend-stored actual-distance field — see fetchDeliveries in - // api.js). Same sort -> Kalman-smooth -> polyline-length pipeline - // Dispatch.js's Compare feature already uses to show a real "actual km" - // figure, just reused here (see dispatchShared.js). Keyed off - // consignmentid, NOT deliveryid/bookingid — GET /admin/consignments/:id/logs - // is consignment-scoped (the same id-class mix-up already fixed in - // updateDeliveryAPI this session). - const { data: computedActualKm } = useQuery({ - queryKey: ['deliveryActualKm', currentorder?.consignmentid], - queryFn: async () => { - let rows = []; - try { - rows = (await getConsignmentLogs(currentorder.consignmentid)) || []; - } catch { - return null; - } - const sorted = rows - .map((r) => { - const ts = r?.logdate ? dayjs(r.logdate) : null; - return { - lat: parseFloat(r?.latitude ?? r?.lat), - lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon), - logdate: r?.logdate, - _ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER - }; - }) - .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng)) - .sort((a, b) => a._ts - b._ts); - // Fewer than 2 usable pings (order not yet delivered, or no telemetry - // recorded) — a real, expected case, not an error. Leave the field - // to its existing blank/manual-entry behavior. - if (sorted.length < 2) return null; - const smoothed = kalmanSmoothGps(sorted); - const km = polylineLengthKm(smoothed.map((p) => [p.lat, p.lng])); - return km > 0 ? km : null; - }, - enabled: dialogopen && !!currentorder?.consignmentid, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false - }); - - // Only pre-fill while the field is still empty — never overwrite an - // operator's own edit (or an earlier pre-fill) if this query happens to - // re-run while the dialog is already open. - useEffect(() => { - if (computedActualKm != null && !cumulativekms) { - setCumulativeKms(Number(computedActualKm.toFixed(2))); - } - }, [computedActualKm]); - const [deliverylat, setDeliverylat] = useState(''); - const [deliverylong, setDeliverylong] = useState(''); - const [currentStatus, setCurrentStatus] = useState('pending'); - const [updateStatus, setUpdateStatus] = useState('delivered'); - const locationRef = useRef(null); - const tenantRef = useRef(null); - const [page, setPage] = React.useState(0); - const [rowsPerPage, setRowsPerPage] = React.useState(50); - const [totalCount, setTotalCount] = React.useState(); - const [productCollapse, setProductCollapse] = useState(null); - const [orderHeaderid, setOrderHeaderId] = useState(null); - const [searchword, setSearchword] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [menuAnchorEl, setMenuAnchorEl] = React.useState(null); - const [selectedRow, setSelectedRow] = useState(null); - const [loading1, setLoading1] = useState(false); - const [anchorEl, setAnchorEl] = React.useState(null); - const [open2, setOpen2] = useState(''); - const [cancelDeliveryOpen, setCancelDeliveryOpen] = useState(false); - const [changeDialogOpen, setChangeDialogOpen] = useState(false); - const [cancelFeed, setCancelFeed] = useState(''); - const [selectedRider, setSelectedRider] = useState(null); - const [tenantid, setTenantid] = useState(0); - const [locationid, setLocationid] = useState(0); - const [tenantValue, setTenantValue] = useState(null); - const [locationValue, setLocationValue] = useState(null); - const [riderid, setRiderid] = useState(0); - // Selected batch id — drives the client-side row filter. Defaults to the - // batch matching the current UTC hour (the operator is most likely curious - // about "now"); never `null` since there's no longer an "All" option. - const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId); - - useEffect(() => { - setTenantid(0); - setTenantValue(null); - setLocationid(0); - setLocationValue(null); - }, [appId]); - // to clear the location autocomplete - useEffect(() => { - setLocationid(0); - setLocationValue(null); - }, [tenantid]); - - const menuOpen = Boolean(menuAnchorEl); - - const handleMenuOpen = (event, row) => { - setSelectedRow(row); - - setMenuAnchorEl(event.currentTarget); - }; - - const handleMenuClose = () => { - setMenuAnchorEl(null); - }; - - // =========================================== || cancelDelivery || =========================================== - - const { mutate: cancelDelivery } = useMutation({ - mutationFn: ({ selectedRow, cancelFeed }) => cancelDeliveryAPI(selectedRow, cancelFeed), - onSuccess: () => { - opentoast('Delivery Cancelled Successfully', 'success'); - setCancelDeliveryOpen(false); - fetchCountRefetch(); // Refresh count data - countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips - }, - onError: (error) => { - opentoast(error.message, 'error'); - } - }); - - // ==============================|| cancelridernotification ||============================== // - - const cancelridernotification = async () => { - try { - // /admin/milers/:id/notify keys off milerprofileid, not the userid - // stored on the booking (confirmed live) — see fetchDeliveries in api.js. - return await notifyMiler(selectedRow.milerprofileid, 'DoormileXpress', `${selectedRow.orderid} have been Cancelled`); - } catch (err) { - opentoast(err.message, 'error', 2000); - } - }; - - // ==============================|| getTenants ||============================== // - - const { - data: tenantlist, - isLoading: fetchtenantsIsLoading, - isError: fetchtenantsIsError, - error: fetchtenantsError - } = useQuery({ - queryKey: ['tenantlist', appId], - queryFn: () => getTenants(appId), // Ensure appId is passed - // appId 0 is a real, deliberate choice — the synthetic "All" zone option - // (fetchAppLocations in api.js) — not "nothing picked yet"; only - // null/undefined means that now. See LocationAutocomplete.js's onChange. - enabled: appId != null - }); - // ==============================|| gettenantlocations ||============================== // - - const { - data: locationlist, - isLoading: fetchlocationsIsLoading, - isError: fetchlocationsIsError, - error: fetchlocationsError - } = useQuery({ - queryKey: ['gettenantlocations', tenantid], - queryFn: () => gettenantlocations(tenantid), // Ensure appId is passed - enabled: tenantid !== 0 // Ensures query runs only when appId is valid - }); - // =========================================== || notifyrider || =========================================== - - const notifyRiderMutation = useMutation({ - mutationFn: notifyRider, // Using the separate function - onSuccess: () => { - enqueueSnackbar('Notification sent Successfully', { - variant: 'success', - anchorOrigin: { vertical: 'top', horizontal: 'right' }, - autoHideDuration: 2000 - }); - }, - onError: (error) => { - enqueueSnackbar(error.message, { - variant: 'error', - anchorOrigin: { vertical: 'top', horizontal: 'right' }, - autoHideDuration: 2000 - }); - } - }); - - // =========================================== || changerider || =========================================== - const changeRiderMutation = useMutation({ - mutationFn: ({ selectedRider, selectedRow }) => changeRiderAPI(selectedRider, selectedRow), - onSuccess: (res, { selectedRider, selectedRow }) => { - setLoading1(false); - setChangeDialogOpen(false); - if (res.success) { - logger.info( - `Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname}` - ); - opentoast('Rider Changed Successfully', 'success'); - } - fetchCountRefetch(); // Refresh count data - countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips - // notifyMiler wants a miler profile id, not an FCM token — the new API - // looks the device token up server-side. selectedRider comes straight - // from getMilers(), which has milerprofileid directly (confirmed live; - // .userid there is the underlying app-user id, a different resource). - notifyRiderMutation.mutate(selectedRider.milerprofileid); - }, - onError: (err, { selectedRider, selectedRow }) => { - logger.error(`Failed to change rider for order ID ${selectedRow?.orderid}:`, err); - opentoast(err.message, 'error'); - setLoading1(false); - } - }); - - /* ============================================= || opentoast || ============================================= */ - - const opentoast = (message, variant) => { - enqueueSnackbar(message, { - variant, - anchorOrigin: { vertical: 'top', horizontal: 'right' }, - autoHideDuration: 2000 - }); - }; - - // ==============================|| getorderdetails ||============================== // - - const { data: orderdetails } = useQuery({ - queryKey: ['orderdetails', orderHeaderid], - queryFn: () => getorderdetails(orderHeaderid), - enabled: !!orderHeaderid // ✅ prevent initial fetch when undefined - }); - - const dialogclose = () => { - setDialogopen(false); - }; - - const handleChangetab = (e, i) => { - setPage(0); - setTabvalue(i); - setRowsPerPage(50); - if (i === 0) { - setTabstatus('Pending'); - setCurrentStatus('pending'); - setTotalCount(countData?.uncoveredLength); - } - if (i === 1) { - setTabstatus('Assigned'); - setCurrentStatus('accepted'); - setTotalCount(countData?.assignedLength); - } - if (i === 2) { - setTabstatus('Arrived'); - setCurrentStatus('arrived'); - setTotalCount(countData?.arrivedLength); - } - if (i === 3) { - setTabstatus('Picked'); - setCurrentStatus('picked'); - setTotalCount(countData?.pickedLength); - } - if (i === 4) { - setTabstatus('Active'); - setCurrentStatus('active'); - setTotalCount(countData?.activeLength); - } - if (i === 5) { - setTabstatus('Skipped'); - setCurrentStatus('skipped'); - setTotalCount(countData?.skippedLength); - } - if (i === 6) { - setTabstatus('Delivered'); - setCurrentStatus('delivered'); - setTotalCount(countData?.coveredLength); - } - - if (i === 7) { - setTabstatus('Cancelled'); - setCurrentStatus('cancelled'); - setTotalCount(countData?.cancelLength); - } - setSearchword(''); - }; - - const okclicked = () => { - setOpen(false); - }; - - // (filteredRows is defined below, after countSourceRows / countSourceLoading - // are in scope — they're the single source of truth for both the table and - // the per-status chip counts.) - - // Parallel "all-statuses" query used ONLY to derive batch-aware tab counts. - // The primary `fetchdeliveries` query above is keyed by `currentStatus`, so - // its rows are scoped to the active tab — they can't tell us how many - // delivered/cancelled/etc. exist in the same batch. We fetch the full day - // once (status='all'), keep it cached, and re-derive the counts per status - // whenever the operator switches batches. Search keyword is intentionally - // dropped from this key so the chip counts reflect the batch totals rather - // than the search-filtered subset. - const { - data: countSourceData, - fetchNextPage: countFetchNext, - hasNextPage: countHasNext, - isFetchingNextPage: countIsFetchingNext, - isLoading: countSourceIsLoading, - refetch: countSourceRefetch - } = useInfiniteQuery({ - queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid], - queryFn: fetchDeliveries, - getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined - }); - - // Newest activity first — an order just created and assigned should be the - // very first row an operator sees, not buried wherever GET /admin/bookings - // happens to return it (unconfirmed/unspecified server-side order). Every - // row here has been dispatched at least once (fetchDeliveries filters to - // that), so assigntime (booking.updatedat) is the more recent of the two - // timestamps on a fresh assignment; orderdate (createdat) covers the rare - // row missing it. parseDoormileTimestamp (not bare dayjs()) avoids the - // false-Z parsing bug documented in utils/doormileTimestamp.js. - const countSourceRows = useMemo(() => { - const rows = (countSourceData?.pages || []).flatMap((p) => p.rows || []); - return [...rows].sort( - (a, b) => - parseDoormileTimestamp(b.assigntime || b.orderdate).valueOf() - parseDoormileTimestamp(a.assigntime || a.orderdate).valueOf() - ); - }, [countSourceData]); - - useEffect(() => { - if (countHasNext && !countIsFetchingNext) countFetchNext(); - }, [countHasNext, countIsFetchingNext, countFetchNext, countSourceRows.length]); - - // Loading flag for the table empty state — true while we're still streaming - // pages, so we don't show "No Orders" prematurely before the full - // day's rows have arrived. Goes false once auto-pagination drains. - const countSourceLoading = countHasNext || countIsFetchingNext; - - // The table now reads from the same source as the chip counts. That way the - // "74" in the Delivered chip and the rows shown in the table can never - // disagree (they used to, because the table consumed a separate per-status - // paginated query while the chips read from the full all-statuses dataset). - // Three filter steps: - // 1. Batch — selected via the dropdown. 'all' bypasses this filter. - // 2. Tab status — currentStatus comes from handleChangetab. - // 3. Search — client-side substring match across customer/address/order - // fields. Server-side search isn't needed since every row is loaded. - const filteredRows = useMemo(() => { - const wantStatus = String(currentStatus || '').toLowerCase(); - const q = String(debouncedSearch || '') - .trim() - .toLowerCase(); - return countSourceRows.filter((r) => { - if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false; - const s = String(r.orderstatus || '').toLowerCase(); - if (wantStatus && s !== wantStatus) return false; - if (q) { - const hay = [ - r.deliverycustomer, - r.deliveryaddress, - r.deliverysuburb, - r.pickupcustomer, - r.pickupaddress, - r.pickupsuburb, - r.orderid, - r.tenantname, - r.ridername, - r.username - ] - .map((v) => String(v || '').toLowerCase()) - .join(' '); - if (!hay.includes(q)) return false; - } - return true; - }); - }, [countSourceRows, selectedBatch, currentStatus, debouncedSearch]); - - // Counts per status, scoped to the selected batch. Keys mirror the legacy - // *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical - // (countData?.uncoveredLength → batchCounts.uncoveredLength). - const batchCounts = useMemo(() => { - const c = { - uncoveredLength: 0, - assignedLength: 0, - arrivedLength: 0, - pickedLength: 0, - activeLength: 0, - skippedLength: 0, - coveredLength: 0, - cancelLength: 0 - }; - countSourceRows.forEach((r) => { - if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return; - const s = String(r.orderstatus || '').toLowerCase(); - switch (s) { - case 'pending': - c.uncoveredLength += 1; - break; - case 'accepted': - case 'assigned': - c.assignedLength += 1; - break; - case 'arrived': - c.arrivedLength += 1; - break; - case 'picked': - c.pickedLength += 1; - break; - case 'active': - c.activeLength += 1; - break; - case 'skipped': - c.skippedLength += 1; - break; - case 'delivered': - c.coveredLength += 1; - break; - case 'cancelled': - case 'canceled': - c.cancelLength += 1; - break; - default: - break; - } - }); - return c; - }, [countSourceRows, selectedBatch]); - - // Per-batch total (any status) for the dropdown badges. Computed once for - // every batch + 'all' so each option in the menu can show its own count - // without re-walking the list. Mirrors dispatch's batchCounts shape - // (`Dispatch.js:1198–1206`) — totals here should match the per-batch numbers - // visible on the dispatch page for the same date. - const batchTotals = useMemo(() => { - // `all` is the SUM of the three batch buckets — not the raw row count. - // Rows that fall in a gap (8–9 AM, 12:30–4 PM, after 7 PM) or have no - // `assigntime` belong to no batch, so they're excluded here too. This keeps - // the "All Batches" badge equal to Morning + Afternoon + Evening - // (e.g. 36 + 89 + 53 = 178) instead of over-reporting the un-bucketed total. - const totals = { all: 0 }; - BATCH_OPTIONS.forEach((b) => { - if (b.id === 'all') return; - totals[b.id] = 0; - }); - countSourceRows.forEach((r) => { - const id = getRowBatchId(r); - if (id) { - totals[id] = (totals[id] || 0) + 1; - totals.all += 1; - } - }); - return totals; - }, [countSourceRows]); - - // IntersectionObserver now watches the count-source query — that's the - // single source of truth for both the table and the chip counts. When the - // sentinel scrolls into view it nudges the next page of all-statuses - // deliveries to keep the dataset complete. - useEffect(() => { - if (!countHasNext) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - countFetchNext(); - } - }, - { - root: document.querySelector('.MuiTableContainer-root'), - rootMargin: '0px', - threshold: 1.0 - } - ); - if (loadMoreRef.current) observer.observe(loadMoreRef.current); - return () => { - if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); - }; - }, [countHasNext, countFetchNext]); - - const handleScroll = (event) => { - const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; - if (scrollTop + clientHeight >= scrollHeight - 50) { - if (countHasNext && !countIsFetchingNext) { - countFetchNext(); - } - } - }; - - /* ============================================= || fetchPercentageAPI | ============================================= */ - - /* ============================================= || fetchcount | ============================================= */ - - const { - data: countData = {}, // Default to empty object - isLoading: fetchCountIsLoading, - isError: fetchCountIsError, - error: fetchCountError, - refetch: fetchCountRefetch - } = useQuery({ - queryKey: ['fetchCountData', appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid, tabstatus], - queryFn: () => fetchCountAPI(appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid) - }); - useEffect(() => { - if (tabvalue === 0 && countData) { - setTotalCount(countData.uncoveredLength); - } - }, [countData]); - - // ==============================|| fetchRidersList ||============================== // - - const { - data: ridersList = [], - isLoading: riderListIsLoading, - isError: ridersListIsError, - error: ridersListError - } = useQuery({ - queryKey: ['ridersList', appId], // Unique key for caching & re-fetching - // fetchRidersList now toasts + returns [] internally on failure (was - // throwing after already toasting there too, which showed this same - // error twice) — no onError needed here anymore. - queryFn: fetchRidersList, - enabled: appId != null - }); - - /* ============================================= || updatedelivery | ============================================= */ - - const updateDeliveryMutation = useMutation({ - mutationFn: (orderData) => updateDeliveryAPI(orderData), - onSuccess: (res) => { - if (res.success) { - opentoast('Updated Successfully', 'success'); - setDeliveryamount(''); - setNotes(''); - setDialogopen(false); - fetchCountRefetch(); - countSourceRefetch(); - } else { - // A 200 with success:false (the Doormile API's validation-failure - // shape, same pattern as customers.js's saveMutation) had no else - // branch here before — the dialog just sat open with zero feedback, - // looking exactly like the button didn't work. - opentoast(res.message || 'Failed to update delivery', 'error'); - } - }, - onError: (err) => { - opentoast(err.response?.data?.message || err.message || 'Failed to update delivery', 'error'); - } - }); - - const errorMessage = fetchCountIsError - ? `Error fetching count summary: ${fetchCountError?.message}` - : ridersListIsError - ? `Error fetching riders: ${ridersListError?.message}` - : fetchtenantsIsError - ? `Error tenant list: ${fetchtenantsError?.message}` - : fetchlocationsIsError - ? `Error location list: ${fetchlocationsError?.message}` - : null; - - if (errorMessage) { - OpenToast(errorMessage, 'error', 2000); - return null; // or return <> if inside a component - } - - return ( - <> - {(fetchCountIsLoading || countSourceIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading) && ( - <> - - {/* */} - - )} - { - theme.zIndex.drawer + 1 - }} - open={fetchCountIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading} - > - {/* */} - - } - {/* ============================================= || Header | ============================================= */} - - } - /> - - {/* ============================================= || KPI Cards | ============================================= */} - - {[ - { ...KPI_META[0], value: batchTotals.all, percentage: null }, - { - ...KPI_META[1], - value: countData?.uncoveredLength, - percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0 - }, - { - ...KPI_META[2], - value: countData?.coveredLength, - percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0 - }, - { - ...KPI_META[3], - value: countData?.cancelLength, - percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0 - } - ].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; - return ( - - } - color={item.color} - loading={fetchCountIsLoading} - caption={hasPct ? `${Math.abs(pct)}% of total` : undefined} - /> - - ); - })} - - - {/* ============================================= || Filter Bar | ============================================= */} - - - - - - - Filters - - - - - {/* Batch dropdown — replaces the legacy "Deliveries-{datestatus}" and - "Orders-All" pills. Filters the loaded rows by the slot the - operator picks; defaults to 'all'. Mirrors the dispatch page's - batch model. UI mirrors the polished Absent Riders dropdown from - the orders page — custom Paper, per-batch color/icon, selected - indicator. */} - {(() => { - const activeBatch = BATCH_OPTIONS.find((b) => b.id === selectedBatch) || BATCH_OPTIONS[0]; - const ActiveIcon = BATCH_ICONS[activeBatch.iconKey] || MdAccessTime; - return ( - opts} - options={BATCH_OPTIONS} - value={activeBatch} - onChange={(e, val) => val && setSelectedBatch(val.id)} - getOptionLabel={(option) => option.label} - isOptionEqualToValue={(option, value) => option.id === value.id} - PaperComponent={SoftPaper} - ListboxProps={{ sx: { py: 0, maxHeight: 360 } }} - renderOption={(props, option, { selected }) => { - const Icon = BATCH_ICONS[option.iconKey] || MdAccessTime; - const total = batchTotals[option.id] ?? 0; - return ( -
  • - - - - - - {option.label} - - - {option.range} - - - {/* Per-batch total — same number you see next to each - batch on the dispatch page. Lets the operator - eyeball-compare without summing the per-status - chip counts on the tabs. */} - 0 ? `${option.color}18` : '#f1f5f9', - color: total > 0 ? option.color : '#94a3b8', - border: `1px solid ${total > 0 ? option.color + '55' : '#e2e8f0'}`, - '& .MuiChip-label': { px: 0.75 } - }} - /> - {selected && ( - - - - )} -
  • - ); - }} - renderInput={(params) => ( - - - - -
    - ) - }} - sx={{ - minWidth: { xs: 180, sm: 220, md: 240 }, - cursor: 'pointer', - '& .MuiOutlinedInput-root': { - borderRadius: '10px', - bgcolor: '#ffffff', - fontWeight: 600, - color: '#0f172a', - paddingRight: '8px', - cursor: 'pointer', - transition: 'border-color 0.15s, box-shadow 0.15s', - '& fieldset': { borderColor: '#e2e8f0', borderWidth: 1 }, - '&:hover fieldset': { borderColor: '#cbd5e1' }, - '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#000000')}` }, - '&.Mui-focused fieldset': { borderColor: '#000000', borderWidth: 1.5 } - }, - '& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: '#94a3b8' } - }} - /> - )} - /> - ); - })()} - - {/* 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 && ( - setOpen(true)} - sx={{ - pl: 0.5, - pr: 1.25, - py: 0.5, - borderRadius: '10px', - cursor: 'pointer', - bgcolor: '#ffffff', - border: '1px solid #e2e8f0', - transition: 'border-color 0.15s', - '&:hover': { borderColor: '#cbd5e1' } - }} - > - - - - - {dayjs(startdate).format('DD MMM')} – {dayjs(enddate).format('DD MMM')} - - - )} -
    - - {/* Tenant */} - { - if (appId == null) { - event.preventDefault(); - OpenToast('Please select your zone 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) => ( - - - - - - ) - }} - sx={pillFieldSx('#0ea5e9')} - /> - )} - /> - {/* Location */} - (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')} - value={locationValue} - PaperComponent={SoftPaper} - sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }} - onOpen={(event) => { - if (appId == null && !tenantid) { - event.preventDefault(); - OpenToast('Please select your Zone and Tenant first!', 'warning', 3000); - setTimeout(() => locationRef.current?.focus(), 0); - } else if (!tenantid) { - event.preventDefault(); - OpenToast('Please select your 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) => ( - - - - - - ) - }} - sx={pillFieldSx('#14b8a6')} - /> - )} - /> - {/* Rider */} - { - const name = option?.displayname || option?.authname || ''; - return option?.contactno ? `${name} (${option.contactno})` : name; - }} - 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); - }} - onOpen={(event) => { - if (appId == null) { - event.preventDefault(); - OpenToast('Please select your zone first!', 'warning', 3000); - setTimeout(() => locationRef.current?.focus(), 0); - } - }} - renderInput={(params) => ( - - - - - - ) - }} - sx={pillFieldSx('#8b5cf6')} - /> - )} - /> - -
    - {/* ============================================= || Status Tabs + Search || ============================================= */} - - - - {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: DT.radiusField + 'px', - border: `1px solid ${active ? '#000000' : DT.borderSubtle}`, - bgcolor: active ? '#000000' : DT.surface, - color: active ? '#fff' : DT.textSecondary, - fontWeight: 600, - boxShadow: 'none', - transition: 'background-color 0.15s, border-color 0.15s, color 0.15s', - '&:hover': { - borderColor: active ? '#000000' : DT.borderHover, - bgcolor: active ? '#000000' : DT.surfaceAlt - } - }} - > - - - - - {meta.label} - - - {count} - - - ); - })} - - - {/* ============================================= || Search | ============================================= */} - - - - - - {/* ============================================= || Table || ============================================= */} - - - {(() => { - const showAction = tabstatus !== 'Cancelled' && tabstatus !== 'Delivered'; - const showSelect = tabstatus == 'Created'; - const totalCols = 15 + (showAction ? 1 : 0) + (showSelect ? 1 : 0); - return isMobile ? ( - /* ===================== MOBILE: card list ===================== */ - - {filteredRows.length === 0 && !loading1 && !countSourceLoading && ( - - - - - - 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… - - - )} - {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); - const isOpen = productCollapse?.orderid === row?.orderid; - const chipSx = (c) => ({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - color: c, - fontWeight: 700, - fontSize: 12, - whiteSpace: 'nowrap' - }); - return ( - - - - {showSelect && ( - { - if (e.target.checked) { - let arr = deliverylist; - arr.push({ ...row, sno: deliverylist.length + 1 }); - setDeliverylist([...arr]); - } else { - let res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); - if (res) { - let arr = deliverylist; - arr.splice(res.sno - 1, 1); - arr.map((val, i) => { - val.sno = i + 1; - }); - setDeliverylist([...arr]); - } - } - }} - checked={!!deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid)} - /> - )} - - {String(page * rowsPerPage + index + 1).padStart(2, '0')} - - - - - - - {rowStatusMeta.label} - - - - - {row.deliverytype == 'C' && ( - { - if (productCollapse?.orderid === row.orderid) { - setProductCollapse(null); - setOrderHeaderId(null); - } else { - setProductCollapse(row); - setOrderHeaderId(row.orderheaderid); - } - }} - sx={{ - borderRadius: 999, - bgcolor: tint('#06b6d4'), - color: '#06b6d4', - border: `1px solid ${edge('#06b6d4')}`, - '&:hover': { bgcolor: soft('#06b6d4') } - }} - > - {isOpen ? : } - - )} - {showAction && ( - handleMenuOpen(e, row)} - sx={{ - borderRadius: 999, - bgcolor: tint('#000000'), - color: '#000000', - border: `1px solid ${edge('#000000')}`, - '&:hover': { bgcolor: soft('#000000') } - }} - > - - - )} - - - - - {row.tenantname} - - - {[row.tenantsuburb, row.applocation].filter(Boolean).join(' · ') || '—'} - - - - } - > - - - - {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname} - - - {row.orderid} · {row.deliveryid} - - - - - {row.pickupcustomer || '—'} - - - {row.pickupcontactno} - - - - - {row.deliverycustomer || '—'} - - - {row.deliverycontactno} - - - - {row.ridername ? ( - - - - - - - {row.ridername} - - - ID #{row.userid} · {row.ridercontact || '—'} - - - - ) : ( - - Unassigned - - )} - - - - {row.transitminutes || 0}m - - - - {Number(row.kms || 0).toFixed(2)} km - {Number(row.cumulativekms || 0).toFixed(2)} km - - - - - ₹ {row.deliverycharges?.toFixed(2) ?? '0.00'} - ₹ {row.deliveryamt?.toFixed(2) ?? '0.00'} - - - - - - {row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'} - - - - {row.step ? ( - {row.step} - ) : ( - - — - - )} - - {row.notes && ( - - - {row.notes} - - - )} - - {isOpen && ( - - - - - - - Product Details - - - - {/* GET /admin/bookings has no product image/qty/tax breakdown — this - renders the real "parcels" array (itemcategory/itemdescription/ - declaredvalue) instead of the old product-catalog shape. */} - {orderdetails?.parcels?.map((parcel, idx2) => ( - - - - - - - {parcel?.itemdescription || parcel?.itemcategory || 'Item'} - - - {parcel?.itemcategory || ''} {parcel?.weight ? `· ${parcel.weight}kg` : ''} - - - - ₹ {(parcel?.declaredvalue || 0).toFixed(2)} - - - ))} - - - Delivery Charge - - - ₹ {(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)} - - - - - )} - - ); - })} - {countSourceRows?.length != 0 && ( -
    - {countIsFetchingNext || countHasNext ? ( - - ) : ( - - · End of list · - - )} -
    - )} -
    - ) : ( -
    - - - {showSelect && ( - - 0 && deliverylist.length != filteredRows.length} - onChange={(e) => { - if (e.target.checked) { - setDeliverylist([...filteredRows]); - } else { - setDeliverylist([]); - } - }} - checked={deliverylist.length > 0 && deliverylist.length == filteredRows.length} - /> - - )} - # - Status - Tenant - Order / Location - Pickup - Drop - Rider - ETA - Transit - Kms - Amount - Notes - Step - Qty - COD - {showAction && Action} - - - - {(loading1 || countSourceIsLoading) && } - {filteredRows.length == 0 && !loading1 && !countSourceLoading && ( - - - - - - - - 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… - - - - )} - {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 ( - - - {tabstatus == 'Created' && ( - - { - if (e.target.checked) { - let arr = deliverylist; - arr.push({ ...row, sno: deliverylist.length + 1 }); - setDeliverylist([...arr]); - } else { - let res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); - if (res) { - let arr = deliverylist; - let res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); - arr.splice(res.sno - 1, 1); - arr.map((val, i) => { - val.sno = i + 1; - }); - setDeliverylist([...arr]); - } - } - }} - checked={deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid)} - /> - - )} - - - {String(page * rowsPerPage + index + 1).padStart(2, '0')} - - - {/* Status badge */} - - - - - - - {rowStatusMeta.label} - - - - {/* Tenants */} - - - - - {row.tenantname} - - - {row.tenantsuburb} - - - {row.applocation} - - - - - {/* order details */} - - - - {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname} - - - - - - - - {row.orderid} - - - {/* express-console-api.md: Doormile timestamps are IST - wall-clock with no timezone marker — bare dayjs() was - still being used here despite this comment saying - otherwise (the fix was never actually applied below, - only claimed) — a Z-suffixed response reinterprets as - local time and shifts 5:30, showing the wrong time - (and sometimes the wrong day). parseDoormileTimestamp - strips that, matching getRowBatchId above. */} - - - {parseDoormileTimestamp(row.orderdate).format('DD/MM/YYYY')} - - - {parseDoormileTimestamp(row.orderdate).format('hh:mm A')} - - - - - - - - - {row.deliveryid} - - - - - {parseDoormileTimestamp(row.deliverydate).format('DD/MM/YYYY')} - - - - {parseDoormileTimestamp(row.deliverydate).format('hh:mm A')} - - - - - - {/* pickup — capped to the same maxWidth as drop below, with CSS - ellipsis truncation, so one long address can't stretch this - column wider than its neighbour. row.pickuplocation is just - the full raw address (see fetchDeliveries in api.js), not a - pre-shortened name, so the old `.slice(0, 14)` fallback never - actually ran — it was always overridden by the untruncated - value on its left. Full text still available via the tooltip. */} - - - - {row.pickupcustomer} - - - {row.pickupcontactno} - - - - {row.pickuplocation || row.Pickupaddress || '—'} - - - - - {/* drop — same maxWidth/truncation treatment as pickup above. */} - - - - {row.deliverycustomer} - - - {row.deliverycontactno} - - - - {row.deliverylocation || row.deliveryaddress || '—'} - - - - - {/* rider */} - - {row.ridername ? ( - - - - - - - {row.ridername} - - - ID #{row.userid} · {row.ridercontact || '—'} - - - - ) : ( - - Unassigned - - )} - - {/* Estimated Delivery Time */} - - - {row.expecteddeliverytime ? parseDoormileTimestamp(row.expecteddeliverytime).format('hh:mm A') : '—'} - - - {/* Transit Minutes */} - - - {row.transitminutes || 0}m - - - {/* kms */} - - - - - {Number(row.kms || 0).toFixed(2)} km - - - - - {Number(row.cumulativekms || 0).toFixed(2)} km - - - - - {/* amount */} - - - - - ₹ {row.deliverycharges?.toFixed(2) ?? '0.00'} - - - - - ₹ {row.deliveryamt?.toFixed(2) ?? '0.00'} - - - - - {/* notes */} - - {row.notes ? ( - - - {row.notes} - - - ) : ( - - — - - )} - - {/* step */} - - {row.step ? ( - - {row.step} - - ) : ( - - — - - )} - - {/* qty */} - - - {row.Quantity || '—'} - - - {/* COD */} - - - {row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'} - - - {/* Action */} - {tabstatus !== 'Cancelled' && tabstatus !== 'Delivered' && ( - - - {row.deliverytype == 'C' && ( - - { - if (productCollapse?.orderid === row.orderid) { - setProductCollapse(null); - setOrderHeaderId(null); - } else { - 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 ? ( - - ) : ( - - )} - - - )} - - - handleMenuOpen(e, row)} - sx={{ - borderRadius: 999, - bgcolor: tint('#000000'), - color: '#000000', - border: `1px solid ${edge('#000000')}`, - '&:hover': { bgcolor: soft('#000000') } - }} - > - - - - - - )} - - {productCollapse?.orderid === row?.orderid && ( - - - - - - - - - - - Product Details - - -
    - - - # - Item - Category - Weight - Declared Value - - - - {/* GET /admin/bookings has no product image/qty/tax breakdown — - this renders the real "parcels" array instead of the old - product-catalog shape. */} - {orderdetails?.parcels?.map((parcel, idx2) => ( - - - {String(idx2 + 1).padStart(2, '0')} - - - - - - - - {parcel?.itemdescription || 'Item'} - - - - {parcel?.itemcategory || '-'} - - {parcel?.weight ? `${parcel.weight}kg` : '-'} - - - ₹ {(parcel?.declaredvalue || 0).toFixed(2)} - - - ))} - - - Delivery Charge - - - ₹ {(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)} - - - -
    -
    -
    - - -
    - )} - - ); - })} - {countSourceRows?.length != 0 && ( - - -
    - {countIsFetchingNext ? ( - - ) : countHasNext ? ( - - ) : ( - - · End of list · - - )} -
    -
    -
    - )} - - - ); - })()} -
    - - - {/* Shared row-action menu — single instance reused by both the desktop - table rows and the mobile cards (both trigger handleMenuOpen). */} - - {selectedRow?.orderstatus !== 'delivered' && ( - { - notifyRiderMutation.mutate(selectedRow.milerprofileid); - handleMenuClose(); - }} - > - - - - Notify Rider - - )} - {['pending', 'accepted', 'arrived'].includes(selectedRow?.orderstatus) && ( - { - if (appId == null) { - opentoast('Please select a location first!', 'warning'); - locationRef.current?.focus(); - return; - } - setChangeDialogOpen(true); - handleMenuClose(); - }} - > - - - - Change Rider - - )} - {/* Was gated to roleid 1/2 (staff-only), matching the reference app's - identical gate — opened to every login per explicit decision, so - tenant operators can reach the same Update Status fields staff - already could. */} - { - // PUT /admin/consignments/:id/status is consignment-scoped — it - // only exists once the rider marks pickup-complete (doormile-flow.md). - // Without a real consignmentid, updateDeliveryAPI's `?? deliveryid` - // fallback was sending the BOOKING id where a consignment id is - // required — a different id space, so the call failed against - // the wrong (or nonexistent) resource. Combined with the mutation's - // onSuccess only handling `res.success === true` with no else - // branch, that failure was completely silent — dialog just sat - // there looking like nothing happened. Block it here instead, - // with a message that explains why, rather than letting the - // submit silently no-op. - if (!selectedRow.consignmentid) { - opentoast("This order hasn't been picked up yet — status can only be updated once it becomes a consignment.", 'warning'); - handleMenuClose(); - return; - } - setKms(selectedRow.kms); - setCumulativeKms(selectedRow.cumulativekms); - setDeliverylat(selectedRow.droplat); - setDeliverylong(selectedRow.droplon); - setNotes(selectedRow.notes); - setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount); - setUpdateStatus(selectedRow.orderstatus || 'delivered'); - setCurrentorder(selectedRow); - setDialogopen(true); - handleMenuClose(); - }} - > - - - - Update Status - - {selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && ( - { - setCancelDeliveryOpen(true); - handleMenuClose(); - }} - > - - - - Cancel Delivery - - )} - - - {/* =============================== || cancel dialog || =============================== */} - setCancelDeliveryOpen(false)} - maxWidth="xs" - fullWidth - PaperProps={{ - elevation: 0, - sx: { - borderRadius: 3, - border: '1px solid', - borderColor: DT.borderSubtle, - boxShadow: DT.shadowPop, - overflow: 'hidden' - } - }} - > - - - - - - - - - Cancel Delivery? - - - This action will cancel the delivery and notify the rider. Please share a brief reason. - - - 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 rider dialog || =============================== */} - setChangeDialogOpen(false)} - maxWidth="sm" - fullWidth - PaperProps={{ - elevation: 0, - sx: { - borderRadius: 3, - border: '1px solid', - borderColor: DT.borderSubtle, - boxShadow: DT.shadowPop, - overflow: 'hidden' - } - }} - > - - - - - - - - - Change Rider - - - Assign this delivery to a different rider - - - - - - { - const name = option?.displayname || option?.authname || ''; - return option?.contactno ? `${name} (${option.contactno})` : name; - }} - onChange={(e, value) => { - setSelectedRider(value); - logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None'); - }} - renderInput={(params) => ( - - - - - - ) - }} - sx={pillFieldSx('#8b5cf6')} - /> - )} - /> - - - - - - - - - - {/* =============================== || Date filter Dialog || =============================== */} - setOpen(false)} - fullScreen={isMobile} - PaperProps={{ - elevation: 0, - sx: { - borderRadius: { xs: 0, sm: 3 }, - border: '1px solid', - borderColor: DT.borderSubtle, - boxShadow: DT.shadowPop, - overflow: 'hidden' - } - }} - > - - - - - - - - - Date Range - - - Pick a window to filter the deliveries - - - - - - setOpen(!open)} - id="daterange1" - onChange={(range) => { - if (range.label === 'All') { - setStartdate(''); - setEnddate(''); - - setOpen(false); - } else { - setStartdate(dayjs(range.startDate).format('YYYY-MM-DD')); - setEnddate(dayjs(range.endDate).format('YYYY-MM-DD')); - if (range.label) { - setDatestatus(range.label); - } else { - setDatestatus(''); - } - } - }} - definedRanges={[ - { - label: 'Today', - startDate: new Date(), - endDate: new Date() - }, - { - label: 'Yesterday', - startDate: addDays(new Date(), -1), - endDate: addDays(new Date(), -1) - }, - { - label: 'Tomorrow', - startDate: addDays(new Date(), +1), - endDate: addDays(new Date(), +1) - }, - { - label: 'This Week', - startDate: startOfWeek(new Date()), - endDate: endOfWeek(new Date()) - }, - { - label: 'Last Week', - startDate: startOfWeek(addWeeks(new Date(), -1)), - endDate: endOfWeek(addWeeks(new Date(), -1)) - }, - { - label: 'Last 7 Days', - startDate: addWeeks(new Date(), -1), - endDate: new Date() - }, - { - label: 'This Month', - startDate: startOfMonth(new Date()), - endDate: endOfMonth(new Date()) - }, - { - label: 'Last Month', - startDate: startOfMonth(addMonths(new Date(), -1)), - endDate: endOfMonth(addMonths(new Date(), -1)) - } - // { - // label: 'All', - // startDate: new Date(), - // endDate: addDays(new Date(), -1), - // }, - ]} - /> - - - - - - - {/* =============================== || Update Delivery Dialog || =============================== */} - - - - - - - - - - Update Delivery Status - - - {currentorder?.orderid ? `Order #${currentorder.orderid}` : 'Modify delivery details'} - - - - - - - {[ - { 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} - - f.set(e.target.value)} - sx={{ - mt: 0.5, - '& .MuiOutlinedInput-root': { - borderRadius: 2, - bgcolor: '#fff', - '& fieldset': { borderColor: DT.borderSubtle }, - '&:hover fieldset': { borderColor: f.color }, - '&.Mui-focused fieldset': { borderColor: f.color, borderWidth: 2 }, - '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(f.color)}` } - } - }} - /> - - ))} - - - 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: '#000000' }, - '&.Mui-focused fieldset': { borderColor: '#000000', borderWidth: 2 }, - '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#000000')}` } - } - }} - > - {['pending', 'accepted', 'started', 'arrived', 'delivered', 'cancelled'].map((s) => { - const m = STATUS_META[s] || { label: s, color: '#000000', 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: '#000000' }, - '&.Mui-focused fieldset': { borderColor: '#000000', borderWidth: 2 }, - '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#000000')}` } - } - }} - /> - - - - - - - - - - - - ); -}; - -export default Deliveries; +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 dayjs from 'dayjs'; +var utc = require('dayjs/plugin/utc'); +dayjs.extend(utc); +import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi'; +import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared'; +import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; +import { useTheme } from '@mui/material/styles'; +import { + MdOutlineDateRange, + MdAccessTime, + MdAllInclusive, + MdLightMode, + MdWbSunny, + MdNightsStay, + MdCheck, + MdStorefront, + MdLocationOn, + MdDirectionsBike, + MdNotificationsActive, + MdPersonPin, + MdHistoryToggleOff, + MdCheckCircle, + MdCancel, + MdInventory2, + MdHourglassEmpty, + MdRoute, + MdSkipNext, + MdTune, + MdOutlineLocalShipping, + MdOutlinePendingActions, + MdOutlineCheckCircle, + MdOutlineCancel +} from 'react-icons/md'; +import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query'; + +import { + Avatar, + Box, + Button, + Chip, + Grid, + IconButton, + Stack, + Typography, + Table, + TableCell, + TableBody, + TableHead, + Collapse, + Dialog, + TableRow, + DialogContent, + DialogTitle, + Tooltip, + DialogActions, + Checkbox, + Autocomplete, + TextField, + TableContainer, + Backdrop, + MenuItem, + Menu, + Paper, + useMediaQuery +} from '@mui/material'; + +import { addDays, addMonths, addWeeks, endOfMonth, endOfWeek, startOfMonth, startOfWeek } from 'date-fns'; +import { DateRangePicker } from 'components/nearle_components/DateRangePicker'; +import { Dialog as AstryxDialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { VStack } from '@astryxdesign/core/VStack'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Button as AstryxButton } from '@astryxdesign/core/Button'; +import * as React from 'react'; +import Loader from 'components/Loader'; +import { KeyboardArrowDownOutlined, KeyboardArrowUpOutlined } from '@mui/icons-material'; + +import { + cancelDeliveryAPI, + changeRiderAPI, + fetchCountAPI, + fetchDeliveries, + fetchRidersList, + notifyRider, + updateDeliveryAPI, + getorderdetails, + gettenantlocations, + getTenants +} from 'pages/api/api'; +import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; +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'; +import PageHeader from 'components/nearle_components/PageHeader'; +import StatCard from 'components/nearle_components/StatCard'; +import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; + +// ============================================================================ +// Design tokens — shared across every DT-styled operator page so filters, +// KPIs, tabs, status badges, and dialogs stay visually consistent. +// See src/themes/dt/tokens.js for the canonical source. +// ============================================================================ +import { DT, tint, soft, ring, edge, pillFieldSx } from 'themes/dt/tokens'; + +// 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: MdOutlineLocalShipping }, + { key: 'pending', label: 'Pending Orders', color: '#f59e0b', icon: MdOutlinePendingActions }, + { key: 'delivered', label: 'Delivered Orders', color: '#10b981', icon: MdOutlineCheckCircle }, + { key: 'cancelled', label: 'Cancelled Orders', color: '#ef4444', icon: MdOutlineCancel } +]; + +// 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* +// batch (or none, if the gap isn't covered). +const BATCH_OPTIONS = [ + { id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' }, + { id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 }, + { + id: 'afternoon', + label: 'Afternoon Batch', + range: '9 AM to 12:30 PM', + color: '#f59e0b', + iconKey: 'afternoon', + startHour: 9, + endHour: 12.5 + }, + { id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 } +]; + +// Per-batch icon components — kept separate from BATCH_OPTIONS so the option +// objects stay serializable / printable. Looked up at render time by iconKey. +const BATCH_ICONS = { + all: MdAllInclusive, + morning: MdLightMode, + afternoon: MdWbSunny, + evening: MdNightsStay +}; + +// Auto-pick the batch matching the operator's LOCAL wall-clock hour so the +// page lands them on the slot they're most likely curious about. Falls back +// to 'all' when the current hour lies in a configured gap (8–9 AM, 12 PM–4 +// PM, after 7 PM). Local time, not UTC, to match the dispatch page's +// bucketing — both pages must agree on which batch a given row belongs to. +const detectInitialBatchId = () => { + const now = dayjs(); + const h = now.hour() + now.minute() / 60; + for (const b of BATCH_OPTIONS) { + if (b.id === 'all') continue; + if (h >= b.startHour && h < b.endHour) return b.id; + } + return 'all'; +}; + +// Bucket by `expecteddeliverytime` only — the booking's promised delivery slot +// (serviceoptions[0].estimateddeliveryat). Must stay identical to the dispatch +// page's default time-field selection (Dispatch.js initialises +// `selectedTimeField` to `'due'`, whose `keys` is just +// `['expecteddeliverytime']`), or the same row lands in different batches on +// the two pages — see the dispatch folder's CLAUDE.md §1. +// +// Two earlier attempts were wrong: +// • `deliverytime` mis-bucketed delivered orders into the wave they were +// *completed* in rather than the wave they belonged to, so an order due at +// 07:00 but delivered at 11:00 fell into the 8–9 AM gap and vanished from +// every batch here while still showing on dispatch. +// • `assigntime` looked correct (the spec says "bucket by assign time") but +// the Doormile backend has no assignment timestamp — api.js maps that field +// to the booking's last-modified column, so it is re-stamped by any status +// change and orders drifted into whichever batch held the current clock +// time. That is what made the same orders show under both Afternoon and +// Evening on the same day. +// +// Rows with no expected delivery time return null and intentionally don't +// bucket — matches dispatch. +const BATCH_TIME_KEYS = ['expecteddeliverytime']; + +// Returns one of the batch ids in BATCH_OPTIONS (excluding 'all'), or null +// when the row has no usable timestamp / falls in a gap between batches. +const getRowBatchId = (row) => { + let t = null; + for (const k of BATCH_TIME_KEYS) { + if (row?.[k]) { + t = row[k]; + break; + } + } + if (!t) return null; + const str = String(t).trim(); + // Bare YYYY-MM-DD with no time component would always parse to midnight and + // get mis-bucketed into Morning — skip those. + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; + // Parse in LOCAL time to match the dispatch page's bucketing exactly + // (`Dispatch.js:218`). Dispatch never uses `.utc()` for batch assignment, + // so a row that lands in Evening Batch on the dispatch page must also land + // in Evening Batch here — otherwise an operator running in IST/PST/etc. + // sees different totals on the two pages for the same underlying rows. + // parseDoormileTimestamp (not bare dayjs()) strips a false trailing Z some + // Doormile timestamps carry (see utils/doormileTimestamp.js) — without + // this, a booking assigned "just now" could get shifted +5:30 (IST) past + // every batch window into a gap, silently vanishing from the table even + // though it was correctly assigned server-side. + const d = parseDoormileTimestamp(t); + if (!d.isValid()) return null; + const h = d.hour() + d.minute() / 60; + for (const b of BATCH_OPTIONS) { + if (b.id === 'all') continue; + if (h >= b.startHour && h < b.endHour) return b.id; + } + 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'); + const theme = useTheme(); + // Below `md` we swap the wide data table for an app-style card list. Desktop + // (md and up) keeps the exact same table — no behaviour change either way. + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const loadMoreRef = useRef(); + const containerRef = useRef(); + const [deliverylist, setDeliverylist] = useState([]); + const [dialogopen, setDialogopen] = useState(false); + const [locaName, setLocoName] = useState('All'); + const [appId, setAppId] = useState(0); + const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD')); + const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD')); + const [tabstatus, setTabstatus] = useState('Pending'); + const [tabvalue, setTabvalue] = useState(0); + const [open, setOpen] = useState(false); + const [datestatus, setDatestatus] = useState('Today'); + const [kms, setKms] = useState(''); + const [cumulativekms, setCumulativeKms] = useState(); + const [deliveryamount, setDeliveryamount] = useState(); + const [notes, setNotes] = useState(''); + const [currentorder, setCurrentorder] = useState({}); + + // Real Actual KMs for the Update Status dialog, computed from real GPS + // ping history instead of leaving the field permanently blank (there is + // no backend-stored actual-distance field — see fetchDeliveries in + // api.js). Same sort -> Kalman-smooth -> polyline-length pipeline + // Dispatch.js's Compare feature already uses to show a real "actual km" + // figure, just reused here (see dispatchShared.js). Keyed off + // consignmentid, NOT deliveryid/bookingid — GET /admin/consignments/:id/logs + // is consignment-scoped (the same id-class mix-up already fixed in + // updateDeliveryAPI this session). + const { data: computedActualKm } = useQuery({ + queryKey: ['deliveryActualKm', currentorder?.consignmentid], + queryFn: async () => { + let rows = []; + try { + rows = (await getConsignmentLogs(currentorder.consignmentid)) || []; + } catch { + return null; + } + const sorted = rows + .map((r) => { + const ts = r?.logdate ? dayjs(r.logdate) : null; + return { + lat: parseFloat(r?.latitude ?? r?.lat), + lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon), + logdate: r?.logdate, + _ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER + }; + }) + .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng)) + .sort((a, b) => a._ts - b._ts); + // Fewer than 2 usable pings (order not yet delivered, or no telemetry + // recorded) — a real, expected case, not an error. Leave the field + // to its existing blank/manual-entry behavior. + if (sorted.length < 2) return null; + const smoothed = kalmanSmoothGps(sorted); + const km = polylineLengthKm(smoothed.map((p) => [p.lat, p.lng])); + return km > 0 ? km : null; + }, + enabled: dialogopen && !!currentorder?.consignmentid, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false + }); + + // Only pre-fill while the field is still empty — never overwrite an + // operator's own edit (or an earlier pre-fill) if this query happens to + // re-run while the dialog is already open. + useEffect(() => { + if (computedActualKm != null && !cumulativekms) { + setCumulativeKms(Number(computedActualKm.toFixed(2))); + } + }, [computedActualKm]); + const [deliverylat, setDeliverylat] = useState(''); + const [deliverylong, setDeliverylong] = useState(''); + const [currentStatus, setCurrentStatus] = useState('pending'); + const [updateStatus, setUpdateStatus] = useState('delivered'); + const locationRef = useRef(null); + const tenantRef = useRef(null); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(50); + const [totalCount, setTotalCount] = React.useState(); + const [productCollapse, setProductCollapse] = useState(null); + const [orderHeaderid, setOrderHeaderId] = useState(null); + const [searchword, setSearchword] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [menuAnchorEl, setMenuAnchorEl] = React.useState(null); + const [selectedRow, setSelectedRow] = useState(null); + const [loading1, setLoading1] = useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + const [open2, setOpen2] = useState(''); + const [cancelDeliveryOpen, setCancelDeliveryOpen] = useState(false); + const [changeDialogOpen, setChangeDialogOpen] = useState(false); + const [cancelFeed, setCancelFeed] = useState(''); + const [selectedRider, setSelectedRider] = useState(null); + const [tenantid, setTenantid] = useState(0); + const [locationid, setLocationid] = useState(0); + const [tenantValue, setTenantValue] = useState(null); + const [locationValue, setLocationValue] = useState(null); + const [riderid, setRiderid] = useState(0); + // Selected batch id — drives the client-side row filter. Defaults to the + // batch matching the current UTC hour (the operator is most likely curious + // about "now"); never `null` since there's no longer an "All" option. + const [selectedBatch, setSelectedBatch] = useState(detectInitialBatchId); + + useEffect(() => { + setTenantid(0); + setTenantValue(null); + setLocationid(0); + setLocationValue(null); + }, [appId]); + // to clear the location autocomplete + useEffect(() => { + setLocationid(0); + setLocationValue(null); + }, [tenantid]); + + const menuOpen = Boolean(menuAnchorEl); + + const handleMenuOpen = (event, row) => { + setSelectedRow(row); + + setMenuAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setMenuAnchorEl(null); + }; + + // =========================================== || cancelDelivery || =========================================== + + const { mutate: cancelDelivery } = useMutation({ + mutationFn: ({ selectedRow, cancelFeed }) => cancelDeliveryAPI(selectedRow, cancelFeed), + onSuccess: () => { + opentoast('Delivery Cancelled Successfully', 'success'); + setCancelDeliveryOpen(false); + fetchCountRefetch(); // Refresh count data + countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips + }, + onError: (error) => { + opentoast(error.message, 'error'); + } + }); + + // ==============================|| cancelridernotification ||============================== // + + const cancelridernotification = async () => { + try { + // /admin/milers/:id/notify keys off milerprofileid, not the userid + // stored on the booking (confirmed live) — see fetchDeliveries in api.js. + return await notifyMiler(selectedRow.milerprofileid, 'DoormileXpress', `${selectedRow.orderid} have been Cancelled`); + } catch (err) { + opentoast(err.message, 'error', 2000); + } + }; + + // ==============================|| getTenants ||============================== // + + const { + data: tenantlist, + isLoading: fetchtenantsIsLoading, + isError: fetchtenantsIsError, + error: fetchtenantsError + } = useQuery({ + queryKey: ['tenantlist', appId], + queryFn: () => getTenants(appId), // Ensure appId is passed + // appId 0 is a real, deliberate choice — the synthetic "All" zone option + // (fetchAppLocations in api.js) — not "nothing picked yet"; only + // null/undefined means that now. See LocationAutocomplete.js's onChange. + enabled: appId != null + }); + // ==============================|| gettenantlocations ||============================== // + + const { + data: locationlist, + isLoading: fetchlocationsIsLoading, + isError: fetchlocationsIsError, + error: fetchlocationsError + } = useQuery({ + queryKey: ['gettenantlocations', tenantid], + queryFn: () => gettenantlocations(tenantid), // Ensure appId is passed + enabled: tenantid !== 0 // Ensures query runs only when appId is valid + }); + // =========================================== || notifyrider || =========================================== + + const notifyRiderMutation = useMutation({ + mutationFn: notifyRider, // Using the separate function + onSuccess: () => { + enqueueSnackbar('Notification sent Successfully', { + variant: 'success', + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + autoHideDuration: 2000 + }); + }, + onError: (error) => { + enqueueSnackbar(error.message, { + variant: 'error', + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + autoHideDuration: 2000 + }); + } + }); + + // =========================================== || changerider || =========================================== + const changeRiderMutation = useMutation({ + mutationFn: ({ selectedRider, selectedRow }) => changeRiderAPI(selectedRider, selectedRow), + onSuccess: (res, { selectedRider, selectedRow }) => { + setLoading1(false); + setChangeDialogOpen(false); + if (res.success) { + logger.info( + `Rider changed successfully for order ID ${selectedRow?.orderid}. New Rider: ${selectedRider?.displayname || selectedRider?.authname}` + ); + opentoast('Rider Changed Successfully', 'success'); + } + fetchCountRefetch(); // Refresh count data + countSourceRefetch(); // Refresh the all-statuses dataset feeding table + chips + // notifyMiler wants a miler profile id, not an FCM token — the new API + // looks the device token up server-side. selectedRider comes straight + // from getMilers(), which has milerprofileid directly (confirmed live; + // .userid there is the underlying app-user id, a different resource). + notifyRiderMutation.mutate(selectedRider.milerprofileid); + }, + onError: (err, { selectedRider, selectedRow }) => { + logger.error(`Failed to change rider for order ID ${selectedRow?.orderid}:`, err); + opentoast(err.message, 'error'); + setLoading1(false); + } + }); + + /* ============================================= || opentoast || ============================================= */ + + const opentoast = (message, variant) => { + enqueueSnackbar(message, { + variant, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + autoHideDuration: 2000 + }); + }; + + // ==============================|| getorderdetails ||============================== // + + const { data: orderdetails } = useQuery({ + queryKey: ['orderdetails', orderHeaderid], + queryFn: () => getorderdetails(orderHeaderid), + enabled: !!orderHeaderid // ✅ prevent initial fetch when undefined + }); + + const dialogclose = () => { + setDialogopen(false); + }; + + const handleChangetab = (e, i) => { + setPage(0); + setTabvalue(i); + setRowsPerPage(50); + if (i === 0) { + setTabstatus('Pending'); + setCurrentStatus('pending'); + setTotalCount(countData?.uncoveredLength); + } + if (i === 1) { + setTabstatus('Assigned'); + setCurrentStatus('accepted'); + setTotalCount(countData?.assignedLength); + } + if (i === 2) { + setTabstatus('Arrived'); + setCurrentStatus('arrived'); + setTotalCount(countData?.arrivedLength); + } + if (i === 3) { + setTabstatus('Picked'); + setCurrentStatus('picked'); + setTotalCount(countData?.pickedLength); + } + if (i === 4) { + setTabstatus('Active'); + setCurrentStatus('active'); + setTotalCount(countData?.activeLength); + } + if (i === 5) { + setTabstatus('Skipped'); + setCurrentStatus('skipped'); + setTotalCount(countData?.skippedLength); + } + if (i === 6) { + setTabstatus('Delivered'); + setCurrentStatus('delivered'); + setTotalCount(countData?.coveredLength); + } + + if (i === 7) { + setTabstatus('Cancelled'); + setCurrentStatus('cancelled'); + setTotalCount(countData?.cancelLength); + } + setSearchword(''); + }; + + const okclicked = () => { + setOpen(false); + }; + + // (filteredRows is defined below, after countSourceRows / countSourceLoading + // are in scope — they're the single source of truth for both the table and + // the per-status chip counts.) + + // Parallel "all-statuses" query used ONLY to derive batch-aware tab counts. + // The primary `fetchdeliveries` query above is keyed by `currentStatus`, so + // its rows are scoped to the active tab — they can't tell us how many + // delivered/cancelled/etc. exist in the same batch. We fetch the full day + // once (status='all'), keep it cached, and re-derive the counts per status + // whenever the operator switches batches. Search keyword is intentionally + // dropped from this key so the chip counts reflect the batch totals rather + // than the search-filtered subset. + const { + data: countSourceData, + fetchNextPage: countFetchNext, + hasNextPage: countHasNext, + isFetchingNextPage: countIsFetchingNext, + isLoading: countSourceIsLoading, + refetch: countSourceRefetch + } = useInfiniteQuery({ + queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid], + queryFn: fetchDeliveries, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined + }); + + // Newest activity first — an order just created and assigned should be the + // very first row an operator sees, not buried wherever GET /admin/bookings + // happens to return it (unconfirmed/unspecified server-side order). Every + // row here has been dispatched at least once (fetchDeliveries filters to + // that), so assigntime (booking.updatedat) is the more recent of the two + // timestamps on a fresh assignment; orderdate (createdat) covers the rare + // row missing it. parseDoormileTimestamp (not bare dayjs()) avoids the + // false-Z parsing bug documented in utils/doormileTimestamp.js. + const countSourceRows = useMemo(() => { + const rows = (countSourceData?.pages || []).flatMap((p) => p.rows || []); + return [...rows].sort( + (a, b) => + parseDoormileTimestamp(b.assigntime || b.orderdate).valueOf() - parseDoormileTimestamp(a.assigntime || a.orderdate).valueOf() + ); + }, [countSourceData]); + + useEffect(() => { + if (countHasNext && !countIsFetchingNext) countFetchNext(); + }, [countHasNext, countIsFetchingNext, countFetchNext, countSourceRows.length]); + + // Loading flag for the table empty state — true while we're still streaming + // pages, so we don't show "No Orders" prematurely before the full + // day's rows have arrived. Goes false once auto-pagination drains. + const countSourceLoading = countHasNext || countIsFetchingNext; + + // The table now reads from the same source as the chip counts. That way the + // "74" in the Delivered chip and the rows shown in the table can never + // disagree (they used to, because the table consumed a separate per-status + // paginated query while the chips read from the full all-statuses dataset). + // Three filter steps: + // 1. Batch — selected via the dropdown. 'all' bypasses this filter. + // 2. Tab status — currentStatus comes from handleChangetab. + // 3. Search — client-side substring match across customer/address/order + // fields. Server-side search isn't needed since every row is loaded. + const filteredRows = useMemo(() => { + const wantStatus = String(currentStatus || '').toLowerCase(); + const q = String(debouncedSearch || '') + .trim() + .toLowerCase(); + return countSourceRows.filter((r) => { + if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false; + const s = String(r.orderstatus || '').toLowerCase(); + if (wantStatus && s !== wantStatus) return false; + if (q) { + const hay = [ + r.deliverycustomer, + r.deliveryaddress, + r.deliverysuburb, + r.pickupcustomer, + r.pickupaddress, + r.pickupsuburb, + r.orderid, + r.tenantname, + r.ridername, + r.username + ] + .map((v) => String(v || '').toLowerCase()) + .join(' '); + if (!hay.includes(q)) return false; + } + return true; + }); + }, [countSourceRows, selectedBatch, currentStatus, debouncedSearch]); + + // Counts per status, scoped to the selected batch. Keys mirror the legacy + // *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical + // (countData?.uncoveredLength → batchCounts.uncoveredLength). + const batchCounts = useMemo(() => { + const c = { + uncoveredLength: 0, + assignedLength: 0, + arrivedLength: 0, + pickedLength: 0, + activeLength: 0, + skippedLength: 0, + coveredLength: 0, + cancelLength: 0 + }; + countSourceRows.forEach((r) => { + if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return; + const s = String(r.orderstatus || '').toLowerCase(); + switch (s) { + case 'pending': + c.uncoveredLength += 1; + break; + case 'accepted': + case 'assigned': + c.assignedLength += 1; + break; + case 'arrived': + c.arrivedLength += 1; + break; + case 'picked': + c.pickedLength += 1; + break; + case 'active': + c.activeLength += 1; + break; + case 'skipped': + c.skippedLength += 1; + break; + case 'delivered': + c.coveredLength += 1; + break; + case 'cancelled': + case 'canceled': + c.cancelLength += 1; + break; + default: + break; + } + }); + return c; + }, [countSourceRows, selectedBatch]); + + // Per-batch total (any status) for the dropdown badges. Computed once for + // every batch + 'all' so each option in the menu can show its own count + // without re-walking the list. Mirrors dispatch's batchCounts shape + // (`Dispatch.js:1198–1206`) — totals here should match the per-batch numbers + // visible on the dispatch page for the same date. + const batchTotals = useMemo(() => { + // `all` is the SUM of the three batch buckets — not the raw row count. + // Rows that fall in a gap (8–9 AM, 12:30–4 PM, after 7 PM) or have no + // expected delivery time belong to no batch, so they're excluded here too. This keeps + // the "All Batches" badge equal to Morning + Afternoon + Evening + // (e.g. 36 + 89 + 53 = 178) instead of over-reporting the un-bucketed total. + const totals = { all: 0 }; + BATCH_OPTIONS.forEach((b) => { + if (b.id === 'all') return; + totals[b.id] = 0; + }); + countSourceRows.forEach((r) => { + const id = getRowBatchId(r); + if (id) { + totals[id] = (totals[id] || 0) + 1; + totals.all += 1; + } + }); + return totals; + }, [countSourceRows]); + + // IntersectionObserver now watches the count-source query — that's the + // single source of truth for both the table and the chip counts. When the + // sentinel scrolls into view it nudges the next page of all-statuses + // deliveries to keep the dataset complete. + useEffect(() => { + if (!countHasNext) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + countFetchNext(); + } + }, + { + root: document.querySelector('.MuiTableContainer-root'), + rootMargin: '0px', + threshold: 1.0 + } + ); + if (loadMoreRef.current) observer.observe(loadMoreRef.current); + return () => { + if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); + }; + }, [countHasNext, countFetchNext]); + + const handleScroll = (event) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + if (scrollTop + clientHeight >= scrollHeight - 50) { + if (countHasNext && !countIsFetchingNext) { + countFetchNext(); + } + } + }; + + /* ============================================= || fetchPercentageAPI | ============================================= */ + + /* ============================================= || fetchcount | ============================================= */ + + const { + data: countData = {}, // Default to empty object + isLoading: fetchCountIsLoading, + isError: fetchCountIsError, + error: fetchCountError, + refetch: fetchCountRefetch + } = useQuery({ + queryKey: ['fetchCountData', appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid, tabstatus], + queryFn: () => fetchCountAPI(appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid) + }); + useEffect(() => { + if (tabvalue === 0 && countData) { + setTotalCount(countData.uncoveredLength); + } + }, [countData]); + + // ==============================|| fetchRidersList ||============================== // + + const { + data: ridersList = [], + isLoading: riderListIsLoading, + isError: ridersListIsError, + error: ridersListError + } = useQuery({ + queryKey: ['ridersList', appId], // Unique key for caching & re-fetching + // fetchRidersList now toasts + returns [] internally on failure (was + // throwing after already toasting there too, which showed this same + // error twice) — no onError needed here anymore. + queryFn: fetchRidersList, + enabled: appId != null + }); + + /* ============================================= || updatedelivery | ============================================= */ + + const updateDeliveryMutation = useMutation({ + mutationFn: (orderData) => updateDeliveryAPI(orderData), + onSuccess: (res) => { + if (res.success) { + opentoast('Updated Successfully', 'success'); + setDeliveryamount(''); + setNotes(''); + setDialogopen(false); + fetchCountRefetch(); + countSourceRefetch(); + } else { + // A 200 with success:false (the Doormile API's validation-failure + // shape, same pattern as customers.js's saveMutation) had no else + // branch here before — the dialog just sat open with zero feedback, + // looking exactly like the button didn't work. + opentoast(res.message || 'Failed to update delivery', 'error'); + } + }, + onError: (err) => { + opentoast(err.response?.data?.message || err.message || 'Failed to update delivery', 'error'); + } + }); + + const errorMessage = fetchCountIsError + ? `Error fetching count summary: ${fetchCountError?.message}` + : ridersListIsError + ? `Error fetching riders: ${ridersListError?.message}` + : fetchtenantsIsError + ? `Error tenant list: ${fetchtenantsError?.message}` + : fetchlocationsIsError + ? `Error location list: ${fetchlocationsError?.message}` + : null; + + if (errorMessage) { + OpenToast(errorMessage, 'error', 2000); + return null; // or return <> if inside a component + } + + return ( + <> + {(fetchCountIsLoading || countSourceIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading) && ( + <> + + {/* */} + + )} + { + theme.zIndex.drawer + 1 + }} + open={fetchCountIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || riderListIsLoading} + > + {/* */} + + } + {/* ============================================= || Header | ============================================= */} + + } + /> + + {/* ============================================= || KPI Cards | ============================================= */} + + {[ + { ...KPI_META[0], value: batchTotals.all, percentage: null }, + { + ...KPI_META[1], + value: countData?.uncoveredLength, + percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0 + }, + { + ...KPI_META[2], + value: countData?.coveredLength, + percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0 + }, + { + ...KPI_META[3], + value: countData?.cancelLength, + percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0 + } + ].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; + return ( + + } + color={item.color} + loading={fetchCountIsLoading} + caption={hasPct ? `${Math.abs(pct)}% of total` : undefined} + /> + + ); + })} + + + {/* ============================================= || Filter Bar | ============================================= */} + + + + + + + Filters + + + + + {/* Batch dropdown — replaces the legacy "Deliveries-{datestatus}" and + "Orders-All" pills. Filters the loaded rows by the slot the + operator picks; defaults to 'all'. Mirrors the dispatch page's + batch model. UI mirrors the polished Absent Riders dropdown from + the orders page — custom Paper, per-batch color/icon, selected + indicator. */} + {(() => { + const activeBatch = BATCH_OPTIONS.find((b) => b.id === selectedBatch) || BATCH_OPTIONS[0]; + const ActiveIcon = BATCH_ICONS[activeBatch.iconKey] || MdAccessTime; + return ( + opts} + options={BATCH_OPTIONS} + value={activeBatch} + onChange={(e, val) => val && setSelectedBatch(val.id)} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => option.id === value.id} + PaperComponent={SoftPaper} + ListboxProps={{ sx: { py: 0, maxHeight: 360 } }} + renderOption={(props, option, { selected }) => { + const Icon = BATCH_ICONS[option.iconKey] || MdAccessTime; + const total = batchTotals[option.id] ?? 0; + return ( +
  • + + + + + + {option.label} + + + {option.range} + + + {/* Per-batch total — same number you see next to each + batch on the dispatch page. Lets the operator + eyeball-compare without summing the per-status + chip counts on the tabs. */} + 0 ? `${option.color}18` : '#f1f5f9', + color: total > 0 ? option.color : '#94a3b8', + border: `1px solid ${total > 0 ? option.color + '55' : '#e2e8f0'}`, + '& .MuiChip-label': { px: 0.75 } + }} + /> + {selected && ( + + + + )} +
  • + ); + }} + renderInput={(params) => ( + + + + +
    + ) + }} + sx={{ + minWidth: { xs: 180, sm: 220, md: 240 }, + cursor: 'pointer', + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + bgcolor: '#ffffff', + fontWeight: 600, + color: '#0f172a', + paddingRight: '8px', + cursor: 'pointer', + transition: 'border-color 0.15s, box-shadow 0.15s', + '& fieldset': { borderColor: '#e2e8f0', borderWidth: 1 }, + '&:hover fieldset': { borderColor: '#cbd5e1' }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#000000')}` }, + '&.Mui-focused fieldset': { borderColor: '#000000', borderWidth: 1.5 } + }, + '& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: '#94a3b8' } + }} + /> + )} + /> + ); + })()} + + {/* 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 && ( + setOpen(true)} + sx={{ + pl: 0.5, + pr: 1.25, + py: 0.5, + borderRadius: '10px', + cursor: 'pointer', + bgcolor: '#ffffff', + border: '1px solid #e2e8f0', + transition: 'border-color 0.15s', + '&:hover': { borderColor: '#cbd5e1' } + }} + > + + + + + {dayjs(startdate).format('DD MMM')} – {dayjs(enddate).format('DD MMM')} + + + )} +
    + + {/* Tenant */} + { + if (appId == null) { + event.preventDefault(); + OpenToast('Please select your zone 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) => ( + + + + + + ) + }} + sx={pillFieldSx('#0ea5e9')} + /> + )} + /> + {/* Location */} + (option?.locationname ? `${option.locationname}${option.suburb ? ` (${option.suburb})` : ''}` : '')} + value={locationValue} + PaperComponent={SoftPaper} + sx={{ flex: { xs: '1 1 100%', sm: '1 1 180px' }, minWidth: { xs: '100%', sm: 180 } }} + onOpen={(event) => { + if (appId == null && !tenantid) { + event.preventDefault(); + OpenToast('Please select your Zone and Tenant first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); + } else if (!tenantid) { + event.preventDefault(); + OpenToast('Please select your 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) => ( + + + + + + ) + }} + sx={pillFieldSx('#14b8a6')} + /> + )} + /> + {/* Rider */} + { + const name = option?.displayname || option?.authname || ''; + return option?.contactno ? `${name} (${option.contactno})` : name; + }} + 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); + }} + onOpen={(event) => { + if (appId == null) { + event.preventDefault(); + OpenToast('Please select your zone first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); + } + }} + renderInput={(params) => ( + + + + + + ) + }} + sx={pillFieldSx('#8b5cf6')} + /> + )} + /> + +
    + {/* ============================================= || Status Tabs + Search || ============================================= */} + + + + {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: DT.radiusField + 'px', + border: `1px solid ${active ? '#000000' : DT.borderSubtle}`, + bgcolor: active ? '#000000' : DT.surface, + color: active ? '#fff' : DT.textSecondary, + fontWeight: 600, + boxShadow: 'none', + transition: 'background-color 0.15s, border-color 0.15s, color 0.15s', + '&:hover': { + borderColor: active ? '#000000' : DT.borderHover, + bgcolor: active ? '#000000' : DT.surfaceAlt + } + }} + > + + + + + {meta.label} + + + {count} + + + ); + })} + + + {/* ============================================= || Search | ============================================= */} + + + + + + {/* ============================================= || Table || ============================================= */} + + + {(() => { + const showAction = tabstatus !== 'Cancelled' && tabstatus !== 'Delivered'; + const showSelect = tabstatus == 'Created'; + const totalCols = 15 + (showAction ? 1 : 0) + (showSelect ? 1 : 0); + return isMobile ? ( + /* ===================== MOBILE: card list ===================== */ + + {filteredRows.length === 0 && !loading1 && !countSourceLoading && ( + + + + + + 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… + + + )} + {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); + const isOpen = productCollapse?.orderid === row?.orderid; + const chipSx = (c) => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: c, + fontWeight: 700, + fontSize: 12, + whiteSpace: 'nowrap' + }); + return ( + + + + {showSelect && ( + { + if (e.target.checked) { + let arr = deliverylist; + arr.push({ ...row, sno: deliverylist.length + 1 }); + setDeliverylist([...arr]); + } else { + let res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); + if (res) { + let arr = deliverylist; + arr.splice(res.sno - 1, 1); + arr.map((val, i) => { + val.sno = i + 1; + }); + setDeliverylist([...arr]); + } + } + }} + checked={!!deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid)} + /> + )} + + {String(page * rowsPerPage + index + 1).padStart(2, '0')} + + + + + + + {rowStatusMeta.label} + + + + + {row.deliverytype == 'C' && ( + { + if (productCollapse?.orderid === row.orderid) { + setProductCollapse(null); + setOrderHeaderId(null); + } else { + setProductCollapse(row); + setOrderHeaderId(row.orderheaderid); + } + }} + sx={{ + borderRadius: 999, + bgcolor: tint('#06b6d4'), + color: '#06b6d4', + border: `1px solid ${edge('#06b6d4')}`, + '&:hover': { bgcolor: soft('#06b6d4') } + }} + > + {isOpen ? : } + + )} + {showAction && ( + handleMenuOpen(e, row)} + sx={{ + borderRadius: 999, + bgcolor: tint('#000000'), + color: '#000000', + border: `1px solid ${edge('#000000')}`, + '&:hover': { bgcolor: soft('#000000') } + }} + > + + + )} + + + + + {row.tenantname} + + + {[row.tenantsuburb, row.applocation].filter(Boolean).join(' · ') || '—'} + + + + } + > + + + + {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname} + + + {row.orderid} · {row.deliveryid} + + + + + {row.pickupcustomer || '—'} + + + {row.pickupcontactno} + + + + + {row.deliverycustomer || '—'} + + + {row.deliverycontactno} + + + + {row.ridername ? ( + + + + + + + {row.ridername} + + + ID #{row.userid} · {row.ridercontact || '—'} + + + + ) : ( + + Unassigned + + )} + + + + {row.transitminutes || 0}m + + + + {Number(row.kms || 0).toFixed(2)} km + {Number(row.cumulativekms || 0).toFixed(2)} km + + + + + ₹ {row.deliverycharges?.toFixed(2) ?? '0.00'} + ₹ {row.deliveryamt?.toFixed(2) ?? '0.00'} + + + + + + {row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'} + + + + {row.step ? ( + {row.step} + ) : ( + + — + + )} + + {row.notes && ( + + + {row.notes} + + + )} + + {isOpen && ( + + + + + + + Product Details + + + + {/* GET /admin/bookings has no product image/qty/tax breakdown — this + renders the real "parcels" array (itemcategory/itemdescription/ + declaredvalue) instead of the old product-catalog shape. */} + {orderdetails?.parcels?.map((parcel, idx2) => ( + + + + + + + {parcel?.itemdescription || parcel?.itemcategory || 'Item'} + + + {parcel?.itemcategory || ''} {parcel?.weight ? `· ${parcel.weight}kg` : ''} + + + + ₹ {(parcel?.declaredvalue || 0).toFixed(2)} + + + ))} + + + Delivery Charge + + + ₹ {(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)} + + + + + )} + + ); + })} + {countSourceRows?.length != 0 && ( +
    + {countIsFetchingNext || countHasNext ? ( + + ) : ( + + · End of list · + + )} +
    + )} +
    + ) : ( + + + + {showSelect && ( + + 0 && deliverylist.length != filteredRows.length} + onChange={(e) => { + if (e.target.checked) { + setDeliverylist([...filteredRows]); + } else { + setDeliverylist([]); + } + }} + checked={deliverylist.length > 0 && deliverylist.length == filteredRows.length} + /> + + )} + # + Status + Tenant + Order / Location + Pickup + Drop + Rider + ETA + Transit + Kms + Amount + Notes + Step + Qty + COD + {showAction && Action} + + + + {(loading1 || countSourceIsLoading) && } + {filteredRows.length == 0 && !loading1 && !countSourceLoading && ( + + + + + + + + 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… + + + + )} + {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 ( + + + {tabstatus == 'Created' && ( + + { + if (e.target.checked) { + let arr = deliverylist; + arr.push({ ...row, sno: deliverylist.length + 1 }); + setDeliverylist([...arr]); + } else { + let res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); + if (res) { + let arr = deliverylist; + let res = deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid); + arr.splice(res.sno - 1, 1); + arr.map((val, i) => { + val.sno = i + 1; + }); + setDeliverylist([...arr]); + } + } + }} + checked={deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid)} + /> + + )} + + + {String(page * rowsPerPage + index + 1).padStart(2, '0')} + + + {/* Status badge */} + + + + + + + {rowStatusMeta.label} + + + + {/* Tenants */} + + + + + {row.tenantname} + + + {row.tenantsuburb} + + + {row.applocation} + + + + + {/* order details */} + + + + {row.locationsuburb ? `${row.locationname}-(${row.locationsuburb})` : row.locationname} + + + + + + + + {row.orderid} + + + {/* express-console-api.md: Doormile timestamps are IST + wall-clock with no timezone marker — bare dayjs() was + still being used here despite this comment saying + otherwise (the fix was never actually applied below, + only claimed) — a Z-suffixed response reinterprets as + local time and shifts 5:30, showing the wrong time + (and sometimes the wrong day). parseDoormileTimestamp + strips that, matching getRowBatchId above. */} + + + {parseDoormileTimestamp(row.orderdate).format('DD/MM/YYYY')} + + + {parseDoormileTimestamp(row.orderdate).format('hh:mm A')} + + + + - + + + + {row.deliveryid} + + + + + {parseDoormileTimestamp(row.deliverydate).format('DD/MM/YYYY')} + + + + {parseDoormileTimestamp(row.deliverydate).format('hh:mm A')} + + + + + + {/* pickup — capped to the same maxWidth as drop below, with CSS + ellipsis truncation, so one long address can't stretch this + column wider than its neighbour. row.pickuplocation is just + the full raw address (see fetchDeliveries in api.js), not a + pre-shortened name, so the old `.slice(0, 14)` fallback never + actually ran — it was always overridden by the untruncated + value on its left. Full text still available via the tooltip. */} + + + + {row.pickupcustomer} + + + {row.pickupcontactno} + + + + {row.pickuplocation || row.Pickupaddress || '—'} + + + + + {/* drop — same maxWidth/truncation treatment as pickup above. */} + + + + {row.deliverycustomer} + + + {row.deliverycontactno} + + + + {row.deliverylocation || row.deliveryaddress || '—'} + + + + + {/* rider */} + + {row.ridername ? ( + + + + + + + {row.ridername} + + + ID #{row.userid} · {row.ridercontact || '—'} + + + + ) : ( + + Unassigned + + )} + + {/* Estimated Delivery Time */} + + + {row.expecteddeliverytime ? parseDoormileTimestamp(row.expecteddeliverytime).format('hh:mm A') : '—'} + + + {/* Transit Minutes */} + + + {row.transitminutes || 0}m + + + {/* kms */} + + + + + {Number(row.kms || 0).toFixed(2)} km + + + + + {Number(row.cumulativekms || 0).toFixed(2)} km + + + + + {/* amount */} + + + + + ₹ {row.deliverycharges?.toFixed(2) ?? '0.00'} + + + + + ₹ {row.deliveryamt?.toFixed(2) ?? '0.00'} + + + + + {/* notes */} + + {row.notes ? ( + + + {row.notes} + + + ) : ( + + — + + )} + + {/* step */} + + {row.step ? ( + + {row.step} + + ) : ( + + — + + )} + + {/* qty */} + + + {row.Quantity || '—'} + + + {/* COD */} + + + {row.collectionamt ? `₹ ${row.collectionamt.toFixed(2)}` : '—'} + + + {/* Action */} + {tabstatus !== 'Cancelled' && tabstatus !== 'Delivered' && ( + + + {row.deliverytype == 'C' && ( + + { + if (productCollapse?.orderid === row.orderid) { + setProductCollapse(null); + setOrderHeaderId(null); + } else { + 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 ? ( + + ) : ( + + )} + + + )} + + + handleMenuOpen(e, row)} + sx={{ + borderRadius: 999, + bgcolor: tint('#000000'), + color: '#000000', + border: `1px solid ${edge('#000000')}`, + '&:hover': { bgcolor: soft('#000000') } + }} + > + + + + + + )} + + {productCollapse?.orderid === row?.orderid && ( + + + + + + + + + + + Product Details + + +
    + + + # + Item + Category + Weight + Declared Value + + + + {/* GET /admin/bookings has no product image/qty/tax breakdown — + this renders the real "parcels" array instead of the old + product-catalog shape. */} + {orderdetails?.parcels?.map((parcel, idx2) => ( + + + {String(idx2 + 1).padStart(2, '0')} + + + + + + + + {parcel?.itemdescription || 'Item'} + + + + {parcel?.itemcategory || '-'} + + {parcel?.weight ? `${parcel.weight}kg` : '-'} + + + ₹ {(parcel?.declaredvalue || 0).toFixed(2)} + + + ))} + + + Delivery Charge + + + ₹ {(orderdetails?.serviceoptions?.[0]?.estimatedprice || 0).toFixed(2)} + + + +
    +
    + + + + + )} + + ); + })} + {countSourceRows?.length != 0 && ( + + +
    + {countIsFetchingNext ? ( + + ) : countHasNext ? ( + + ) : ( + + · End of list · + + )} +
    +
    +
    + )} + + + ); + })()} + +
    + + {/* Shared row-action menu — single instance reused by both the desktop + table rows and the mobile cards (both trigger handleMenuOpen). */} + + {selectedRow?.orderstatus !== 'delivered' && ( + { + notifyRiderMutation.mutate(selectedRow.milerprofileid); + handleMenuClose(); + }} + > + + + + Notify Rider + + )} + {['pending', 'accepted', 'arrived'].includes(selectedRow?.orderstatus) && ( + { + if (appId == null) { + opentoast('Please select a location first!', 'warning'); + locationRef.current?.focus(); + return; + } + setChangeDialogOpen(true); + handleMenuClose(); + }} + > + + + + Change Rider + + )} + {/* Was gated to roleid 1/2 (staff-only), matching the reference app's + identical gate — opened to every login per explicit decision, so + tenant operators can reach the same Update Status fields staff + already could. */} + { + // PUT /admin/consignments/:id/status is consignment-scoped — it + // only exists once the rider marks pickup-complete (doormile-flow.md). + // Without a real consignmentid, updateDeliveryAPI's `?? deliveryid` + // fallback was sending the BOOKING id where a consignment id is + // required — a different id space, so the call failed against + // the wrong (or nonexistent) resource. Combined with the mutation's + // onSuccess only handling `res.success === true` with no else + // branch, that failure was completely silent — dialog just sat + // there looking like nothing happened. Block it here instead, + // with a message that explains why, rather than letting the + // submit silently no-op. + if (!selectedRow.consignmentid) { + opentoast("This order hasn't been picked up yet — status can only be updated once it becomes a consignment.", 'warning'); + handleMenuClose(); + return; + } + setKms(selectedRow.kms); + setCumulativeKms(selectedRow.cumulativekms); + setDeliverylat(selectedRow.droplat); + setDeliverylong(selectedRow.droplon); + setNotes(selectedRow.notes); + setDeliveryamount(selectedRow.deliveryamt ?? selectedRow.deliveryamount); + setUpdateStatus(selectedRow.orderstatus || 'delivered'); + setCurrentorder(selectedRow); + setDialogopen(true); + handleMenuClose(); + }} + > + + + + Update Status + + {selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && ( + { + setCancelDeliveryOpen(true); + handleMenuClose(); + }} + > + + + + Cancel Delivery + + )} + + + {/* =============================== || cancel dialog || =============================== */} + setCancelDeliveryOpen(false)} + maxWidth="xs" + fullWidth + PaperProps={{ + elevation: 0, + sx: { + borderRadius: 3, + border: '1px solid', + borderColor: DT.borderSubtle, + boxShadow: DT.shadowPop, + overflow: 'hidden' + } + }} + > + + + + + + + + + Cancel Delivery? + + + This action will cancel the delivery and notify the rider. Please share a brief reason. + + + 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 rider dialog || =============================== */} + setChangeDialogOpen(false)} + maxWidth="sm" + fullWidth + PaperProps={{ + elevation: 0, + sx: { + borderRadius: 3, + border: '1px solid', + borderColor: DT.borderSubtle, + boxShadow: DT.shadowPop, + overflow: 'hidden' + } + }} + > + + + + + + + + + Change Rider + + + Assign this delivery to a different rider + + + + + + { + const name = option?.displayname || option?.authname || ''; + return option?.contactno ? `${name} (${option.contactno})` : name; + }} + onChange={(e, value) => { + setSelectedRider(value); + logger.debug('Rider selected in dropdown:', value ? `${value.firstname} ${value.lastname}` : 'None'); + }} + renderInput={(params) => ( + + + + + + ) + }} + sx={pillFieldSx('#8b5cf6')} + /> + )} + /> + + + + + + + + + + {/* =============================== || Date filter Dialog || =============================== */} + !next && setOpen(false)} width={640} purpose="form"> + + + setOpen(!open)} + id="daterange1" + onChange={(range) => { + if (range.label === 'All') { + setStartdate(''); + setEnddate(''); + + setOpen(false); + } else { + setStartdate(dayjs(range.startDate).format('YYYY-MM-DD')); + setEnddate(dayjs(range.endDate).format('YYYY-MM-DD')); + if (range.label) { + setDatestatus(range.label); + } else { + setDatestatus(''); + } + } + }} + definedRanges={[ + { + label: 'Today', + startDate: new Date(), + endDate: new Date() + }, + { + label: 'Yesterday', + startDate: addDays(new Date(), -1), + endDate: addDays(new Date(), -1) + }, + { + label: 'Tomorrow', + startDate: addDays(new Date(), +1), + endDate: addDays(new Date(), +1) + }, + { + label: 'This Week', + startDate: startOfWeek(new Date()), + endDate: endOfWeek(new Date()) + }, + { + label: 'Last Week', + startDate: startOfWeek(addWeeks(new Date(), -1)), + endDate: endOfWeek(addWeeks(new Date(), -1)) + }, + { + label: 'Last 7 Days', + startDate: addWeeks(new Date(), -1), + endDate: new Date() + }, + { + label: 'This Month', + startDate: startOfMonth(new Date()), + endDate: endOfMonth(new Date()) + }, + { + label: 'Last Month', + startDate: startOfMonth(addMonths(new Date(), -1)), + endDate: endOfMonth(addMonths(new Date(), -1)) + } + // { + // label: 'All', + // startDate: new Date(), + // endDate: addDays(new Date(), -1), + // }, + ]} + /> + + + + + + + + {/* =============================== || Update Delivery Dialog || =============================== */} + + + + + + + + + + Update Delivery Status + + + {currentorder?.orderid ? `Order #${currentorder.orderid}` : 'Modify delivery details'} + + + + + + + {[ + { 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} + + f.set(e.target.value)} + sx={{ + mt: 0.5, + '& .MuiOutlinedInput-root': { + borderRadius: 2, + bgcolor: '#fff', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: f.color }, + '&.Mui-focused fieldset': { borderColor: f.color, borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(f.color)}` } + } + }} + /> + + ))} + + + 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: '#000000' }, + '&.Mui-focused fieldset': { borderColor: '#000000', borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#000000')}` } + } + }} + > + {['pending', 'accepted', 'started', 'arrived', 'delivered', 'cancelled'].map((s) => { + const m = STATUS_META[s] || { label: s, color: '#000000', 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: '#000000' }, + '&.Mui-focused fieldset': { borderColor: '#000000', borderWidth: 2 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#000000')}` } + } + }} + /> + + + + + + + + + + + + ); +}; + +export default Deliveries; diff --git a/src/pages/nearle/dispatch/CLAUDE.md b/src/pages/nearle/dispatch/CLAUDE.md index 2fec8fa..9a932d8 100644 --- a/src/pages/nearle/dispatch/CLAUDE.md +++ b/src/pages/nearle/dispatch/CLAUDE.md @@ -10,7 +10,7 @@ Rules for editing `Dispatch.js`, `Preview.js`, `CompareDataPanel.js`, and `dispa 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 +// BATCHES_DEFAULT_RAW — 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.5 }, // 9 AM – 12:30 PM @@ -21,11 +21,19 @@ Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors the **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. +Default `'due'` → bucket key is `['expecteddeliverytime']` (the booking's promised delivery slot, `serviceoptions[0].estimateddeliveryat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing. + +### ⛔ Never bucket on `assigntime` +It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it. + +Bucketing on it (which both pages did until this was found) means an order silently leaves the batch it belongs to and joins whichever window contains the current clock time, so the same orders appear under Afternoon and then Evening on the same day. The Dispatch page's 15-second poll makes the counts move on their own. + +Displaying it as a "last updated" stamp is fine — that's all the reports use it for. The true assignment record lives on `bookingassignments`, reachable only per-booking via `GET /admin/bookings/:id/track` (`{ booking, assignments[], riders[] }`); its `assignments[]` has not been observed non-empty, so it is not a verified source yet. If the backend ever exposes an `assignedat` on the list feed, that becomes the correct bucket key. ### 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 bucket on a timestamp that the backend re-stamps. See above. - Don't add a 4th batch without updating both pages and confirming with the backend what the new boundary means for assignments. --- diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index c2ca58a..d1c79cf 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -75,16 +75,17 @@ } .dispatch-container .logo-badge { - width: 32px; - height: 32px; - border-radius: 8px; - background: linear-gradient(135deg, #3A3A3A, #2563eb); + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; - font-weight: 800; - font-size: 14px; - color: #fff; +} + +.dispatch-container .logo-badge-img { + width: 100%; + height: 100%; + object-fit: contain; } .dispatch-container .logo-name { @@ -1902,21 +1903,6 @@ margin-bottom: 12px; } -.dispatch-container .kitchen-mark { - background: #f59e0b; - color: #fff; - width: 34px; - height: 34px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 800; - font-size: 14px; - border: 3px solid #fff; - box-shadow: 0 0 20px rgba(245, 158, 11, 0.6), 0 0 40px rgba(245, 158, 11, 0.3); -} - .dispatch-container .rcard-info { flex: 1; } @@ -4945,6 +4931,12 @@ /* Markers - styled as clean flags natively in Dispatch.js */ +/* Colour + shape only. createKitchenIcon() in Dispatch.js pins + width/height/font-size/border-width/box-shadow inline on every render, so + the size values below are never what actually paints — the marker is a fixed + 31px (38px focused) at every zoom level. Change the size there, not here. + (A second, conflicting .kitchen-mark rule used to sit ~3000 lines earlier in + this file and was silently overridden by this one. It has been removed.) */ .dispatch-container .kitchen-mark { background: var(--kitchen); border: 3px solid #fff; @@ -6814,10 +6806,8 @@ } .dispatch-container .logo-badge { - width: 28px; - height: 28px; - font-size: 13px; - border-radius: 6px; + width: 38px; + height: 38px; } .dispatch-container .logo { diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 3fd1d53..d674bb4 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -7,6 +7,7 @@ import 'leaflet/dist/leaflet.css'; // Compare → Combined mode to render planned + actual as parallel rails when // they share the same road geometry (otherwise they'd stack and read as one). import '../../../utils/leafletPolylineOffset'; +import doormileMark from 'assets/images/doormile-mark.png'; import dayjs from 'dayjs'; import { useInfiniteQuery, useQueries, useQuery, useMutation } from '@tanstack/react-query'; import { @@ -137,7 +138,7 @@ const pickupName = (o) => o.pickupcustomer || o.kitchen_key || o.locationname || // Named delivery batches — operator's mental model of the day's waves. // Each entry covers a half-open range [startHour, endHour) measured in // FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported. -// Three named batches, bucketed by assigntime per spec: +// Three named batches, bucketed by expected delivery time (see TIME_FIELDS): // • Morning Batch: before 8 AM (00:00 → 08:00) // • Afternoon Batch: 9 AM → 12:30 PM (09:00 → 12:30) // • Evening Batch: 4 PM → 7 PM (16:00 → 19:00) @@ -217,7 +218,12 @@ const getBatchForHour = (h, batches) => { // with a fallback to expecteddeliverytime so undelivered orders still bucket. const TIME_FIELDS = [ { id: 'delivered', label: 'Delivered', keys: ['deliverytime'] }, - { id: 'pending', label: 'Pending', keys: ['expecteddeliverytime'] }, + // The batch-bucketing default. `expecteddeliverytime` is the booking's + // promised delivery slot (serviceoptions[0].estimateddeliveryat) — it is set + // once and not re-stamped as the order progresses, which is exactly what a + // "wave" needs. Do not point this at `assigntime`: that field is the + // booking's last-modified column (see api.js) and drifts through the day. + { id: 'due', label: 'Due', keys: ['expecteddeliverytime'] }, { id: 'assigned', label: 'Assigned', keys: ['assigntime'] }, { id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] }, { id: 'started', label: 'Started', keys: ['starttime'] }, @@ -294,20 +300,6 @@ function MapAutoResize({ trigger }) { return null; } -// Leaflet divIcons are pixel-fixed by design — they don't shrink as the map -// zooms out, so a kitchen badge sized to look right at street level (zoom -// ~13+) reads as oversized once the operator zooms out to see a whole city. -// Reports current zoom up so the kitchen marker size can scale down with it. -function MapZoomTracker({ onZoomChange }) { - const map = useMap(); - useEffect(() => { - onZoomChange(map.getZoom()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map]); - useMapEvents({ zoomend: (e) => onZoomChange(e.target.getZoom()) }); - return null; -} - // haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so // deliveries.js's Update Status dialog can compute the same real, GPS-based // Actual KMs figure instead of leaving that field permanently blank. @@ -930,9 +922,6 @@ const Dispatch = ({ const [centerPopupOrder, setCenterPopupOrder] = useState(null); const isControlled = selectedRiderId !== undefined; const [clock, setClock] = useState(''); - // Current map zoom, mirrored from MapZoomTracker — drives the kitchen - // marker's zoom-responsive size (see kitchenIconZoomScale below). - const [mapZoom, setMapZoom] = useState(12); // Fetch all hubs/locations the logged-in user has access to. The list is // rendered as a dropdown next to the Dispatch title so the operator can @@ -952,11 +941,20 @@ const Dispatch = ({ const [locationMenuOpen, setLocationMenuOpen] = useState(false); const locationMenuRef = useRef(null); - // Which timestamp column drives slot bucketing. Default = assigntime so - // orders bucket into Morning/Afternoon/Evening by when they were assigned, - // per current spec. The status-wise time-field dropdown is hidden for now - // (see commented-out block in JSX), so this stays fixed at 'assigned'. - const [selectedTimeField, setSelectedTimeField] = useState('assigned'); + // Which timestamp column drives slot bucketing. Default = 'due' + // (expecteddeliverytime) so orders bucket into Morning/Afternoon/Evening by + // the delivery slot they were promised for. The status-wise time-field + // dropdown is hidden for now (see commented-out block in JSX), so this stays + // fixed at 'due'. + // + // Was 'assigned' (assigntime). That looked right — the spec says "bucket by + // assign time" — but on the Doormile backend `assigntime` is mapped to the + // booking's last-modified column (api.js), so it moves whenever the row is + // touched. Today's live orders therefore migrated into whichever batch + // contained the current clock time, and the 15s poll made the counts shift + // on their own. deliveries.js buckets on the same field for the same reason + // — the two pages must agree (see this folder's CLAUDE.md §1). + const [selectedTimeField, setSelectedTimeField] = useState('due'); const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false); const timeFieldMenuRef = useRef(null); @@ -2674,24 +2672,38 @@ const Dispatch = ({ } }; - // divIcons are pixel-fixed and don't shrink with the map's own zoom-out, - // so scale the badge down as the operator zooms out past street level - // (zoom 12 is this map's initial zoom — see the MapContainer below). - // Clamped to 1 at the top so zooming in past the default never grows the - // badge beyond its original design size, and floored at 0.55 so it never - // shrinks past legibility. - const kitchenIconZoomScale = Math.max(0.55, Math.min(1, 1 + (mapZoom - 12) * 0.12)); + // Kitchen badge is a FIXED 31px at every zoom level — deliberately not + // zoom-responsive. + // + // It used to scale with zoom (46px at street level, shrinking as the operator + // zoomed out) which made the marker read as a big amber blob over a + // city-wide view. Rather than tune the curve, the size is now constant: a + // pin that never changes size is easier to scan for, and 31px is the size + // the scaled version happened to hit at city zoom, which is the level + // operators actually work at. + // + // The white ring and amber glow are pinned to match. They're what made the + // old marker look oversized — the stylesheet painted a 3px ring and a + // 20/40px blur regardless of badge size, so a small dot sat inside an ~80px + // halo. These inline values win over the .kitchen-mark rule in Dispatch.css, + // which now only carries the unscaled design defaults. + // + // The focused badge stays proportionally larger (same 56:46 ratio as the + // original design) so drilling into a kitchen still visibly marks it. + const KITCHEN_ICON_SIZE = 31; + const KITCHEN_ICON_FOCUSED_SIZE = 38; const createKitchenIcon = (name, focused = false) => { - const base = focused ? 56 : 46; - const size = Math.round(base * kitchenIconZoomScale); + const size = focused ? KITCHEN_ICON_FOCUSED_SIZE : KITCHEN_ICON_SIZE; const anchor = Math.round(size / 2); + const border = focused ? 3 : 2; + const glow = 14; return L.divIcon({ className: '', iconSize: [size, size], iconAnchor: [anchor, anchor], popupAnchor: [0, -(anchor + 2)], - html: `
    ${(name || 'K').charAt(0).toUpperCase()}
    ` + html: `
    ${(name || 'K').charAt(0).toUpperCase()}
    ` }); }; @@ -3292,7 +3304,9 @@ const Dispatch = ({ {!embedded && (
    -
    D
    +
    + Doormile +
    Dispatch
    {appLocations && appLocations.length > 0 && (
    @@ -3637,8 +3651,10 @@ const Dispatch = ({
    Batch {/* Status-wise (time-field) filter is hidden for now per spec — - bucketing is locked to `assigntime`. Restore this block to bring - back the Delivered/Pending/Assigned/... dropdown. + bucketing is locked to `due` (expecteddeliverytime). Restore this + block to bring back the Delivered/Due/Assigned/... dropdown. Note + that picking "Assigned" there buckets on the booking's + last-modified column, which drifts through the day (see api.js).
    - } - /> - - - - - {/* ====================================== ||Contact Name (pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="Contact Name" - value={pickCust.firstname} - onChange={(e) => { - setPickCust({ ...pickCust, firstname: e.target.value }); - }} - /> - - {/* ====================================== ||Contact Number(pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="Contact Number" - value={pickCust.contactno} - onChange={(e) => { - if (e.target.value.length <= 10) { - setPickCust({ ...pickCust, contactno: e.target.value }); - } - if (pickNum == e.target.value) { - setShowCheck1(0); - } else { - setShowCheck1(1); - } - if (e.target.value.length < 10) { - setNumErr1(true); - } else { - setNumErr1(false); - } - }} - /> - - {/* ====================================== || Address (pick) || ====================================== */} - - - {addId1 == 0 ? ( -
    - -
    - ) : ( - { - setAddId1(0); - setPickCust({ - ...pickCust, - // firstname: '', - // contactno: '', - doorno: '', - suburb: '', - city: '', - postcode: '', - landmark: '' - }); - setShowDistance(false); - setStartPoint({ latitude: 0, longitude: 0 }); - }} - > - - - ) - }} - variant="outlined" - placeholder="Select" - value={pickCust.address} - onChange={(e) => { - setPickCust({ ...pickCust, address: e.target.value }); - if (e.target.value == '') { - setAddId1(0); - setShowDistance(false); - setStartPoint({ latitude: 0, longitude: 0 }); - } - }} - /> - )} -
    -
    - - {/* ====================================== ||Door No (pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="Door No / Street" - value={pickCust.doorno} - onChange={(e) => { - setPickCust({ ...pickCust, doorno: e.target.value }); - }} - /> - - {/* ====================================== || Suburb (pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="Location" - value={pickCust.suburb} - onChange={(e) => { - setPickCust({ ...pickCust, suburb: e.target.value }); - }} - /> - - {/* ====================================== || City (pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="City" - value={pickCust.city} - onChange={(e) => { - setPickCust({ ...pickCust, city: e.target.value }); - }} - /> - - {/* ====================================== || postcode (pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="Postcode" - value={pickCust.postcode} - onChange={(e) => { - setPickCust({ ...pickCust, postcode: e.target.value }); - }} - /> - - {/* ====================================== || Landmark (pick) || ====================================== */} - - - - - ) - }} - variant="outlined" - label="Landmark" - value={pickCust.landmark} - onChange={(e) => { - setPickCust({ ...pickCust, landmark: e.target.value }); - }} - /> - - {/* ====================================== ||Checkbox save for later (pick) || ====================================== */} - {showCheck1 == 1 && ( - - - { - setIsNumChange1(e.target.checked ? 1 : 0); - }} - /> - } - label="Save For Later" - /> - - - )} -
    -
    -
    -
    - - - {/* ================================================= || Drop || ================================================= */} - - - } - title="Drop Details" - subtitle="Where the order is delivered" - action={ - - } - /> - - - - - - Drop Details - - - {/* Customer */} - {/* { - if (val) { - setDropswitch(true); - setClientinfo({}); - } else { - setDropswitch(false); - setClientinfo({}); - } - }} - size="small" - /> - Business */} - - - - {/* new2 */} - {/* `${option.firstname} (${option.contactno})`} - sx={{ mt: 0, mb: 1 }} - renderInput={(params) => ( - { - setSearchCustList(e.target.value); - }} - InputProps={{ - ...params.InputProps, - inputProps: { - ...params.inputProps, - maxLength: 10 - } - }} - /> - )} - onChange={(e, val, reason) => { - if (val) { - if (pickCust.customerid === val.customerid) { - opentoast('Select Another Customer', 'error'); - setSearchCustList(''); - } else { - setDropCust(val); - setEndPoint({ latitude: val.latitude, longitude: val.longitude }); - setAddId2(1); - } - } - if (reason == 'clear') { - setSearchCustList(''); - } - }} - noOptionsText={ - /^[0-9]{10}$/.test(searchCustList) ? ( - - { - setDropCust({ ...dropCust, contactno: searchCustList }); - handleOkClick2(); - setSearchCustList(''); - }} - /> - - ) : null - } - /> */} - - - - - {/* ====================================== ||Contact Name (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.firstname} - onChange={(e) => { - setDropCust({ ...dropCust, firstname: e.target.value }); - }} - /> - - {/* ====================================== ||Contact Number (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.contactno} - onChange={(e) => { - if (e.target.value.length <= 10) { - setDropCust({ ...dropCust, contactno: e.target.value }); - } - if (dropNum == e.target.value) { - setShowCheck2(0); - } else { - setShowCheck2(1); - } - if (e.target.value.length < 10) { - setNumErr2(true); - } else { - setNumErr2(false); - } - }} - /> - - - - {addId2 == 0 ? ( -
    - -
    - ) : ( - { - setAddId2(0); - setDropCust({ - ...dropCust, - firstname: '', - contactno: '', - doorno: '', - suburb: '', - city: '', - postcode: '', - landmark: '' - }); - setShowDistance(false); - setEndPoint({ latitude: 0, longitude: 0 }); - }} - > - - - ) - }} - variant="outlined" - placeholder="Select" - value={dropCust.address} - onChange={(e) => { - setPickCust({ ...dropCust, address: e.target.value }); - if (e.target.value == '') { - setAddId2(0); - setShowDistance(false); - setEndPoint({ latitude: 0, longitude: 0 }); - } - }} - /> - )} -
    -
    - - {/* ====================================== ||Door No (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.doorno} - onChange={(e) => { - setDropCust({ ...dropCust, doorno: e.target.value }); - }} - /> - - {/* ====================================== ||Suburb (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.suburb} - onChange={(e) => { - setDropCust({ ...dropCust, suburb: e.target.value }); - }} - /> - - {/* ====================================== ||City (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.city} - onChange={(e) => { - setDropCust({ ...dropCust, city: e.target.value }); - }} - /> - - - {/* ====================================== ||Postcode (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.postcode} - onChange={(e) => { - setDropCust({ ...dropCust, postcode: e.target.value }); - }} - /> - - {/* ====================================== ||Landmark (drop) || ====================================== */} - - - - - ) - }} - value={dropCust.landmark} - onChange={(e) => { - setDropCust({ ...dropCust, landmark: e.target.value }); - }} - /> - - {/* ====================================== ||Checkbox save for later (drop) || ====================================== */} - {showCheck2 == 1 && ( - - - { - setIsNumChange2(e.target.checked ? 1 : 0); - }} - /> - } - label="Save For Later" - /> - - - )} -
    -
    - {/* - - */} -
    -
    -
    -
    - {/* ================================================= || Time || ================================================= */} - - - } title="Schedule" subtitle="Pickup date & time slot" /> - - - - - Date - - - { - let dateres11 = dayjs().diff(dayjs(`${dayjs(e).format('YYYY-MM-DD')}`), 'd'); - setSelectedtime(''); - if (dateres11 <= 0) { - setStartdate(e); - setEnddate(e); - - let arr = []; - timeslotarr.map((val) => { - if ( - dayjs().diff(dayjs(`${dayjs(e).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0 - ) { - arr.push(val); - } - }); - if (arr[0]) { - setOrderarr([ - { - sno: 1, - address: '', - customerid: '', - deliverytime: dayjs(arr[0]) || '', - deliverylocationid: '', - clientname: '', - contactno: '', - latitude: '', - longitude: '' - } - ]); - } else { - setOrderarr([]); - } - } else { - setAlertmessage('choose Upcoming Date'); - opentoast('choose Upcoming Date', 'warning'); - setStartdate(NaN); - } - }} - value={dayjs(startdate)} - sx={{ width: '100%', mt: 2 }} - disablePast - /> - - - Time - - - - {timeslotarr.map((val, index) => { - if ( - dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= - 0 - ) { - const active = dayjs(selectedtime).format('HH:mm') == dayjs(val).format('HH:mm'); - return ( - { - if (distance > appLocaRadius) { - setOpen4(true); - } else if (showDistance) { - setSelectedtime(val); - } else { - opentoast('Select Pickup and Drop', 'error'); - } - }} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - px: 1.25, - py: 0.5, - cursor: 'pointer', - borderRadius: 999, - border: `1.5px solid ${active ? '#f59e0b' : edge('#f59e0b')}`, - bgcolor: active ? '#f59e0b' : tint('#f59e0b'), - color: active ? '#fff' : '#f59e0b', - fontSize: 12, - fontWeight: 800, - transition: 'all 0.15s', - boxShadow: active ? `0 6px 18px ${ring('#f59e0b')}` : 'none', - '&:hover': { - borderColor: '#f59e0b', - boxShadow: active ? `0 6px 18px ${ring('#f59e0b')}` : `0 0 0 3px ${ring('#f59e0b')}` - } - }} - > - {dayjs(val).format('hh:mm A')} - - ); - } - })} - - - - - - - - - - } - title="Distance & Pricing" - subtitle="Auto-calculated from pickup → drop" - /> - - {showDistance && ( - - - - - - - - - )} - - Category - } - onChange={(event, value) => setCategory(value || null)} - onInputChange={(event, value, reason) => { - if (reason === 'input') setCategory(value || null); - }} - /> - - Weight - - { - handleChipClick('1-10kgs'); - setWeight('1-10kgs'); - }} - /> - { - handleChipClick('11-20kgs'); - setWeight('11-20kgs'); - }} - /> - { - handleChipClick('21-30kgs'); - setWeight('21-30kgs'); - }} - /> - - - SMS Delivery - { - setIsSms(e.target.checked ? 1 : 0); - }} - /> - - - - - - - - - {/* ============================== || Cash Collect & Quantity || ============================== */} - - } - title="Collection & Quantity" - subtitle="Cash to collect on delivery, total items" - /> - - - - setCollectionamt(e.target.value)} - inputProps={{ min: 0 }} - /> - - - setQuantity(e.target.value)} - inputProps={{ min: 1 }} - /> - - - - - - {/* ================================================= || Notes || ================================================= */} - - } - title="Notes" - subtitle="Add anything the rider should know" - /> - - setOtherinstructions(e.target.value)} - /> - - - {!showDistance && ( - - Set pickup & drop to enable - - )} - - - - - - - - {/* - - - Select Time - - - - - - {timeslotarr.map((val) => { - if ( - dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0 - // && currentsno - ) { - return ( - <> - - { - setSelectedtime(dayjs(val).format('hh:mm A')); - }} - /> - - - ); - } - })} - - - - - - - - */} - - {/* ============================================= || saved address Dialog || ============================================= */} - setIsCustomerOpen(false)} - fullWidth - maxWidth="sm" - PaperProps={{ sx: { borderRadius: 3 } }} - > - - - - - - - - Saved Contacts - - - Pick a saved contact's name & phone for {pickordrop === 1 ? 'Pickup' : 'Drop'} — address isn't stored, search it below - - - - - - setSearchCustList(e.target.value)} - sx={{ - flex: 1, - '& .MuiOutlinedInput-input': { p: '6px 4px', fontSize: 13, fontWeight: 600 }, - '& fieldset': { border: 'none' } - }} - endAdornment={ - searchCustList && ( - setSearchCustList('')} sx={{ p: 0.25, color: BRAND }}> - - - ) - } - autoComplete="off" - /> - - - - {customerlist.length === 0 ? ( - - - - - - No saved contacts - - - {searchCustList ? 'Try a different keyword.' : 'Place an order with a new contact — it will show up here next time.'} - - - ) : ( - - {customerlist.map((address, index) => { - // GET /admin/customers (B2C directory) — { appcustomerid, - // createdat, email, name, phone, totalbookings }. No - // address/coordinates at all (that was a jupiter-only field - // on the old customer record). Picking a saved contact here - // only pre-fills the name & phone; the operator still has - // to search the address below like a brand-new contact. - const contactPhone = address.phone; - const isUsed = !!contactPhone && pickCust.phone === contactPhone; - return ( - { - if (isUsed) return; - setIsCustomerOpen(false); - const contact = { firstname: address.name, contactno: contactPhone, phone: contactPhone }; - if (pickordrop === 1) { - setAddId1(1); - setPickCust((prev) => ({ ...prev, ...contact })); - setPickNum(contactPhone); - } else { - setAddId2(1); - setDropCust((prev) => ({ ...prev, ...contact })); - setdropNum(contactPhone); - } - }} - sx={{ - display: 'flex', - alignItems: 'flex-start', - gap: 1.25, - p: 1.5, - borderRadius: 2, - border: '1px solid', - borderColor: isUsed ? edge('#94a3b8') : DT.borderSubtle, - bgcolor: isUsed ? DT.surfaceAlt : '#fff', - cursor: isUsed ? 'not-allowed' : 'pointer', - opacity: isUsed ? 0.6 : 1, - transition: 'all 0.15s', - '&:hover': isUsed - ? {} - : { - borderColor: edge(BRAND), - bgcolor: tint(BRAND), - boxShadow: DT.shadowSoft - } - }} - > - - {String(address.name || '?') - .charAt(0) - .toUpperCase()} - - - - - {address.name || 'Unnamed'} - - - {contactPhone || '—'} - - {isUsed && ( - - Already used - - )} - - - {address.email || 'No address on file — search it below'} - - - - ); - })} - - )} - - - - - - - setOpen4(false)} PaperProps={{ sx: { borderRadius: 3 } }}> - - - - - - - - Out of Service Area - - - This drop point is outside the supported radius - - - - - - - Service is not available at this location. Try a different drop point within the coverage zone. - - - - - - - - - ); -}; - -export default Createorder1; +import { + MdAddShoppingCart, + MdMyLocation, + MdLocationOn, + MdSchedule, + MdLocalShipping, + MdAttachMoney, + MdNotes, + MdCheckCircle, + MdReceiptLong, + MdInventory2, + MdStraighten, + MdPersonPin, + MdPerson, + MdHistoryToggleOff +} from 'react-icons/md'; + +const dtA = (c, suffix) => `${c}${suffix}`; +const BRAND = '#000000'; +const BRAND_LIGHT = '#3A3A3A'; + +// Soft card section header — coloured banner above each form section. +const SectionHeader = ({ color, icon, title, subtitle, action }) => ( + + + {icon} + + {title} + {subtitle && ( + + {subtitle} + + )} + + + {action} + +); + +// Section wrapper Paper used as the outer card for each form block. +const SectionCard = ({ children, sx = {} }) => ( + + {children} + +); + +// Some saved locations come back from GET /admin/tenants/:id/locations with +// a blank locationname (live-confirmed, e.g. tenant 1's only location) — fall +// back to the address rather than rendering a bare icon with no text. +const locationDisplayName = (loc) => (loc?.locationname && loc.locationname.trim()) || loc?.address || 'Unnamed location'; + +// Tenants with many locations in the same city (DailyGrubs has 23, all in +// Coimbatore) made the old "(city)" suffix useless for telling them apart — +// sort primary-first then alphabetically so the picker is at least ordered +// the same way every time; the full address is shown per-option instead +// (see renderOption on the Business Location Autocomplete below). +const sortLocations = (locations) => + [...(locations || [])].sort((a, b) => { + if (!!a.isprimary !== !!b.isprimary) return a.isprimary ? -1 : 1; + return locationDisplayName(a).localeCompare(locationDisplayName(b)); + }); + +// POST /admin/expressbooking's parcels[].itemcategory is a free-text string +// (see express-console-api.md's sample body) — the old jupiter backend had a +// real /utils/getsubcategories lookup table behind this dropdown, but that +// endpoint has no equivalent on the new API (confirmed — see +// src/pages/api/CLAUDE.md's list of retired-with-no-replacement resources). +// This is a fixed, curated list rather than a live lookup so the dropdown +// actually has options; `freeSolo` lets an operator type a category that +// isn't on the list instead of being blocked. +const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others']; + +const Createorder1 = () => { + // ================================================= || GoogleMaps (Drawer) || ================================================= + const [inputValue, setInputValue] = React.useState(''); + const [inputValue1, setInputValue1] = React.useState(''); + const [inputValue2, setInputValue2] = React.useState(''); + const [inputValue3, setInputValue3] = React.useState(''); + const [options, setOptions] = React.useState([]); + const [options1, setOptions1] = React.useState([]); + const loaded1 = React.useRef(false); + const [mobilenumber, setMobilenumber] = useState(''); + const [emailaddress, setEmailaddress] = useState(''); + const [city, setCity] = useState(''); + const [city1, setCity1] = useState(''); + const [zipcode, setZipcode] = useState(''); + const [zipcode1, setZipcode1] = useState(''); + const [state, setState] = useState(''); + const [state1, setState1] = useState(''); + const [suburb, setSuburb] = useState(''); + const [suburb1, setSuburb1] = useState(''); + const [pickContactName, setPickContactName] = useState(''); + const [dropContactName, setDropContactName] = useState(''); + const [pickdoorno, setPickDoorno] = useState(''); + const [dropDoorno, setDropDoorno] = useState(''); + const [pickLandmark, setPickLandmark] = useState(''); + const [dropLandmark, setDropLandmark] = useState(''); + const [latlong, setLatlong] = useState({}); + const [latlong1, setLatlong1] = useState({}); + const autocompleteService = useRef(null); + const [tenanatLocoId, setTenanatLocoId] = useState(localStorage.getItem('locationid')); + const [isLocation, setIsLocation] = useState(false); + // Drives what shows in place of the Business Location picker. A toast alone + // wasn't enough — it's easy to miss, and an empty Autocomplete with no + // toast visible reads as "broken" with zero explanation. This makes the + // reason a persistent, visible message instead. + const [locationsStatus, setLocationsStatus] = useState('loading'); + const textFieldRef1 = useRef(null); + const textFieldRef2 = useRef(null); + const [collectionamt, setCollectionamt] = useState(0); + const [quantity, setQuantity] = useState(1); + + const handleOkClick1 = () => { + // Set focus back to the text field after clicking the "OK" chip + if (textFieldRef1.current) { + textFieldRef1.current.focus(); + } + }; + + const handleOkClick2 = () => { + // Set focus back to the text field after clicking the "OK" chip + if (textFieldRef2.current) { + textFieldRef2.current.focus(); + } + }; + + const top100Films = [ + { label: 'The Shawshank Redemption', year: 1994 }, + { label: 'The Godfather', year: 1972 }, + { label: 'The Godfather: Part II', year: 1974 }, + { label: 'The Dark Knight', year: 2008 }, + { label: '12 Angry Men', year: 1957 } + ]; + + const appId = localStorage.getItem('applocationid'); + const navigate = useNavigate(); + const [open, setOpen] = useState({}); + const [open1, setOpen1] = useState(''); + const [open2, setOpen2] = useState(false); + const [open3, setOpen3] = useState(false); + const [open4, setOpen4] = useState(false); + const [shift, setShift] = useState(1); + const [clientlist, setClientlist] = useState([]); + const [clientdetail, setClientdetail] = useState([]); + const [eventname, setEventname] = useState(''); + const [startdate, setStartdate] = useState(dayjs().format('MM-DD-YYYY')); + const [enddate, setEnddate] = useState(dayjs().add(1, 'day').format('MM-DD-YYYY')); + // const [starttime, setStatrttime] = useState(`${dayjs().format('MM-DD-YYYY')} 08:00:00`); + const [starttime, setStatrttime] = useState(); + // const [endtime, setEndtime] = useState(`${dayjs().format('MM-DD-YYYY')} 20:00:00`); + const [endtime, setEndtime] = useState(); + const [timeslotarr, setTimeslotarr] = useState([]); + const [currentsno, setCurrentsno] = useState(''); + const [roleoptions, setRoleoptions] = useState([]); + const theme = useTheme(); + const [otherinstructions, setOtherinstructions] = useState(''); + const [attireslist, setAttireslist] = useState([]); + const [serviceaddonslist, setServiceaddonslist] = useState([]); + const [orderaddonobj, setOrderaddonobj] = useState([]); + const [stafflist, setStafflist] = useState([]); + const [loading2, setLoading2] = useState(false); + const [loading, setLoading] = useState(false); + const [shiftarr, setShiftarr] = useState([]); + const [shiftarr1, setShiftarr1] = useState([]); + const [orderarr, setOrderarr] = useState([]); + const [alertmessage, setAlertmessage] = useState(''); + const [tabstatus, setTabstatus] = useState(''); + const [tenantinfo, setTenantinfo] = useState({}); + const [searchword, setSearchword] = useState(''); + const [clientdetailarr, setClientdetailarr] = useState([]); + const [admintoken, setAdmintoken] = useState(); + const [tenantlocationlist, setTenantlocationlist] = useState([]); + const [tenant, setTenant] = useState({}); + const [clientinfo, setClientinfo] = useState({}); + const [selectedtime, setSelectedtime] = useState(''); + const [tenantlist, setTenantlist] = useState([]); + const [tenantid, setTenantid] = useState(); + const [tenantlocation, setTenantlocation] = useState(''); + const [pickupswitch, setPickupswitch] = useState(true); + const [deliverytype, setDeliverytype] = useState('B'); + const [dropswitch, setDropswitch] = useState(false); + const [startPoint, setStartPoint] = useState({ latitude: 0, longitude: 0 }); + const [endPoint, setEndPoint] = useState({ latitude: 0, longitude: 0 }); + const [showDistance, setShowDistance] = useState(false); + const [distance, setDistance] = useState(0); + const [basePrice, setBasePrice] = useState(0); + const [pricePerKm, setPricePerKm] = useState(0); + const [minKm, setMinKm] = useState(0); + const [totalCharge, setTotalCharge] = useState(0); + const [category, setCategory] = useState(null); + const [weight, setWeight] = useState(''); + // Per express-console-api.md's "Tenant scoping" section: token tenantid + // 0/null means Doormile's own staff, who can see/act on every tenant — + // NOT an error state. A client (tenant) login carries a real tenantid and + // never needs this picker at all. loggedInTenantId is what's actually on + // the account; selectedTenantId is what the page operates as right now + // (auto-filled for a client login, chosen from the picker for staff). + const loggedInTenantId = localStorage.getItem('tenantid'); + const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0'; + const [selectedTenantId, setSelectedTenantId] = useState(isStaffLogin ? '' : loggedInTenantId); + const [staffTenantList, setStaffTenantList] = useState([]); + const [staffTenantListLoading, setStaffTenantListLoading] = useState(false); + const tid = selectedTenantId; + const [selectedCatChip, setSelectedCatChip] = useState(null); + const [isCustomerOpen, setIsCustomerOpen] = useState(false); + const [searchCustList, setSearchCustList] = useState(''); + const [customerlist, setCustomerlist] = useState([]); + const [pickCust, setPickCust] = useState({}); + const [dropCust, setDropCust] = useState({}); + const [pickordrop, setpickordrop] = useState(0); // 1 ->pick 2 -> drop + const [addId1, setAddId1] = useState(0); + const [addId2, setAddId2] = useState(0); + const [tenantLocations, setTenantlocations] = useState([]); + const [appLocaLat, setAppLocaLat] = useState(); + const [appLocaLng, setAppLocaLng] = useState(); + const [appLocaRadius, setAppLocaRadius] = useState(); + const [isNumChange1, setIsNumChange1] = useState(0); + const [isNumChange2, setIsNumChange2] = useState(0); + const [showCheck1, setShowCheck1] = useState(0); + const [showCheck2, setShowCheck2] = useState(0); + const [pickNum, setPickNum] = useState(); + const [dropNum, setdropNum] = useState(); + const [numErr1, setNumErr1] = useState(false); + const [numErr2, setNumErr2] = useState(false); + const [isSms, setIsSms] = useState(0); + + const handleChipClick = (chipLabel) => { + setSelectedCatChip(chipLabel); + }; + + const chipStyle = (chipLabel) => ({ + cursor: 'pointer', + backgroundColor: selectedCatChip === chipLabel ? theme.palette.primary.main : 'default', + color: selectedCatChip === chipLabel ? '#fff' : '', + '&:hover': { + backgroundColor: selectedCatChip === chipLabel ? theme.palette.primary.main : theme.palette.primary.light, + color: '#fff' + } + }); + + const fetchTenantPricing = async () => { + try { + const pricing = (await getAdminPricing()) || []; + // tid comes from localStorage (always a string) but pricing.tenantid is + // numeric — a strict === here never matched, so basePrice/pricePerKm/ + // minKm stayed at their 0 defaults and every order priced at ₹0. + const match = pricing.find((p) => String(p.tenantid) === String(tid)); + if (match) { + setBasePrice(match.baseprice); + setPricePerKm(match.priceperkm); + setMinKm(match.basedistance); + } + } catch (error) { + // swallow — pricing defaults to 0 and stays there, handled upstream + } + }; + useEffect(() => { + if (tid) fetchTenantPricing(); + }, [tid]); + + useEffect(() => { + if (startPoint.latitude != 0 && startPoint.longitude != 0 && endPoint.latitude != 0 && endPoint.longitude != 0) { + // getDistance(); + calculateDistance(startPoint, endPoint); + } + }, [startPoint, endPoint]); + + const getDistance = () => { + const dist = geolib.getPreciseDistance(startPoint, endPoint, 1); + const distanceInKm = (dist / 1000).toFixed(2); + const roundedDistance = Math.round(distanceInKm); + setDistance(roundedDistance.toFixed(2)); + if (roundedDistance < minKm) { + setTotalCharge(basePrice); + } else { + const total = (roundedDistance - minKm) * pricePerKm + basePrice; + setTotalCharge(total); + } + setShowDistance(true); + if (roundedDistance > appLocaRadius) { + setShowDistance(true); + setOpen4(true); + } + }; + + const calculateDistance = async (pickup, drop) => { + try { + const roundedDistance = await calculateDrivingDistance(pickup, drop); + setDistance(roundedDistance); + + const total = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm); + setTotalCharge(total); + + setShowDistance(true); + if (roundedDistance > appLocaRadius) { + setShowDistance(true); + setOpen4(true); + } + } catch (error) { + console.error('Error calculating distance:', error); + } + }; + + useEffect(() => { + if (timeslotarr[0]) { + let arr = []; + timeslotarr.map((val) => { + if (dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0) { + arr.push(val); + } + }); + + if (arr[0]) { + setOrderarr([ + { + sno: 1, + address: '', + customerid: '', + deliverytime: dayjs(arr[0]) || '', + deliverylocationid: '', + clientname: '', + contactno: '', + latitude: '', + longitude: '' + } + ]); + } + } + }, [timeslotarr]); + + useEffect(() => { + if (searchword) { + let arr = clientdetail.filter((val) => { + return ( + val.address.toLowerCase().includes(searchword.toLowerCase()) || + val.firstname.toLowerCase().includes(searchword.toLowerCase()) || + val.contactno.toLowerCase().includes(searchword.toLowerCase()) + ); + }); + setClientdetailarr([...arr]); + } else { + setClientdetailarr([...clientdetail]); + } + }, [searchword]); + + // ==================================================== || fetchtenantinfo || ==================================================== + const fetchtenantinfo = async () => { + setLoading(true); + try { + const info = await getAdminTenant(tid); + if (info) { + setTenant(info); + setTenantid(info.tenantid); + fetchAppAdminTokens(); + } + } catch (err) { + // doormileAxios's response interceptor rejects with error.response.data + // (not the raw axios error), so the message lives at err.message here. + opentoast(err?.message || 'Could not load tenant info', 'error', 3000); + } finally { + setLoading(false); + } + }; + useEffect(() => { + if (tid) fetchtenantinfo(); + }, [tid]); + + // ==================================================== || fetchTiming || ==================================================== + // No per-zone open/close-hours or geofence-radius endpoint in the new API + // (applocationid/"zones" aren't a resource there at all — see fetchAppLocations + // in pages/api/api.js). Falls back to a fixed 09:00–21:00 delivery window so + // the order-row / time-slot picker below still has slots to initialise from, + // and a generous default radius so the "too far" warning doesn't misfire. + const fetchTiming = async () => { + setLoading(true); + const opentime = '09:00:00'; + const closetime = '21:00:00'; + setAppLocaLat(0); + setAppLocaLng(0); + setAppLocaRadius(100); + setStatrttime(`${dayjs().format('MM-DD-YYYY')} ${opentime}`); + setEndtime(`${dayjs().format('MM-DD-YYYY')} ${closetime}`); + let arr = []; + for ( + let i = `${dayjs().format('MM-DD-YYYY')} ${opentime}`; + dayjs(`${dayjs().format('MM-DD-YYYY')} ${closetime} `).diff(i, 'm') >= 0; + i = dayjs(i).add(30, 'm') + ) { + arr.push(i); + } + setTimeslotarr(arr); + setLoading(false); + }; + useEffect(() => { + fetchTiming(); + }, []); + + // =============================================== || fetchAppAdminTokens (via appId) || =============================================== + // No zone-admin-token lookup endpoint in the new API. + const fetchAppAdminTokens = async () => setAdmintoken([]); + + useEffect(() => { + if (starttime && endtime) { + fetchAppAdminTokens(); + } + }, [starttime, endtime]); + + // =============================================== || opentoast || =============================================== + + const opentoast = (message, variant, time) => { + enqueueSnackbar(message, { + variant: variant, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + autoHideDuration: time ? time : 2000 + }); + }; + + function closeAddressModal() { + setOpen2(false); + } + function closetimemodal() { + setOpen3(false); + setCurrentsno(''); + } + + // =============================================== || createsubmitobj1 (create orders) || =============================================== + + const createsubmitobj2 = async () => { + // POST /admin/expressbooking's schema is a single flat booking object + // (tenantid, pickup* fields, customer_phone/name, delivery* fields, + // service_option, finalprice, notes, parcels[]) — nothing like the old + // pickup/drop/orders triple. The drop contact becomes the booking's + // customer (new API models the delivery recipient as "the customer"). + // + // Always send raw pickupaddress/pickuppincode/pickupcity/pickuplatitude/ + // pickuplongitude, even when a saved location was picked (the Business + // Location Autocomplete + gettenantlocations()'s single-location + // auto-select both copy the chosen location's fields into pickCust) — + // live-confirmed the pickuplocationid shortcut currently 500s on + // POST /admin/expressbooking (backend bug, reported; not something a + // frontend fix can work around other than avoiding the field). + const arr = { + tenantid: Number(tid) || tenant.tenantid, + pickupaddress: pickCust.address || '', + pickuppincode: pickCust.postcode || '', + pickupcity: pickCust.city || '', + pickuplatitude: Number(pickCust.latitude) || 0, + pickuplongitude: Number(pickCust.longitude) || 0, + customer_phone: dropCust.contactno || '', + customer_name: dropCust.firstname || '', + deliveryaddress: dropCust.address || '', + deliverypincode: dropCust.postcode || '', + deliverycity: dropCust.city || '', + deliverylatitude: Number(dropCust.latitude) || 0, + deliverylongitude: Number(dropCust.longitude) || 0, + service_option: 'Normal', + finalprice: +totalCharge.toFixed(2) || 0, + notes: otherinstructions || '', + // The parcel object has no numeric quantity field (confirmed against + // express-console-api.md's schema) — "N items" is expressed by sending + // N parcel entries, which is also how the Deliveries page already reads + // it back (fetchDeliveries in api.js: `Quantity: b.parcels?.length`). + // This previously always sent exactly one parcel regardless of what the + // operator typed into the Quantity field, so it silently got dropped — + // matching "I entered 5, deliveries page shows 1". + parcels: Array.from({ length: Math.max(1, Number(quantity) || 1) }, () => ({ + itemcategory: category || 'General', + itemdescription: 'Order', + declaredvalue: +totalCharge.toFixed(2) || 0 + })) + }; + if (!arr.tenantid) { + opentoast('Pick a tenant before creating an order', 'warning', 2000); + } else if (!pickCust.firstname) { + opentoast('Enter Pickup Contact Name ', 'warning', 2000); + } else if (!pickCust.contactno) { + opentoast('Enter Pickup Contact Number ', 'warning', 2000); + } else if (pickCust.contactno.length != 10) { + opentoast('Check Pickup Contact Number ', 'error', 2000); + } else if (!pickCust.suburb) { + opentoast('Enter Pickup Location ', 'warning', 2000); + } else if (!pickCust.city) { + opentoast('Enter Pickup City ', 'warning', 2000); + } else if (!pickCust.postcode) { + opentoast('Enter Pickup Postcode ', 'warning', 2000); + } else if (!pickCust.landmark) { + opentoast('Enter Pickup Landmark ', 'warning', 2000); + } else if (!dropCust.firstname) { + opentoast('Enter Drop Contact Name ', 'warning', 2000); + } else if (!dropCust.contactno) { + opentoast('Enter Drop Contact Number', 'warning', 2000); + } else if (dropCust.contactno.length !== 10) { + opentoast('Check Drop Contact Number ', 'error', 2000); + } else if (!dropCust.suburb) { + opentoast('Enter Drop Suburb ', 'warning', 2000); + } else if (!dropCust.city) { + opentoast('Enter Drop City ', 'warning', 2000); + } else if (!dropCust.postcode) { + opentoast('Enter Drop postcode ', 'warning', 2000); + } else if (!dropCust.landmark) { + opentoast('Enter Drop Landmark ', 'warning', 2000); + } else if (!selectedtime) { + opentoast('Choose deliverytime ', 'warning', 2000); + } else { + setLoading(true); + try { + const createRes = await createExpressBooking(arr); + if (createRes.success) { + enqueueSnackbar('Order Created Successfully', { + variant: 'success', + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + autoHideDuration: 1000 + }); + navigate('/doormile/orders'); + } else { + opentoast(createRes.message || 'Something went wrong, Cannot create order', 'warning'); + } + } catch (error) { + // Previously silent — a thrown error here (network failure, CityGate + // rejection, validation error) left the operator with no feedback at + // all and a permanently stuck loading spinner (setLoading(false) was + // only reachable on the success path). doormileAxios's response + // interceptor already unwraps to error.response.data before + // rejecting, so the message lives at error.message, not + // error.response.data.message. + opentoast(error?.message || 'Cannot create order', 'error', 3000); + } finally { + setLoading(false); + } + } + }; + + const clicked1 = (e) => { + setShift(e); + }; + + const dialogopen = (i, result) => { + setOpen({ shiftsno: result.sno, sno: i.sno }); + }; + const dialogclose = () => { + setOpen(''); + }; + // ========================================================= || clientdetails || ========================================================= + // GET /admin/customers (B2C directory), not /admin/tenantcustomers — that + // resource is a manually-added contact book with nothing in it unless + // someone explicitly creates an entry. This one is confirmed to auto-grow: + // POST /admin/expressbooking's customer_phone/customer_name "creates a + // Guest customer if unknown" (see express-console-api.md), so every order + // placed lands a reusable contact here — matching the old jupiter flow + // where placing an order (customerid: 0) auto-saved the customer too. + // Field shape is { appcustomerid, createdat, email, name, phone, + // totalbookings } — a single `name`, not firstname/lastname, and still no + // address at all (see the Saved Contacts dialog below). + const clientdetails = async () => { + setLoading2(true); + try { + const customers = (await getAdminCustomers()) || []; + const filtered = searchCustList + ? customers.filter((val) => val.name?.toLowerCase().includes(searchCustList.toLowerCase()) || val.phone?.includes(searchCustList)) + : customers; + setClientdetail(filtered); + setCustomerlist(filtered); + setClientdetailarr(filtered.map((val) => ({ label: `${val.name} | ${val.phone}`, ...val }))); + } catch (err) { + opentoast('server error', 'warning'); + } finally { + setLoading2(false); + } + }; + useEffect(() => { + clientdetails(); + }, [searchCustList.length > 3, searchCustList == '']); + // ================================================== || sendnotifications || ================================================== + // No bulk-notify endpoint in the new API — never actually called (dead in + // the original code too, only referenced from a commented-out call site). + const sendnotifications = async () => {}; + // ============================================= || Address Autocomplete (pick) || ============================================= + const handlePickPlaceSelected = (place) => { + setInputValue2(`${place.name}, ${place.formatted_address}`); + // to trigger getDistance + setStartPoint({ latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng() }); + setPickCust({ ...pickCust, address: `${place.name} ${place.formatted_address}` }); + const address = { + address: `${place.name} ${place.formatted_address}`, + street_number: '', + route: '', + locality: '', + sublocality_level_1: '', + administrative_area_level_3: '', + administrative_area_level_1: '', + country: '', + postal_code: '' + }; + place.address_components.forEach((component) => { + component.types.forEach((type) => { + switch (type) { + case 'street_number': + address.street_number = component.long_name; + break; + case 'route': + address.route = component.long_name; + break; + case 'locality': + address.locality = component.long_name; + break; + case 'sublocality_level_1': + address.sublocality_level_1 = component.long_name; + break; + case 'administrative_area_level_3': + address.administrative_area_level_3 = component.long_name; + break; + case 'administrative_area_level_1': + address.administrative_area_level_1 = component.long_name; + break; + case 'country': + address.country = component.long_name; + break; + case 'postal_code': + address.postal_code = component.long_name; + break; + // Add more cases as needed for other types + } + }); + }); + + // Use address object as per your requirements + setPickCust({ + ...pickCust, + address: address.address, + doorno: `${address.street_number} ${address.route}`, + suburb: address.sublocality_level_1, + city: address.locality, + postcode: address.postal_code, + latitude: place.geometry.location.lat(), + longitude: place.geometry.location.lng() + }); + }; + // ============================================= || Address Autocomplete (Drop) || ============================================= + + const handleDropPlaceSelected = (place) => { + setInputValue3(`${place.name}, ${place.formatted_address}`); + setEndPoint({ latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng() }); + setDropCust({ ...dropCust, address: `${place.name} ${place.formatted_address}` }); + const address = { + address: `${place.name} ${place.formatted_address}`, + street_number: '', + route: '', + locality: '', + sublocality_level_1: '', + administrative_area_level_3: '', + administrative_area_level_1: '', + country: '', + postal_code: '' + }; + place.address_components.forEach((component) => { + component.types.forEach((type) => { + switch (type) { + case 'street_number': + address.street_number = component.long_name; + break; + case 'route': + address.route = component.long_name; + break; + case 'locality': + address.locality = component.long_name; + break; + case 'sublocality_level_1': + address.sublocality_level_1 = component.long_name; + break; + case 'administrative_area_level_3': + address.administrative_area_level_3 = component.long_name; + break; + case 'administrative_area_level_1': + address.administrative_area_level_1 = component.long_name; + break; + case 'country': + address.country = component.long_name; + break; + case 'postal_code': + address.postal_code = component.long_name; + break; + // Add more cases as needed for other types + } + }); + }); + + // Use address object as per your requirements + setDropCust({ + ...dropCust, + address: address.address, + doorno: `${address.street_number} ${address.route}`, + suburb: address.sublocality_level_1, + city: address.locality, + postcode: address.postal_code, + latitude: place.geometry.location.lat(), + longitude: place.geometry.location.lng() + }); + }; + + // ============================================= || gettenantlocations (branches) || ============================================= + const gettenantlocations = async () => { + if (!tid) { + // Staff login (express-console-api.md: token tenantid 0/null = Doormile + // staff, who can act on any tenant) with no tenant picked yet — the + // picker UI covers this, not an error. A genuinely tenant-less, + // non-staff account can't get here at all (isStaffLogin would be true). + setLocationsStatus('no-tenant'); + return; + } + setLocationsStatus('loading'); + try { + const locations = (await getTenantLocations(tid)) || []; + setTenantlocations(locations); + if (locations.length === 1) { + setLocationsStatus('ready'); + setIsLocation(true); + setTenanatLocoId(locations[0].tenantlocationid); + const loc = locations[0]; + setPickCust((prev) => ({ + ...prev, + address: loc.address || '', + suburb: loc.locationname || loc.city || '', + city: loc.city || '', + postcode: loc.pincode || '', + landmark: loc.address || '', + latitude: loc.latitude, + longitude: loc.longitude + })); + } else if (locations.length === 0) { + // No saved locations for this tenant — every pickup field below is a + // plain manual TextField anyway, so let the operator fill it in by + // hand instead of leaving the form permanently disabled. + setLocationsStatus('empty'); + setIsLocation(true); + opentoast('No saved business locations — enter pickup details manually below', 'info', 3000); + } else { + setLocationsStatus('ready'); + } + } catch (err) { + // doormileAxios's response interceptor rejects with error.response.data + // (not the raw axios error) — so the message is err.message here, not + // err.response.data.message (that was reading through a layer that no + // longer exists at this point and always fell back to the generic text). + setTenantlocations([]); + setLocationsStatus('error'); + opentoast(err?.message || 'Could not load business locations', 'error', 4000); + } + }; + useEffect(() => { + gettenantlocations(); + }, [tid]); + + // ============================================= || fetchStaffTenantList (Doormile staff picker) || ============================================= + // Only staff logins (loggedInTenantId blank/0) need this — a client login's + // tenant is already fixed by their token and this never fetches for them. + useEffect(() => { + if (!isStaffLogin) return; + setStaffTenantListLoading(true); + getAdminTenants() + .then((tenants) => setStaffTenantList((tenants || []).filter((t) => t.status === 'Active'))) + .catch((err) => { + opentoast(err?.message || 'Could not load tenant list', 'error', 4000); + }) + .finally(() => setStaffTenantListLoading(false)); + }, []); + + return ( + <> + {loading && } + + + + + + + + + + + + Create Order + + + + + Fill pickup, drop, schedule & pricing details + + + + + + {locationsStatus === 'loading' ? ( + + Loading business locations… + + ) : locationsStatus === 'no-tenant' && isStaffLogin ? ( + // Staff login (express-console-api.md: token tenantid 0/null + // = "Doormile's own staff... sees everything, all tenants") + // — pick which tenant to create this order for. Everything + // downstream (pricing, saved locations, the booking's own + // tenantid) is keyed off `tid`, which equals selectedTenantId. + option?.tenantname || ''} + isOptionEqualToValue={(option, value) => option?.tenantid === value?.tenantid} + PaperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 300 } }} + size="small" + onChange={(event, value) => setSelectedTenantId(value ? String(value.tenantid) : '')} + renderInput={(params) => ( + + + + + + ) + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: DT.radiusPill + 'px', + bgcolor: '#fff', + fontWeight: 600, + '& fieldset': { borderColor: edge(BRAND), borderWidth: 1.5 }, + '&:hover fieldset': { borderColor: BRAND }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }, + '&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 2 } + } + }} + /> + )} + /> + ) : locationsStatus === 'no-tenant' ? ( + + This login has no tenant assigned — sign in as a tenant user + + ) : locationsStatus === 'error' ? ( + + Could not load business locations — tap to retry + + ) : locationsStatus === 'empty' ? ( + + No saved locations — enter pickup details manually below + + ) : tenantLocations.length === 1 ? ( + + {locationDisplayName(tenantLocations[0])} + + ) : ( + locationDisplayName(option)} + isOptionEqualToValue={(option, value) => option?.tenantlocationid === value?.tenantlocationid} + renderOption={(props, option) => ( + + + + + {locationDisplayName(option)} + + {option.isprimary && ( + + PRIMARY + + )} + + + {option.address || option.city || ''} + + + + )} + PaperComponent={SoftPaper} + sx={{ width: { xs: '100%', sm: 320 } }} + size="small" + renderInput={(params) => ( + + + + + + ) + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: DT.radiusPill + 'px', + bgcolor: '#fff', + fontWeight: 600, + '& fieldset': { borderColor: edge(BRAND), borderWidth: 1.5 }, + '&:hover fieldset': { borderColor: BRAND }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }, + '&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 2 } + } + }} + /> + )} + onChange={(event, value, reason) => { + if (value) { + setTenanatLocoId(value.tenantlocationid); + setIsLocation(true); + // Also fill pickCust's raw address fields from the + // picked location — POST /admin/expressbooking's + // pickuplocationid currently 500s server-side (live- + // confirmed), so createsubmitobj2() always sends raw + // pickup* fields instead; this keeps that path working + // when a saved location is chosen here. + setPickCust((prev) => ({ + ...prev, + address: value.address || '', + suburb: value.locationname || value.city || '', + city: value.city || '', + postcode: value.pincode || '', + landmark: value.address || '', + latitude: value.latitude, + longitude: value.longitude + })); + } + if (reason === 'clear') { + setIsLocation(false); + } + }} + /> + )} + + + + + + + + + + {/* ================================================= || Pickup || ================================================= */} + + + } + title="Pickup Details" + subtitle="Where the order is picked up" + action={ + + } + /> + + + + + {/* ====================================== ||Contact Name (pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="Contact Name" + value={pickCust.firstname} + onChange={(e) => { + setPickCust({ ...pickCust, firstname: e.target.value }); + }} + /> + + {/* ====================================== ||Contact Number(pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="Contact Number" + value={pickCust.contactno} + onChange={(e) => { + if (e.target.value.length <= 10) { + setPickCust({ ...pickCust, contactno: e.target.value }); + } + if (pickNum == e.target.value) { + setShowCheck1(0); + } else { + setShowCheck1(1); + } + if (e.target.value.length < 10) { + setNumErr1(true); + } else { + setNumErr1(false); + } + }} + /> + + {/* ====================================== || Address (pick) || ====================================== */} + + + {addId1 == 0 ? ( +
    + +
    + ) : ( + { + setAddId1(0); + setPickCust({ + ...pickCust, + // firstname: '', + // contactno: '', + doorno: '', + suburb: '', + city: '', + postcode: '', + landmark: '' + }); + setShowDistance(false); + setStartPoint({ latitude: 0, longitude: 0 }); + }} + > + + + ) + }} + variant="outlined" + placeholder="Select" + value={pickCust.address} + onChange={(e) => { + setPickCust({ ...pickCust, address: e.target.value }); + if (e.target.value == '') { + setAddId1(0); + setShowDistance(false); + setStartPoint({ latitude: 0, longitude: 0 }); + } + }} + /> + )} +
    +
    + + {/* ====================================== ||Door No (pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="Door No / Street" + value={pickCust.doorno} + onChange={(e) => { + setPickCust({ ...pickCust, doorno: e.target.value }); + }} + /> + + {/* ====================================== || Suburb (pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="Location" + value={pickCust.suburb} + onChange={(e) => { + setPickCust({ ...pickCust, suburb: e.target.value }); + }} + /> + + {/* ====================================== || City (pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="City" + value={pickCust.city} + onChange={(e) => { + setPickCust({ ...pickCust, city: e.target.value }); + }} + /> + + {/* ====================================== || postcode (pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="Postcode" + value={pickCust.postcode} + onChange={(e) => { + setPickCust({ ...pickCust, postcode: e.target.value }); + }} + /> + + {/* ====================================== || Landmark (pick) || ====================================== */} + + + + + ) + }} + variant="outlined" + label="Landmark" + value={pickCust.landmark} + onChange={(e) => { + setPickCust({ ...pickCust, landmark: e.target.value }); + }} + /> + + {/* ====================================== ||Checkbox save for later (pick) || ====================================== */} + {showCheck1 == 1 && ( + + + { + setIsNumChange1(e.target.checked ? 1 : 0); + }} + /> + } + label="Save For Later" + /> + + + )} +
    +
    +
    +
    +
    +
    + {/* ================================================= || Drop || ================================================= */} + + + } + title="Drop Details" + subtitle="Where the order is delivered" + action={ + + } + /> + + + + + + Drop Details + + + {/* Customer */} + {/* { + if (val) { + setDropswitch(true); + setClientinfo({}); + } else { + setDropswitch(false); + setClientinfo({}); + } + }} + size="small" + /> + Business */} + + + + {/* new2 */} + {/* `${option.firstname} (${option.contactno})`} + sx={{ mt: 0, mb: 1 }} + renderInput={(params) => ( + { + setSearchCustList(e.target.value); + }} + InputProps={{ + ...params.InputProps, + inputProps: { + ...params.inputProps, + maxLength: 10 + } + }} + /> + )} + onChange={(e, val, reason) => { + if (val) { + if (pickCust.customerid === val.customerid) { + opentoast('Select Another Customer', 'error'); + setSearchCustList(''); + } else { + setDropCust(val); + setEndPoint({ latitude: val.latitude, longitude: val.longitude }); + setAddId2(1); + } + } + if (reason == 'clear') { + setSearchCustList(''); + } + }} + noOptionsText={ + /^[0-9]{10}$/.test(searchCustList) ? ( + + { + setDropCust({ ...dropCust, contactno: searchCustList }); + handleOkClick2(); + setSearchCustList(''); + }} + /> + + ) : null + } + /> */} + + + + + {/* ====================================== ||Contact Name (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.firstname} + onChange={(e) => { + setDropCust({ ...dropCust, firstname: e.target.value }); + }} + /> + + {/* ====================================== ||Contact Number (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.contactno} + onChange={(e) => { + if (e.target.value.length <= 10) { + setDropCust({ ...dropCust, contactno: e.target.value }); + } + if (dropNum == e.target.value) { + setShowCheck2(0); + } else { + setShowCheck2(1); + } + if (e.target.value.length < 10) { + setNumErr2(true); + } else { + setNumErr2(false); + } + }} + /> + + + + {addId2 == 0 ? ( +
    + +
    + ) : ( + { + setAddId2(0); + setDropCust({ + ...dropCust, + firstname: '', + contactno: '', + doorno: '', + suburb: '', + city: '', + postcode: '', + landmark: '' + }); + setShowDistance(false); + setEndPoint({ latitude: 0, longitude: 0 }); + }} + > + + + ) + }} + variant="outlined" + placeholder="Select" + value={dropCust.address} + onChange={(e) => { + setPickCust({ ...dropCust, address: e.target.value }); + if (e.target.value == '') { + setAddId2(0); + setShowDistance(false); + setEndPoint({ latitude: 0, longitude: 0 }); + } + }} + /> + )} +
    +
    + + {/* ====================================== ||Door No (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.doorno} + onChange={(e) => { + setDropCust({ ...dropCust, doorno: e.target.value }); + }} + /> + + {/* ====================================== ||Suburb (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.suburb} + onChange={(e) => { + setDropCust({ ...dropCust, suburb: e.target.value }); + }} + /> + + {/* ====================================== ||City (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.city} + onChange={(e) => { + setDropCust({ ...dropCust, city: e.target.value }); + }} + /> + + + {/* ====================================== ||Postcode (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.postcode} + onChange={(e) => { + setDropCust({ ...dropCust, postcode: e.target.value }); + }} + /> + + {/* ====================================== ||Landmark (drop) || ====================================== */} + + + + + ) + }} + value={dropCust.landmark} + onChange={(e) => { + setDropCust({ ...dropCust, landmark: e.target.value }); + }} + /> + + {/* ====================================== ||Checkbox save for later (drop) || ====================================== */} + {showCheck2 == 1 && ( + + + { + setIsNumChange2(e.target.checked ? 1 : 0); + }} + /> + } + label="Save For Later" + /> + + + )} +
    +
    + {/* + + */} +
    +
    +
    +
    + {/* ================================================= || Time || ================================================= */} + + + } title="Schedule" subtitle="Pickup date & time slot" /> + + + + + Date + + { + if (!v) return; + let dateres11 = dayjs().diff(dayjs(v), 'd'); + setSelectedtime(''); + if (dateres11 <= 0) { + setStartdate(dayjs(v)); + setEnddate(dayjs(v)); + + let arr = []; + timeslotarr.map((val) => { + if ( + dayjs().diff(dayjs(`${dayjs(v).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0 + ) { + arr.push(val); + } + }); + if (arr[0]) { + setOrderarr([ + { + sno: 1, + address: '', + customerid: '', + deliverytime: dayjs(arr[0]) || '', + deliverylocationid: '', + clientname: '', + contactno: '', + latitude: '', + longitude: '' + } + ]); + } else { + setOrderarr([]); + } + } else { + setAlertmessage('choose Upcoming Date'); + opentoast('choose Upcoming Date', 'warning'); + setStartdate(NaN); + } + }} + /> + + Time + + + + {timeslotarr.map((val, index) => { + if ( + dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= + 0 + ) { + const active = dayjs(selectedtime).format('HH:mm') == dayjs(val).format('HH:mm'); + return ( + { + if (distance > appLocaRadius) { + setOpen4(true); + } else if (showDistance) { + setSelectedtime(val); + } else { + opentoast('Select Pickup and Drop', 'error'); + } + }} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + px: 1.25, + py: 0.5, + cursor: 'pointer', + borderRadius: 999, + border: `1.5px solid ${active ? '#f59e0b' : edge('#f59e0b')}`, + bgcolor: active ? '#f59e0b' : tint('#f59e0b'), + color: active ? '#fff' : '#f59e0b', + fontSize: 12, + fontWeight: 800, + transition: 'all 0.15s', + boxShadow: active ? `0 6px 18px ${ring('#f59e0b')}` : 'none', + '&:hover': { + borderColor: '#f59e0b', + boxShadow: active ? `0 6px 18px ${ring('#f59e0b')}` : `0 0 0 3px ${ring('#f59e0b')}` + } + }} + > + {dayjs(val).format('hh:mm A')} + + ); + } + })} + + + + + + + + + + } + title="Distance & Pricing" + subtitle="Auto-calculated from pickup → drop" + /> + + {showDistance && ( + + + + + + + + + )} + + Category + } + onChange={(event, value) => setCategory(value || null)} + onInputChange={(event, value, reason) => { + if (reason === 'input') setCategory(value || null); + }} + /> + + Weight + + { + handleChipClick('1-10kgs'); + setWeight('1-10kgs'); + }} + /> + { + handleChipClick('11-20kgs'); + setWeight('11-20kgs'); + }} + /> + { + handleChipClick('21-30kgs'); + setWeight('21-30kgs'); + }} + /> + + + SMS Delivery + { + setIsSms(e.target.checked ? 1 : 0); + }} + /> + + + + +
    +
    +
    + + {/* ============================== || Cash Collect & Quantity || ============================== */} + + } + title="Collection & Quantity" + subtitle="Cash to collect on delivery, total items" + /> + + + + setCollectionamt(e.target.value)} + inputProps={{ min: 0 }} + /> + + + setQuantity(e.target.value)} + inputProps={{ min: 1 }} + /> + + + + + + {/* ================================================= || Notes || ================================================= */} + + } + title="Notes" + subtitle="Add anything the rider should know" + /> + + setOtherinstructions(e.target.value)} + /> + + + {!showDistance && ( + + Set pickup & drop to enable + + )} + + + + +
    +
    + + {/* + + + Select Time + + + + + + {timeslotarr.map((val) => { + if ( + dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0 + // && currentsno + ) { + return ( + <> + + { + setSelectedtime(dayjs(val).format('hh:mm A')); + }} + /> + + + ); + } + })} + + + + + + + + */} + + {/* ============================================= || saved address Dialog || ============================================= */} + setIsCustomerOpen(false)} + fullWidth + maxWidth="sm" + PaperProps={{ sx: { borderRadius: 3 } }} + > + + + + + + + + Saved Contacts + + + Pick a saved contact's name & phone for {pickordrop === 1 ? 'Pickup' : 'Drop'} — address isn't stored, search it below + + + + + + setSearchCustList(e.target.value)} + sx={{ + flex: 1, + '& .MuiOutlinedInput-input': { p: '6px 4px', fontSize: 13, fontWeight: 600 }, + '& fieldset': { border: 'none' } + }} + endAdornment={ + searchCustList && ( + setSearchCustList('')} sx={{ p: 0.25, color: BRAND }}> + + + ) + } + autoComplete="off" + /> + + + + {customerlist.length === 0 ? ( + + + + + + No saved contacts + + + {searchCustList ? 'Try a different keyword.' : 'Place an order with a new contact — it will show up here next time.'} + + + ) : ( + + {customerlist.map((address, index) => { + // GET /admin/customers (B2C directory) — { appcustomerid, + // createdat, email, name, phone, totalbookings }. No + // address/coordinates at all (that was a jupiter-only field + // on the old customer record). Picking a saved contact here + // only pre-fills the name & phone; the operator still has + // to search the address below like a brand-new contact. + const contactPhone = address.phone; + const isUsed = !!contactPhone && pickCust.phone === contactPhone; + return ( + { + if (isUsed) return; + setIsCustomerOpen(false); + const contact = { firstname: address.name, contactno: contactPhone, phone: contactPhone }; + if (pickordrop === 1) { + setAddId1(1); + setPickCust((prev) => ({ ...prev, ...contact })); + setPickNum(contactPhone); + } else { + setAddId2(1); + setDropCust((prev) => ({ ...prev, ...contact })); + setdropNum(contactPhone); + } + }} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 1.25, + p: 1.5, + borderRadius: 2, + border: '1px solid', + borderColor: isUsed ? edge('#94a3b8') : DT.borderSubtle, + bgcolor: isUsed ? DT.surfaceAlt : '#fff', + cursor: isUsed ? 'not-allowed' : 'pointer', + opacity: isUsed ? 0.6 : 1, + transition: 'all 0.15s', + '&:hover': isUsed + ? {} + : { + borderColor: edge(BRAND), + bgcolor: tint(BRAND), + boxShadow: DT.shadowSoft + } + }} + > + + {String(address.name || '?') + .charAt(0) + .toUpperCase()} + + + + + {address.name || 'Unnamed'} + + + {contactPhone || '—'} + + {isUsed && ( + + Already used + + )} + + + {address.email || 'No address on file — search it below'} + + + + ); + })} + + )} + + + + + + + setOpen4(false)} PaperProps={{ sx: { borderRadius: 3 } }}> + + + + + + + + Out of Service Area + + + This drop point is outside the supported radius + + + + + + + Service is not available at this location. Try a different drop point within the coverage zone. + + + + + + +
    + + ); +}; + +export default Createorder1; diff --git a/src/pages/nearle/orders/multipleOrders.js b/src/pages/nearle/orders/multipleOrders.js index 45293a7..50ce0f6 100644 --- a/src/pages/nearle/orders/multipleOrders.js +++ b/src/pages/nearle/orders/multipleOrders.js @@ -1,1835 +1,1815 @@ -/* eslint-disable no-unused-vars */ -import React, { useEffect, useMemo, useState, useRef } from 'react'; -import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi'; -import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api'; -import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; -import Papa from 'papaparse'; -import * as XLSX from 'xlsx'; -import dayjs from 'dayjs'; -import { useNavigate } from 'react-router'; -import { useTheme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import { enqueueSnackbar } from 'notistack'; -import { calculateDrivingDistance, calculateTotalCharge } from '../../../utils/distance'; - -import { - FormControl, - InputAdornment, - Grid, - Typography, - Stack, - Box, - Card, - Button, - TextField, - Autocomplete, - Divider, - Dialog, - DialogTitle, - DialogContent, - Checkbox, - DialogActions, - CircularProgress, - IconButton, - OutlinedInput, - FormGroup, - FormControlLabel, - Table, - TableContainer, - TableCell, - TableBody, - TableRow, - Paper, - TableHead, - Backdrop, - Chip, - Tooltip -} from '@mui/material'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import ClearIcon from '@mui/icons-material/Clear'; -import MyLocationIcon from '@mui/icons-material/MyLocation'; -import { - SearchOutlined, - CloseOutlined, - ExclamationCircleOutlined, - FileAddOutlined, - CalendarOutlined, - ClockCircleOutlined, - FileTextOutlined, - InboxOutlined -} from '@ant-design/icons'; -import { Empty } from 'antd'; -import { FaUser, FaTruck, FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt } from 'react-icons/fa'; -import { FaLocationDot } from 'react-icons/fa6'; -import { MdOutlineCloudUpload } from 'react-icons/md'; - -import Loader from 'components/Loader'; -import CircularLoader from 'components/CircularLoader'; -import './OrdersRedesign.css'; - -var utc = require('dayjs/plugin/utc'); -dayjs.extend(utc); - -const cellHeaderSx = { fontSize: 11.5, fontWeight: 700, color: '#475569', py: 0.75, px: 1 }; -const cellBodySx = { fontSize: 12, py: 0.6, px: 1 }; - -// GET /admin/tenants/:id/locations carries no `slots` field (no per-location -// pickup-hours endpoint in the new API) — the pickup-slot Autocomplete used -// to read tenantLocation.slots, which was always undefined, leaving the -// dropdown permanently empty and the page unsubmittable. Falls back to a -// fixed 09:00-21:00 / 30-min window, same rationale as createorder1.js's -// fetchTiming(). -const buildDefaultPickupSlots = () => { - const arr = []; - for (let t = dayjs().hour(9).minute(0).second(0); t.hour() < 21 || (t.hour() === 21 && t.minute() === 0); t = t.add(30, 'm')) { - arr.push({ name: t.format('hh:mm A'), time: t.format('HH:mm') }); - } - return arr; -}; - -// Some saved locations come back with a blank locationname (live-confirmed, -// e.g. tenant 1's only location) — fall back to the address rather than -// showing an empty field/option. -const locationDisplayName = (loc) => (loc?.locationname && loc.locationname.trim()) || loc?.address || 'Unnamed location'; - -// Tenants with many locations in the same city (DailyGrubs has 23, all in -// Coimbatore) made a "(city)" suffix useless for telling them apart — sort -// primary-first then alphabetically; the full address is shown per-option -// instead (see renderOption on the Business Location Autocomplete below). -const sortLocations = (locations) => - [...(locations || [])].sort((a, b) => { - if (!!a.isprimary !== !!b.isprimary) return a.isprimary ? -1 : 1; - return locationDisplayName(a).localeCompare(locationDisplayName(b)); - }); - -const MultipleOrders = () => { - const navigate = useNavigate(); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - const locationRef = useRef(null); - const userid = localStorage.getItem('userid'); - // Per express-console-api.md's "Tenant scoping" section: token tenantid - // 0/null means Doormile's own staff, who can act on any tenant — NOT an - // error state. A client (tenant) login carries a real tenantid and never - // needs the picker below. `tenantid` (used throughout this file already) - // now resolves to whichever the operator is currently working as. - const loggedInTenantId = localStorage.getItem('tenantid'); - const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0'; - - // ============================== state ============================== - const [locations, setLocations] = useState([]); - const [tenantLocations, setTenantlocations] = useState([]); - const [loading, setLoading] = useState(false); - const [btnLoading, setBtnLoading] = useState(false); - - const [appId, setAppId] = useState(0); - const [locationid, setLocationid] = useState(0); - const [locationValue, setLocationValue] = useState(null); - const [locationsStatus, setLocationsStatus] = useState('loading'); - const [selectedTenantId, setSelectedTenantId] = useState(isStaffLogin ? '' : loggedInTenantId); - const [staffTenantList, setStaffTenantList] = useState([]); - const [staffTenantListLoading, setStaffTenantListLoading] = useState(false); - const tenantid = selectedTenantId; - - const [basePrice, setBasePrice] = useState(0); - const [pricePerKm, setPricePerKm] = useState(0); - const [minKm, setMinKm] = useState(0); - - const [pickCust, setPickCust] = useState(null); - const [dropCust, setDropCust] = useState([]); - const [customerlist, setCustomerlist] = useState([]); - const [isCustomerOpen, setIsCustomerOpen] = useState(false); - const [searchCustList, setSearchCustList] = useState(''); - - const [startdate, setStartdate] = useState(dayjs().format('MM-DD-YYYY')); - const [selectedtime, setSelectedtime] = useState(''); - const [pickupSlotsList, setPickupSlotsList] = useState(buildDefaultPickupSlots); - const [pickupSlot, setPickupSlot] = useState(null); - - const [otherinstructions, setOtherinstructions] = useState(''); - const [admintoken, setAdmintoken] = useState(); - - const [totaldist, settotaldist] = useState(0); - const [totalAmt, settotalAmt] = useState(0); - const [totalQty, settotalQty] = useState(0); - const [totalCash, settotalCash] = useState(0); - - const [uploadType, setUploadType] = useState(null); - const [users, setUsers] = useState([]); - const [fileName, setFileName] = useState(''); - const [slotDropdownOpen, setSlotDropdownOpen] = useState(false); - - const toastCacheRef = useRef({}); - - // ============================== toast ============================== - const opentoast = (message, variant, time) => { - enqueueSnackbar(message, { - variant, - anchorOrigin: { vertical: 'top', horizontal: 'right' }, - autoHideDuration: time || 1500 - }); - }; - - const OpenToast = (message, type = 'info', timeout = 3000) => { - const key = `${type}-${message}`; - if (toastCacheRef.current[key]) return; - opentoast(message, type, timeout); - toastCacheRef.current[key] = true; - setTimeout(() => delete toastCacheRef.current[key], 3000); - }; - - // ============================== reset chains ============================== - useEffect(() => { - setLocationid(0); - setLocationValue(null); - setDropCust([]); - setUsers([]); - setUploadType(null); - setFileName(''); - setPickupSlotsList(buildDefaultPickupSlots()); - setPickupSlot(null); - setSelectedtime(''); - }, [appId]); - - // ============================== fetchAppLocations ============================== - const fetchAppLocations = async () => { - setLoading(true); - try { - const locations = await fetchZoneLocations(); - setLocations(locations || []); - } catch (err) { - OpenToast(err.message, 'error', 5000); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchAppLocations(); - }, []); - - // ============================== fetchTenantPricing ============================== - const fetchTenantPricing = async (id) => { - try { - const pricing = (await getAdminPricing()) || []; - // id comes from localStorage (always a string) but pricing.tenantid is - // numeric — a strict === here never matched, so basePrice/pricePerKm/ - // minKm stayed at their 0 defaults and every order priced at ₹0. - const match = pricing.find((p) => String(p.tenantid) === String(id)); - if (match) { - setBasePrice(match.baseprice || 0); - setPricePerKm(match.priceperkm || 0); - setMinKm(match.basedistance || 0); - } - } catch (err) { - // swallow - } - }; - - useEffect(() => { - if (tenantid) fetchTenantPricing(tenantid); - }, [tenantid]); - - // ============================== gettenantlocations ============================== - // Drives what shows in place of the Business Location picker. Previously - // this function was only ever called when `tenantid` was truthy, so an - // operator on a no-tenant login (the shared admin/developer account has - // tenantid: null) got a permanently empty picker with zero feedback — not - // even a toast, since the function body that would show one never ran. - const gettenantlocations = async (id) => { - if (!id) { - // Staff login with no tenant picked yet — the picker UI covers this. - setLocationsStatus('no-tenant'); - return; - } - setLocationsStatus('loading'); - try { - const details = (await getTenantLocations(id)) || []; - setTenantlocations(details); - if (details.length === 1) { - setLocationsStatus('ready'); - setPickCust(details[0]); - setLocationid(details[0].tenantlocationid); - setLocationValue(details[0]); - } else if (details.length === 0) { - setLocationsStatus('empty'); - opentoast('No saved business locations for this tenant', 'info', 3000); - } else { - setLocationsStatus('ready'); - } - } catch (err) { - // doormileAxios's response interceptor rejects with error.response.data - // (not the raw axios error), so the message lives at err.message here. - setTenantlocations([]); - setLocationsStatus('error'); - opentoast(err?.message || 'Could not load business locations', 'error', 4000); - } - }; - - useEffect(() => { - gettenantlocations(tenantid); - }, [tenantid]); - - // ============================== fetchStaffTenantList (Doormile staff picker) ============================== - // Only staff logins (loggedInTenantId blank/0) need this — a client login's - // tenant is already fixed by their token and this never fetches for them. - useEffect(() => { - if (!isStaffLogin) return; - setStaffTenantListLoading(true); - getAdminTenants() - .then((tenants) => setStaffTenantList((tenants || []).filter((t) => t.status === 'Active'))) - .catch((err) => { - opentoast(err?.message || 'Could not load tenant list', 'error', 4000); - }) - .finally(() => setStaffTenantListLoading(false)); - }, []); - - // ============================== clientdetails ============================== - // GET /admin/customers (B2C directory) — not /admin/tenantcustomers, which - // is an empty, manually-added contact book with no address/coordinates. - // This resource auto-grows from real orders (see the identical fix on - // createorder1.js's Saved Contacts) and has a confirmed live shape of - // { appcustomerid, createdat, email, name, phone, totalbookings } — no - // search/query param exists, so it's fetched once and filtered in-memory. - const clientdetails = async () => { - try { - const customers = (await getAdminCustomers()) || []; - setCustomerlist(customers); - } catch (err) { - opentoast('server error', 'warning'); - } - }; - - useEffect(() => { - if (!tenantid || !isCustomerOpen) return; - clientdetails(); - }, [tenantid, isCustomerOpen]); - - const filteredCustomerList = useMemo(() => { - const q = searchCustList.trim().toLowerCase(); - if (!q) return customerlist; - return customerlist.filter((c) => c.name?.toLowerCase().includes(q) || c.phone?.includes(searchCustList)); - }, [customerlist, searchCustList]); - - // ============================== totals ============================== - useEffect(() => { - let a1 = 0, - a2 = 0, - a3 = 0, - a4 = 0; - dropCust.forEach((c) => { - a1 += Number(c.distance) || 0; - a2 += Number(c.totalcharge) || 0; - a3 += Number(c.quantity) || 0; - a4 += Number(c.collectionamt) || 0; - }); - settotaldist(a1); - settotalAmt(a2); - settotalQty(a3); - settotalCash(a4); - }, [dropCust]); - - // ============================== distance (Google) ============================== - // ============================== distance (OSRM/Haversine) ============================== - const calculateDistance = async (customer) => { - try { - if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.'); - if (!pickCust || typeof pickCust !== 'object') throw new Error('Origin (pickCust) data missing or invalid.'); - - const roundedDistance = await calculateDrivingDistance(pickCust, customer); - const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm); - return { roundedDistance, totalcharge }; - } catch (error) { - if (error.message.includes('Origin') || error.message.includes('customer') || error.message.includes('coordinates')) { - OpenToast('Missing or invalid input data for distance calculation.', 'warning', 3000); - } else { - OpenToast('Unexpected error during distance calculation.', 'error', 3000); - } - throw error; - } - }; - - // ============================== checkbox handlers ============================== - // GET /admin/customers has no address/coordinates at all, so a saved - // customer can no longer be distance-calculated at selection time (that's - // what used to throw "Invalid coordinates" and silently drop the row every - // time). Add the row immediately instead — its Address cell renders an - // editable AddressAutocomplete (see the Drop List table) until the - // operator picks a real address, which is what fills in lat/lng and lets - // distance/charge be computed per-row. - const handleCheckboxChange = (event, customer) => { - const rowkey = `sel-${customer.appcustomerid}`; - if (event.target.checked) { - setDropCust((prev) => { - if (prev.some((c) => c._rowkey === rowkey)) return prev; - return [ - ...prev, - { - _rowkey: rowkey, - appcustomerid: customer.appcustomerid, - firstname: customer.name || '', - contactno: customer.phone || '', - address: '', - city: '', - postcode: '', - latitude: null, - longitude: null, - distance: 0, - totalcharge: 0, - quantity: 0, - collectionamt: 0 - } - ]; - }); - } else { - setDropCust((prev) => prev.filter((c) => c._rowkey !== rowkey)); - } - }; - - // Still used by the CSV "Continue" promotion and the Drop List remove - // button. Safe to keep calling calculateDistance here — CSV rows always - // carry real latitude/longitude from the sheet, unlike Selection rows. - const handleCheckboxChange1 = async (customer) => { - const rowkey = customer._rowkey; - let wasSelected = false; - setDropCust((prev) => { - wasSelected = prev.some((c) => c._rowkey === rowkey); - if (wasSelected) return prev.filter((c) => c._rowkey !== rowkey); - return prev; - }); - if (wasSelected) return; - setLoading(true); - try { - const { roundedDistance, totalcharge } = await calculateDistance(customer); - setDropCust((prev) => { - if (prev.some((c) => c._rowkey === rowkey)) return prev; - return [...prev, { ...customer, distance: roundedDistance, totalcharge }]; - }); - } catch (err) { - console.error('Failed to calculate distance:', err); - OpenToast(`Could not calculate distance for ${customer.firstname || 'this row'}`, 'warning', 3000); - } finally { - setLoading(false); - } - }; - - // Parses the AddressAutocomplete `place` result (see createorder1.js's - // handleDropPlaceSelected for the same pattern) for one Drop List row, - // updates that row's address/coordinates, then recomputes its distance and - // charge now that coordinates finally exist. - const handleRowPlaceSelected = async (rowkey, place) => { - const parsed = { suburb: '', city: '', postcode: '' }; - place.address_components.forEach((component) => { - component.types.forEach((type) => { - switch (type) { - case 'sublocality_level_1': - case 'sublocality': - parsed.suburb = component.long_name; - break; - case 'locality': - parsed.city = component.long_name; - break; - case 'postal_code': - parsed.postcode = component.long_name; - break; - } - }); - }); - const latitude = place.geometry.location.lat(); - const longitude = place.geometry.location.lng(); - const address = place.formatted_address || ''; - - setDropCust((prev) => - prev.map((c) => (c._rowkey === rowkey ? { ...c, address, city: parsed.city, postcode: parsed.postcode, latitude, longitude } : c)) - ); - - try { - const { roundedDistance, totalcharge } = await calculateDistance({ latitude, longitude }); - setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, distance: roundedDistance, totalcharge } : c))); - } catch (err) { - console.error('Failed to calculate distance:', err); - } - }; - - // No zone-admin-token or bulk-notify endpoint in the new API. - const fetchAppAdminTokens = async () => setAdmintoken([]); - - useEffect(() => { - if (appId) fetchAppAdminTokens(); - }, [appId]); - - // ============================== sendnotifications ============================== - const sendnotifications = async () => {}; - - // ============================== CSV / XLSX upload ============================== - const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name); - const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, ''); - - const headerMap = { - 'pickupdate(yyyy-mmm-dd)': 'date', - 'sendername*': 'locationname', - 'senderphone*': 'locationcontact', - 'senderaddress*': 'locationaddress', - 'receivername*': 'firstname', - receiverphone: 'contactno', - 'receiveralternatephone*': 'altcontactno', - receiverfulladdress: 'address', - // Best-effort — no live sample sheet to confirm these are the tenant's - // actual column headers. Without them deliverycity/deliverypincode in - // the submit payload were always sent blank; if these names are wrong, - // behavior is unchanged from before (still blank), not worse. - receivercity: 'city', - receiverpincode: 'postcode', - receiverlatitude: 'latitude', - receiverlongitude: 'longitude', - 'itemdescription*': 'description', - Quantity: 'quantity', - ' Collect Cash': 'collectionamt' - }; - - const handleFileDirectUpload = (event) => { - try { - const file = event.target.files?.[0]; - if (!file) { - opentoast('No file selected.', 'warning'); - return; - } - const fileNameLower = file.name.toLowerCase(); - const isCSV = fileNameLower.endsWith('.csv'); - const isExcel = fileNameLower.endsWith('.xls') || fileNameLower.endsWith('.xlsx'); - if (!isCSV && !isExcel) { - opentoast('Invalid file type. Please upload a CSV or Excel file.', 'warning'); - return; - } - - const processData = (data, headers) => { - const normalizedMap = {}; - for (const key in headerMap) normalizedMap[normalizeHeader(key)] = headerMap[key]; - const mappedData = data.map((row, idx) => { - const newRow = { _rowkey: `csv-${idx}` }; - for (const key in row) { - const cleanKey = normalizeHeader(key); - const newKey = normalizedMap[cleanKey] || cleanKey; - let value = row[key]; - if (newKey === 'firstname') value = cleanReceiverName(value); - newRow[newKey] = value; - } - return newRow; - }); - const requiredCols = Object.keys(headerMap).filter((k) => k.trim().endsWith('*')); - const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol))); - if (missingRequired.length > 0) { - opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000); - } - setUsers(mappedData); - opentoast('File uploaded successfully', 'success', 2000); - opentoast('Press Continue to add as drop customers', 'info', 2500); - }; - - if (isCSV) { - Papa.parse(file, { - header: true, - dynamicTyping: true, - skipEmptyLines: true, - complete: (results) => { - if (!results.data?.length) { - opentoast('CSV file is empty or has no valid rows.', 'warning'); - setUsers([]); - return; - } - processData(results.data, results.meta.fields.map(normalizeHeader)); - }, - error: (error) => opentoast(`CSV parsing failed: ${error.message}`, 'warning') - }); - } - if (isExcel) { - const reader = new FileReader(); - reader.onload = (e) => { - try { - const workbook = XLSX.read(e.target.result, { type: 'binary' }); - const jsonData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { defval: '' }); - if (!jsonData?.length) { - opentoast('Excel file is empty or invalid.', 'warning'); - setUsers([]); - return; - } - processData(jsonData, Object.keys(jsonData[0]).map(normalizeHeader)); - } catch (err) { - opentoast(`Error reading Excel: ${err.message}`, 'warning'); - } - }; - reader.readAsBinaryString(file); - } - } catch (err) { - opentoast(`Unexpected error: ${err.message}`, 'warning'); - } - }; - - const removeFileExtension = (n) => n.replace(/\.[^/.]+$/, ''); - const onFileChange = (event) => { - const file = event.target.files[0]; - if (!file) return; - const cleanedName = removeFileExtension(file.name); - setFileName((prev) => (prev ? `${prev}, ${cleanedName}` : cleanedName)); - handleFileDirectUpload(event); - }; - - // ============================== row editors ============================== - const handleQuantityChange = (rowkey, value) => { - setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, quantity: Number(value) || 0 } : c))); - }; - const handleCollectionAmtChange = (rowkey, value) => { - setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, collectionamt: Number(value) || 0 } : c))); - }; - - // ============================== buildDeliveryTime ============================== - const buildDeliveryTime = () => { - if (pickupSlot) { - const parsed = dayjs(pickupSlot, ['YYYY-MM-DD hh:mm A', 'YYYY-MM-DD HH:mm:ss']); - if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss'); - } - if (startdate && selectedtime) { - const parsed = dayjs(`${dayjs(startdate).format('YYYY-MM-DD')} ${selectedtime}`, ['YYYY-MM-DD hh:mm A', 'YYYY-MM-DD HH:mm:ss']); - if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss'); - } - return dayjs().format('YYYY-MM-DD HH:mm:ss'); - }; - - // ============================== createorders ============================== - const createorders = async () => { - if (!tenantid) { - opentoast('Client not found. Please re-login.', 'warning'); - return; - } - if (!pickCust) { - opentoast('Pickup location required', 'warning'); - return; - } - if (!pickupSlot) { - opentoast('Select a pickup slot', 'warning'); - return; - } - if (!dropCust.length) { - opentoast('Add at least one drop customer', 'warning'); - return; - } - // A Selection-sourced row (picked from Saved Customers) starts with no - // address/coordinates until the operator fills in its Address cell — - // block submit rather than send a booking with lat/lng silently at 0. - const incomplete = dropCust.find((c) => c.latitude == null || c.longitude == null); - if (incomplete) { - opentoast(`Add a delivery address for ${incomplete.firstname || 'a selected customer'} before submitting`, 'warning', 4000); - return; - } - - const deliverytime = buildDeliveryTime(); - // POST /admin/expressbooking/bulk takes { bookings: [...] } where each - // entry is a flat booking object (tenantid, pickup* fields, customer_*, - // delivery*, service_option, finalprice, notes, parcels[]) — not the old - // per-row orders/pickup/drop shape. - // tenantid comes from the logged-in session (see the top-level `tenantid` - // const above), not from pickCust — pickCust is a tenant *location* - // object (GET /admin/tenants/:id/locations) and was never confirmed to - // carry a tenantid back-reference, so `pickCust.tenantid` was silently - // undefined on every submit. - // - // Always send raw pickup* fields, never pickuplocationid — live-confirmed - // pickuplocationid currently 500s on POST /admin/expressbooking (and by - // extension the bulk endpoint, same handler); a saved location's own - // address/pincode/city/lat/lng (already on pickCust) work fine sent raw. - // Note: pickCust.pincode, not .postcode — that was also wrong (the real - // GET /admin/tenants/:id/locations field is `pincode`). - const bookings = dropCust.map((customer) => ({ - tenantid: Number(tenantid), - pickupaddress: pickCust.address || '', - pickuppincode: pickCust.pincode || '', - pickupcity: pickCust.city || '', - pickuplatitude: Number(pickCust.latitude) || 0, - pickuplongitude: Number(pickCust.longitude) || 0, - customer_phone: customer.contactno?.toString() || '', - customer_name: customer.firstname || '', - deliveryaddress: customer.address || '', - deliverypincode: customer.postcode || '', - deliverycity: customer.city || '', - deliverylatitude: Number(customer.latitude) || 0, - deliverylongitude: Number(customer.longitude) || 0, - deliverytime, - service_option: 'Normal', - finalprice: +customer.totalcharge || 0, - notes: otherinstructions || '', - parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: +customer.totalcharge || 0 }] - })); - - setLoading(true); - setBtnLoading(true); - try { - const res = await createExpressBookingBulk(bookings); - if (res.success) { - opentoast('Orders Created Successfully', 'success', 2000); - navigate('/doormile/orders'); - } else { - opentoast(res?.message || 'Order creation failed. Please try again.', 'warning', 3000); - } - } catch (err) { - // doormileAxios's response interceptor rejects with error.response.data - // (not the raw axios error), so the message lives at err.message here — - // err.response was always undefined at this point, so this always fell - // through to the generic "Something went wrong." fallback before. - opentoast(err?.message || 'Something went wrong.', 'error', 3000); - } finally { - setLoading(false); - setBtnLoading(false); - } - }; - - // ============================== derived ============================== - const canSubmit = !!locationid && !!pickupSlot && dropCust.length > 0; - const prereqOk = !!locationid; - const previewMode = dropCust.length > 0 ? 'drops' : users.length > 0 ? 'preview' : 'empty'; - - // ============================== render ============================== - return ( - <> - {loading && } - t.zIndex.drawer + 1 }} open={btnLoading}> - - - - - {/* Title bar */} - - - Create Multiple Orders - - - - - Bulk-create deliveries from CSV/Excel or saved customers. - - - - {/* 50 / 50 workspace */} - - {/* LEFT — input fields */} - - {/* Card: Schedule & Pickup */} - - - - Schedule & Pickup - - - - - {locationsStatus === 'loading' ? ( - - Loading business locations… - - ) : locationsStatus === 'no-tenant' && isStaffLogin ? ( - // Staff login (express-console-api.md: token tenantid - // 0/null = "Doormile's own staff... sees everything, all - // tenants") — pick which tenant this bulk order is for. - option?.tenantname || ''} - isOptionEqualToValue={(option, value) => option?.tenantid === value?.tenantid} - onChange={(event, value) => setSelectedTenantId(value ? String(value.tenantid) : '')} - renderInput={(params) => ( - - )} - /> - ) : locationsStatus === 'no-tenant' ? ( - - This login has no tenant assigned — sign in as a tenant user - - ) : locationsStatus === 'error' ? ( - gettenantlocations(tenantid)} - sx={{ - display: 'flex', - alignItems: 'center', - px: 1.5, - py: 1, - borderRadius: '10px', - bgcolor: '#fef2f2', - border: '1px solid #fecaca', - color: '#b91c1c', - fontWeight: 600, - fontSize: 12.5, - cursor: 'pointer' - }} - > - Could not load business locations — tap to retry - - ) : locationsStatus === 'empty' ? ( - - No saved business locations for this tenant - - ) : tenantLocations?.length === 1 ? ( - - - - ) - }} - sx={{ - '& .MuiOutlinedInput-root': { borderRadius: '10px', height: '36px', alignItems: 'center' }, - '& .MuiOutlinedInput-input': { padding: '0 14px !important' } - }} - /> - ) : ( - (option ? locationDisplayName(option) : '')} - renderOption={(props, option) => ( - - - - - {locationDisplayName(option)} - - {option.isprimary && ( - - PRIMARY - - )} - - - {option.address || option.city || ''} - - - - )} - value={locationValue} - onChange={(event, value, reason) => { - if (reason === 'clear' || !value) { - setLocationid(0); - setLocationValue(null); - setPickCust(null); - } else { - setLocationid(value.tenantlocationid || 0); - setLocationValue(value); - setPickCust(value); - } - }} - renderInput={(params) => ( - - )} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: '10px', - height: '36px', - alignItems: 'center', - padding: '0 9px !important' - }, - '& .MuiAutocomplete-input': { padding: '0 !important' } - }} - /> - )} - - - - { - if (!e || !dayjs(e).isValid()) { - setStartdate(dayjs().format('MM-DD-YYYY')); - return; - } - const diffDays = dayjs().diff(dayjs(dayjs(e).format('YYYY-MM-DD')), 'd'); - if (diffDays <= 0) { - setStartdate(dayjs(e).format('MM-DD-YYYY')); - setSelectedtime(''); - setPickupSlot(null); - } else { - opentoast('Choose an upcoming date', 'warning'); - setStartdate(dayjs().format('MM-DD-YYYY')); - } - }} - disablePast - slotProps={{ - textField: { - size: 'small', - fullWidth: true, - InputLabelProps: { shrink: true }, - InputProps: { - startAdornment: ( - - - - ) - }, - sx: { '& .MuiOutlinedInput-root': { borderRadius: '10px', height: '36px' } } - } - }} - /> - - - - { - if (!locationid) { - OpenToast('Please select a Business Location first.', 'warning', 3000); - return; - } - setSlotDropdownOpen(true); - }} - onClose={() => setSlotDropdownOpen(false)} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: '10px', - height: '36px', - alignItems: 'center', - padding: '0 9px !important' - }, - '& .MuiAutocomplete-input': { padding: '0 !important' } - }} - onChange={(e, newValue, reason) => { - if (reason === 'clear' || !newValue) { - setSelectedtime(null); - setPickupSlot(null); - return; - } - if (!newValue.time) { - OpenToast('This slot has no time configured.', 'warning', 3000); - return; - } - const formattedTime = dayjs(newValue.time, 'HH:mm').format('hh:mm A'); - setSelectedtime(formattedTime); - const finalDateTime = dayjs(`${startdate} ${formattedTime}`, 'MM-DD-YYYY hh:mm A').format('YYYY-MM-DD hh:mm A'); - setPickupSlot(finalDateTime); - }} - getOptionLabel={(option) => (option ? option.name : '')} - renderInput={(params) => ( - - - - ) - }} - /> - )} - /> - - - {pickCust ? ( - - - - - - - {pickCust.locationname || '—'} - - - {pickCust.address || '—'} - - - - ) : ( - Pickup auto-fills once a Business Location is selected. - )} - - - - - {/* Card: Notes */} - - - Order Notes - Applied to every order - - - - - setOtherinstructions(e.target.value)} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: '10px', - height: '36px', - alignItems: 'center', - fontSize: '12px', - background: '#ffffff' - }, - '& .MuiOutlinedInput-input': { padding: '0 14px !important', fontSize: '12px' } - }} - /> - - - - - {/* Card: Summary + Submit */} - - - - Bulk Summary - - - Live totals - - - {(() => { - const metric = ({ icon: Icon, label, value, accent, active }) => ( - - - - - - - {label} - - - {value} - - - - ); - return ( - - - {metric({ - icon: FaRoute, - label: 'Distance', - value: totaldist ? `${totaldist} km` : '—', - accent: '#0ea5e9', - active: !!totaldist - })} - - - {metric({ icon: FaBoxes, label: 'Quantity', value: totalQty || 0, accent: '#10b981', active: !!totalQty })} - - - {metric({ - icon: FaMoneyBillWave, - label: 'Cash Collect', - value: `₹${Number(totalCash).toFixed(2)}`, - accent: '#f59e0b', - active: !!totalCash - })} - - - {metric({ - icon: FaTruck, - label: 'Deliveries', - value: dropCust.length, - accent: '#8b5cf6', - active: dropCust.length > 0 - })} - - - ); - })()} - - {dropCust.length > 0 && ( -
    -
    - -
    Total Charge
    -
    -
    - ₹{Number(totalAmt).toFixed(2)} -
    -
    - )} - - - - -
    -
    - - {/* RIGHT — file / drop preview */} - - - {/* Preview header */} - - - - {previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'} - - - - {previewMode === 'drops' && ( - - )} - {previewMode === 'preview' && ( - } - sx={{ - height: 22, - fontWeight: 700, - fontSize: 11, - bgcolor: 'rgba(245,158,11,0.12)', - color: '#b45309', - border: '1px solid rgba(245,158,11,0.30)', - '& .MuiChip-icon': { color: '#b45309' } - }} - /> - )} - {previewMode !== 'empty' && - (() => { - const handleHeaderPick = (val) => { - if (!prereqOk) { - OpenToast('Please select a Business Location first.', 'warning', 3000); - return; - } - setUploadType(val); - if (val === 0) document.getElementById('upload-file')?.click(); - else if (val === 1) { - setIsCustomerOpen(true); - setSearchCustList(''); - } - }; - return ( - - - - - ); - })()} - - - {fileName && ( - - - - {fileName} - - - )} - - {previewMode === 'preview' && users.length >= 1 && ( - - - - Process & Calculate Distances - - - Click continue to import spreadsheet rows and calculate drop charges. - - - - - )} - - {/* Scrollable preview body */} - - {/* Drops — table */} - {previewMode === 'drops' && ( - - - - - # - Customer - Address - - Qty - - - Cash - - Km - - Charge - - - {' '} - - - - - {dropCust.map((customer, index) => ( - - {index + 1} - - {customer.firstname} - - - {customer.latitude == null || customer.longitude == null ? ( - - setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c))) - } - onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)} - bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined} - /> - ) : ( - - - {customer.address} - - - )} - - - {uploadType === 0 ? ( - customer.quantity - ) : ( - handleQuantityChange(customer._rowkey, e.target.value)} - inputProps={{ min: 0 }} - sx={{ width: 64, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }} - /> - )} - - - {uploadType === 0 ? ( - `₹${Number(customer.collectionamt || 0).toFixed(2)}` - ) : ( - { - const v = Number(e.target.value); - handleCollectionAmtChange(customer._rowkey, v > 0 ? v : 0); - }} - inputProps={{ min: 0 }} - InputProps={{ startAdornment: ₹ }} - sx={{ width: 90, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }} - /> - )} - - {customer.distance || '—'} - - ₹{Number(customer?.totalcharge || 0).toFixed(2)} - - - - handleCheckboxChange1(customer)} sx={{ color: '#ef4444', p: 0.5 }}> - - - - - - ))} - - Total - - - {totalQty} - - - ₹{Number(totalCash).toFixed(2)} - - {totaldist} - - ₹{Number(totalAmt).toFixed(2)} - - - - -
    -
    - )} - - {/* File preview — table */} - {previewMode === 'preview' && ( - - - - - # - Name - Contact - Address - - Qty - - - Cash - - - - - {users.map((u, i) => ( - - {i + 1} - - {u.firstname || '—'} - - {u.contactno || '—'} - - - - {u.address || '—'} - - - - - {u.quantity ?? '—'} - - - {u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'} - - - ))} - -
    -
    - )} - - {/* Empty state */} - {previewMode === 'empty' && - (() => { - const handleEmptyPick = (val) => { - if (!prereqOk) { - OpenToast('Please select a Business Location first.', 'warning', 3000); - return; - } - setUploadType(val); - setDropCust([]); - setUsers([]); - setFileName(''); - if (val === 0) document.getElementById('upload-file')?.click(); - else if (val === 1) { - setIsCustomerOpen(true); - setSearchCustList(''); - } - }; - const tile = ({ value, icon: Icon, title, sub, accent }) => ( - handleEmptyPick(value)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleEmptyPick(value); - } - }} - sx={{ - flex: 1, - minWidth: 0, - display: 'flex', - alignItems: 'center', - textAlign: 'left', - gap: 1.25, - px: 1.5, - py: 1.25, - borderRadius: '10px', - border: '1.5px solid #eef2f6', - bgcolor: '#fff', - opacity: prereqOk ? 1 : 0.6, - cursor: prereqOk ? 'pointer' : 'not-allowed', - transition: 'all 0.18s ease', - '&:hover': prereqOk - ? { borderColor: accent, boxShadow: `0 4px 12px -4px ${accent}40`, transform: 'translateY(-1px)' } - : undefined - }} - > - - - - - {title} - - {sub} - - - - ); - return ( - - - - - Choose a Drop Source to begin - - Select a source below to import or pick your delivery customers. - - - {tile({ - value: 0, - icon: MdOutlineCloudUpload, - title: 'Excel / CSV', - sub: 'Bulk upload a sheet', - accent: '#0ea5e9' - })} - {tile({ value: 1, icon: FaUsers, title: 'Selection', sub: 'Pick saved customers', accent: '#8b5cf6' })} - - - ); - })()} -
    -
    -
    -
    -
    - - - - {/* Saved customers dialog */} - setIsCustomerOpen(false)} - fullWidth - fullScreen={isMobile} - sx={{ '& .MuiDialog-paper': { borderRadius: { xs: 0, sm: '16px' }, overflow: 'hidden' } }} - > - - - - {`Select Drop Customers (${dropCust.length || 0})`} - - - setSearchCustList(e.target.value)} - sx={{ bgcolor: 'white', borderRadius: '10px', '& .MuiOutlinedInput-input': { p: '10px 14px' } }} - startAdornment={ - - - - } - endAdornment={ - setSearchCustList('')}> - - - } - autoComplete="off" - /> - - - - - - {filteredCustomerList?.length === 0 ? ( - - - - ) : ( - - {filteredCustomerList?.map((customer, index) => { - const rowkey = `sel-${customer.appcustomerid}`; - const checked = dropCust.some((c) => c._rowkey === rowkey); - return ( - - handleCheckboxChange(event, customer)} />} - label={ - - - {customer.name} ({customer.phone}) - - - Address not on file — add one after selecting - - - } - /> - - ); - })} - - )} - - - - - - - - ); -}; - -export default MultipleOrders; +/* eslint-disable no-unused-vars */ +import React, { useEffect, useMemo, useState, useRef } from 'react'; +import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi'; +import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api'; +import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; +import Papa from 'papaparse'; +import * as XLSX from 'xlsx'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { enqueueSnackbar } from 'notistack'; +import { calculateDrivingDistance, calculateTotalCharge } from '../../../utils/distance'; + +import { + FormControl, + InputAdornment, + Grid, + Typography, + Stack, + Box, + Card, + Button, + TextField, + Autocomplete, + Divider, + Dialog, + DialogTitle, + DialogContent, + Checkbox, + DialogActions, + CircularProgress, + IconButton, + OutlinedInput, + FormGroup, + FormControlLabel, + Table, + TableContainer, + TableCell, + TableBody, + TableRow, + Paper, + TableHead, + Backdrop, + Chip, + Tooltip +} from '@mui/material'; +import { DateInput } from '@astryxdesign/core/DateInput'; +import ClearIcon from '@mui/icons-material/Clear'; +import MyLocationIcon from '@mui/icons-material/MyLocation'; +import { + SearchOutlined, + CloseOutlined, + ExclamationCircleOutlined, + FileAddOutlined, + ClockCircleOutlined, + FileTextOutlined, + InboxOutlined +} from '@ant-design/icons'; +import { Empty } from 'antd'; +import { FaUser, FaTruck, FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt } from 'react-icons/fa'; +import { FaLocationDot } from 'react-icons/fa6'; +import { MdOutlineCloudUpload } from 'react-icons/md'; + +import Loader from 'components/Loader'; +import CircularLoader from 'components/CircularLoader'; +import './OrdersRedesign.css'; + +var utc = require('dayjs/plugin/utc'); +dayjs.extend(utc); + +const cellHeaderSx = { fontSize: 11.5, fontWeight: 700, color: '#475569', py: 0.75, px: 1 }; +const cellBodySx = { fontSize: 12, py: 0.6, px: 1 }; + +// GET /admin/tenants/:id/locations carries no `slots` field (no per-location +// pickup-hours endpoint in the new API) — the pickup-slot Autocomplete used +// to read tenantLocation.slots, which was always undefined, leaving the +// dropdown permanently empty and the page unsubmittable. Falls back to a +// fixed 09:00-21:00 / 30-min window, same rationale as createorder1.js's +// fetchTiming(). +const buildDefaultPickupSlots = () => { + const arr = []; + for (let t = dayjs().hour(9).minute(0).second(0); t.hour() < 21 || (t.hour() === 21 && t.minute() === 0); t = t.add(30, 'm')) { + arr.push({ name: t.format('hh:mm A'), time: t.format('HH:mm') }); + } + return arr; +}; + +// Some saved locations come back with a blank locationname (live-confirmed, +// e.g. tenant 1's only location) — fall back to the address rather than +// showing an empty field/option. +const locationDisplayName = (loc) => (loc?.locationname && loc.locationname.trim()) || loc?.address || 'Unnamed location'; + +// Tenants with many locations in the same city (DailyGrubs has 23, all in +// Coimbatore) made a "(city)" suffix useless for telling them apart — sort +// primary-first then alphabetically; the full address is shown per-option +// instead (see renderOption on the Business Location Autocomplete below). +const sortLocations = (locations) => + [...(locations || [])].sort((a, b) => { + if (!!a.isprimary !== !!b.isprimary) return a.isprimary ? -1 : 1; + return locationDisplayName(a).localeCompare(locationDisplayName(b)); + }); + +const MultipleOrders = () => { + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const locationRef = useRef(null); + const userid = localStorage.getItem('userid'); + // Per express-console-api.md's "Tenant scoping" section: token tenantid + // 0/null means Doormile's own staff, who can act on any tenant — NOT an + // error state. A client (tenant) login carries a real tenantid and never + // needs the picker below. `tenantid` (used throughout this file already) + // now resolves to whichever the operator is currently working as. + const loggedInTenantId = localStorage.getItem('tenantid'); + const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0'; + + // ============================== state ============================== + const [locations, setLocations] = useState([]); + const [tenantLocations, setTenantlocations] = useState([]); + const [loading, setLoading] = useState(false); + const [btnLoading, setBtnLoading] = useState(false); + + const [appId, setAppId] = useState(0); + const [locationid, setLocationid] = useState(0); + const [locationValue, setLocationValue] = useState(null); + const [locationsStatus, setLocationsStatus] = useState('loading'); + const [selectedTenantId, setSelectedTenantId] = useState(isStaffLogin ? '' : loggedInTenantId); + const [staffTenantList, setStaffTenantList] = useState([]); + const [staffTenantListLoading, setStaffTenantListLoading] = useState(false); + const tenantid = selectedTenantId; + + const [basePrice, setBasePrice] = useState(0); + const [pricePerKm, setPricePerKm] = useState(0); + const [minKm, setMinKm] = useState(0); + + const [pickCust, setPickCust] = useState(null); + const [dropCust, setDropCust] = useState([]); + const [customerlist, setCustomerlist] = useState([]); + const [isCustomerOpen, setIsCustomerOpen] = useState(false); + const [searchCustList, setSearchCustList] = useState(''); + + const [startdate, setStartdate] = useState(dayjs().format('MM-DD-YYYY')); + const [selectedtime, setSelectedtime] = useState(''); + const [pickupSlotsList, setPickupSlotsList] = useState(buildDefaultPickupSlots); + const [pickupSlot, setPickupSlot] = useState(null); + + const [otherinstructions, setOtherinstructions] = useState(''); + const [admintoken, setAdmintoken] = useState(); + + const [totaldist, settotaldist] = useState(0); + const [totalAmt, settotalAmt] = useState(0); + const [totalQty, settotalQty] = useState(0); + const [totalCash, settotalCash] = useState(0); + + const [uploadType, setUploadType] = useState(null); + const [users, setUsers] = useState([]); + const [fileName, setFileName] = useState(''); + const [slotDropdownOpen, setSlotDropdownOpen] = useState(false); + + const toastCacheRef = useRef({}); + + // ============================== toast ============================== + const opentoast = (message, variant, time) => { + enqueueSnackbar(message, { + variant, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + autoHideDuration: time || 1500 + }); + }; + + const OpenToast = (message, type = 'info', timeout = 3000) => { + const key = `${type}-${message}`; + if (toastCacheRef.current[key]) return; + opentoast(message, type, timeout); + toastCacheRef.current[key] = true; + setTimeout(() => delete toastCacheRef.current[key], 3000); + }; + + // ============================== reset chains ============================== + useEffect(() => { + setLocationid(0); + setLocationValue(null); + setDropCust([]); + setUsers([]); + setUploadType(null); + setFileName(''); + setPickupSlotsList(buildDefaultPickupSlots()); + setPickupSlot(null); + setSelectedtime(''); + }, [appId]); + + // ============================== fetchAppLocations ============================== + const fetchAppLocations = async () => { + setLoading(true); + try { + const locations = await fetchZoneLocations(); + setLocations(locations || []); + } catch (err) { + OpenToast(err.message, 'error', 5000); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAppLocations(); + }, []); + + // ============================== fetchTenantPricing ============================== + const fetchTenantPricing = async (id) => { + try { + const pricing = (await getAdminPricing()) || []; + // id comes from localStorage (always a string) but pricing.tenantid is + // numeric — a strict === here never matched, so basePrice/pricePerKm/ + // minKm stayed at their 0 defaults and every order priced at ₹0. + const match = pricing.find((p) => String(p.tenantid) === String(id)); + if (match) { + setBasePrice(match.baseprice || 0); + setPricePerKm(match.priceperkm || 0); + setMinKm(match.basedistance || 0); + } + } catch (err) { + // swallow + } + }; + + useEffect(() => { + if (tenantid) fetchTenantPricing(tenantid); + }, [tenantid]); + + // ============================== gettenantlocations ============================== + // Drives what shows in place of the Business Location picker. Previously + // this function was only ever called when `tenantid` was truthy, so an + // operator on a no-tenant login (the shared admin/developer account has + // tenantid: null) got a permanently empty picker with zero feedback — not + // even a toast, since the function body that would show one never ran. + const gettenantlocations = async (id) => { + if (!id) { + // Staff login with no tenant picked yet — the picker UI covers this. + setLocationsStatus('no-tenant'); + return; + } + setLocationsStatus('loading'); + try { + const details = (await getTenantLocations(id)) || []; + setTenantlocations(details); + if (details.length === 1) { + setLocationsStatus('ready'); + setPickCust(details[0]); + setLocationid(details[0].tenantlocationid); + setLocationValue(details[0]); + } else if (details.length === 0) { + setLocationsStatus('empty'); + opentoast('No saved business locations for this tenant', 'info', 3000); + } else { + setLocationsStatus('ready'); + } + } catch (err) { + // doormileAxios's response interceptor rejects with error.response.data + // (not the raw axios error), so the message lives at err.message here. + setTenantlocations([]); + setLocationsStatus('error'); + opentoast(err?.message || 'Could not load business locations', 'error', 4000); + } + }; + + useEffect(() => { + gettenantlocations(tenantid); + }, [tenantid]); + + // ============================== fetchStaffTenantList (Doormile staff picker) ============================== + // Only staff logins (loggedInTenantId blank/0) need this — a client login's + // tenant is already fixed by their token and this never fetches for them. + useEffect(() => { + if (!isStaffLogin) return; + setStaffTenantListLoading(true); + getAdminTenants() + .then((tenants) => setStaffTenantList((tenants || []).filter((t) => t.status === 'Active'))) + .catch((err) => { + opentoast(err?.message || 'Could not load tenant list', 'error', 4000); + }) + .finally(() => setStaffTenantListLoading(false)); + }, []); + + // ============================== clientdetails ============================== + // GET /admin/customers (B2C directory) — not /admin/tenantcustomers, which + // is an empty, manually-added contact book with no address/coordinates. + // This resource auto-grows from real orders (see the identical fix on + // createorder1.js's Saved Contacts) and has a confirmed live shape of + // { appcustomerid, createdat, email, name, phone, totalbookings } — no + // search/query param exists, so it's fetched once and filtered in-memory. + const clientdetails = async () => { + try { + const customers = (await getAdminCustomers()) || []; + setCustomerlist(customers); + } catch (err) { + opentoast('server error', 'warning'); + } + }; + + useEffect(() => { + if (!tenantid || !isCustomerOpen) return; + clientdetails(); + }, [tenantid, isCustomerOpen]); + + const filteredCustomerList = useMemo(() => { + const q = searchCustList.trim().toLowerCase(); + if (!q) return customerlist; + return customerlist.filter((c) => c.name?.toLowerCase().includes(q) || c.phone?.includes(searchCustList)); + }, [customerlist, searchCustList]); + + // ============================== totals ============================== + useEffect(() => { + let a1 = 0, + a2 = 0, + a3 = 0, + a4 = 0; + dropCust.forEach((c) => { + a1 += Number(c.distance) || 0; + a2 += Number(c.totalcharge) || 0; + a3 += Number(c.quantity) || 0; + a4 += Number(c.collectionamt) || 0; + }); + settotaldist(a1); + settotalAmt(a2); + settotalQty(a3); + settotalCash(a4); + }, [dropCust]); + + // ============================== distance (Google) ============================== + // ============================== distance (OSRM/Haversine) ============================== + const calculateDistance = async (customer) => { + try { + if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.'); + if (!pickCust || typeof pickCust !== 'object') throw new Error('Origin (pickCust) data missing or invalid.'); + + const roundedDistance = await calculateDrivingDistance(pickCust, customer); + const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm); + return { roundedDistance, totalcharge }; + } catch (error) { + if (error.message.includes('Origin') || error.message.includes('customer') || error.message.includes('coordinates')) { + OpenToast('Missing or invalid input data for distance calculation.', 'warning', 3000); + } else { + OpenToast('Unexpected error during distance calculation.', 'error', 3000); + } + throw error; + } + }; + + // ============================== checkbox handlers ============================== + // GET /admin/customers has no address/coordinates at all, so a saved + // customer can no longer be distance-calculated at selection time (that's + // what used to throw "Invalid coordinates" and silently drop the row every + // time). Add the row immediately instead — its Address cell renders an + // editable AddressAutocomplete (see the Drop List table) until the + // operator picks a real address, which is what fills in lat/lng and lets + // distance/charge be computed per-row. + const handleCheckboxChange = (event, customer) => { + const rowkey = `sel-${customer.appcustomerid}`; + if (event.target.checked) { + setDropCust((prev) => { + if (prev.some((c) => c._rowkey === rowkey)) return prev; + return [ + ...prev, + { + _rowkey: rowkey, + appcustomerid: customer.appcustomerid, + firstname: customer.name || '', + contactno: customer.phone || '', + address: '', + city: '', + postcode: '', + latitude: null, + longitude: null, + distance: 0, + totalcharge: 0, + quantity: 0, + collectionamt: 0 + } + ]; + }); + } else { + setDropCust((prev) => prev.filter((c) => c._rowkey !== rowkey)); + } + }; + + // Still used by the CSV "Continue" promotion and the Drop List remove + // button. Safe to keep calling calculateDistance here — CSV rows always + // carry real latitude/longitude from the sheet, unlike Selection rows. + const handleCheckboxChange1 = async (customer) => { + const rowkey = customer._rowkey; + let wasSelected = false; + setDropCust((prev) => { + wasSelected = prev.some((c) => c._rowkey === rowkey); + if (wasSelected) return prev.filter((c) => c._rowkey !== rowkey); + return prev; + }); + if (wasSelected) return; + setLoading(true); + try { + const { roundedDistance, totalcharge } = await calculateDistance(customer); + setDropCust((prev) => { + if (prev.some((c) => c._rowkey === rowkey)) return prev; + return [...prev, { ...customer, distance: roundedDistance, totalcharge }]; + }); + } catch (err) { + console.error('Failed to calculate distance:', err); + OpenToast(`Could not calculate distance for ${customer.firstname || 'this row'}`, 'warning', 3000); + } finally { + setLoading(false); + } + }; + + // Parses the AddressAutocomplete `place` result (see createorder1.js's + // handleDropPlaceSelected for the same pattern) for one Drop List row, + // updates that row's address/coordinates, then recomputes its distance and + // charge now that coordinates finally exist. + const handleRowPlaceSelected = async (rowkey, place) => { + const parsed = { suburb: '', city: '', postcode: '' }; + place.address_components.forEach((component) => { + component.types.forEach((type) => { + switch (type) { + case 'sublocality_level_1': + case 'sublocality': + parsed.suburb = component.long_name; + break; + case 'locality': + parsed.city = component.long_name; + break; + case 'postal_code': + parsed.postcode = component.long_name; + break; + } + }); + }); + const latitude = place.geometry.location.lat(); + const longitude = place.geometry.location.lng(); + const address = place.formatted_address || ''; + + setDropCust((prev) => + prev.map((c) => (c._rowkey === rowkey ? { ...c, address, city: parsed.city, postcode: parsed.postcode, latitude, longitude } : c)) + ); + + try { + const { roundedDistance, totalcharge } = await calculateDistance({ latitude, longitude }); + setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, distance: roundedDistance, totalcharge } : c))); + } catch (err) { + console.error('Failed to calculate distance:', err); + } + }; + + // No zone-admin-token or bulk-notify endpoint in the new API. + const fetchAppAdminTokens = async () => setAdmintoken([]); + + useEffect(() => { + if (appId) fetchAppAdminTokens(); + }, [appId]); + + // ============================== sendnotifications ============================== + const sendnotifications = async () => {}; + + // ============================== CSV / XLSX upload ============================== + const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name); + const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, ''); + + const headerMap = { + 'pickupdate(yyyy-mmm-dd)': 'date', + 'sendername*': 'locationname', + 'senderphone*': 'locationcontact', + 'senderaddress*': 'locationaddress', + 'receivername*': 'firstname', + receiverphone: 'contactno', + 'receiveralternatephone*': 'altcontactno', + receiverfulladdress: 'address', + // Best-effort — no live sample sheet to confirm these are the tenant's + // actual column headers. Without them deliverycity/deliverypincode in + // the submit payload were always sent blank; if these names are wrong, + // behavior is unchanged from before (still blank), not worse. + receivercity: 'city', + receiverpincode: 'postcode', + receiverlatitude: 'latitude', + receiverlongitude: 'longitude', + 'itemdescription*': 'description', + Quantity: 'quantity', + ' Collect Cash': 'collectionamt' + }; + + const handleFileDirectUpload = (event) => { + try { + const file = event.target.files?.[0]; + if (!file) { + opentoast('No file selected.', 'warning'); + return; + } + const fileNameLower = file.name.toLowerCase(); + const isCSV = fileNameLower.endsWith('.csv'); + const isExcel = fileNameLower.endsWith('.xls') || fileNameLower.endsWith('.xlsx'); + if (!isCSV && !isExcel) { + opentoast('Invalid file type. Please upload a CSV or Excel file.', 'warning'); + return; + } + + const processData = (data, headers) => { + const normalizedMap = {}; + for (const key in headerMap) normalizedMap[normalizeHeader(key)] = headerMap[key]; + const mappedData = data.map((row, idx) => { + const newRow = { _rowkey: `csv-${idx}` }; + for (const key in row) { + const cleanKey = normalizeHeader(key); + const newKey = normalizedMap[cleanKey] || cleanKey; + let value = row[key]; + if (newKey === 'firstname') value = cleanReceiverName(value); + newRow[newKey] = value; + } + return newRow; + }); + const requiredCols = Object.keys(headerMap).filter((k) => k.trim().endsWith('*')); + const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol))); + if (missingRequired.length > 0) { + opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000); + } + setUsers(mappedData); + opentoast('File uploaded successfully', 'success', 2000); + opentoast('Press Continue to add as drop customers', 'info', 2500); + }; + + if (isCSV) { + Papa.parse(file, { + header: true, + dynamicTyping: true, + skipEmptyLines: true, + complete: (results) => { + if (!results.data?.length) { + opentoast('CSV file is empty or has no valid rows.', 'warning'); + setUsers([]); + return; + } + processData(results.data, results.meta.fields.map(normalizeHeader)); + }, + error: (error) => opentoast(`CSV parsing failed: ${error.message}`, 'warning') + }); + } + if (isExcel) { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const workbook = XLSX.read(e.target.result, { type: 'binary' }); + const jsonData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { defval: '' }); + if (!jsonData?.length) { + opentoast('Excel file is empty or invalid.', 'warning'); + setUsers([]); + return; + } + processData(jsonData, Object.keys(jsonData[0]).map(normalizeHeader)); + } catch (err) { + opentoast(`Error reading Excel: ${err.message}`, 'warning'); + } + }; + reader.readAsBinaryString(file); + } + } catch (err) { + opentoast(`Unexpected error: ${err.message}`, 'warning'); + } + }; + + const removeFileExtension = (n) => n.replace(/\.[^/.]+$/, ''); + const onFileChange = (event) => { + const file = event.target.files[0]; + if (!file) return; + const cleanedName = removeFileExtension(file.name); + setFileName((prev) => (prev ? `${prev}, ${cleanedName}` : cleanedName)); + handleFileDirectUpload(event); + }; + + // ============================== row editors ============================== + const handleQuantityChange = (rowkey, value) => { + setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, quantity: Number(value) || 0 } : c))); + }; + const handleCollectionAmtChange = (rowkey, value) => { + setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, collectionamt: Number(value) || 0 } : c))); + }; + + // ============================== buildDeliveryTime ============================== + const buildDeliveryTime = () => { + if (pickupSlot) { + const parsed = dayjs(pickupSlot, ['YYYY-MM-DD hh:mm A', 'YYYY-MM-DD HH:mm:ss']); + if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss'); + } + if (startdate && selectedtime) { + const parsed = dayjs(`${dayjs(startdate).format('YYYY-MM-DD')} ${selectedtime}`, ['YYYY-MM-DD hh:mm A', 'YYYY-MM-DD HH:mm:ss']); + if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss'); + } + return dayjs().format('YYYY-MM-DD HH:mm:ss'); + }; + + // ============================== createorders ============================== + const createorders = async () => { + if (!tenantid) { + opentoast('Client not found. Please re-login.', 'warning'); + return; + } + if (!pickCust) { + opentoast('Pickup location required', 'warning'); + return; + } + if (!pickupSlot) { + opentoast('Select a pickup slot', 'warning'); + return; + } + if (!dropCust.length) { + opentoast('Add at least one drop customer', 'warning'); + return; + } + // A Selection-sourced row (picked from Saved Customers) starts with no + // address/coordinates until the operator fills in its Address cell — + // block submit rather than send a booking with lat/lng silently at 0. + const incomplete = dropCust.find((c) => c.latitude == null || c.longitude == null); + if (incomplete) { + opentoast(`Add a delivery address for ${incomplete.firstname || 'a selected customer'} before submitting`, 'warning', 4000); + return; + } + + const deliverytime = buildDeliveryTime(); + // POST /admin/expressbooking/bulk takes { bookings: [...] } where each + // entry is a flat booking object (tenantid, pickup* fields, customer_*, + // delivery*, service_option, finalprice, notes, parcels[]) — not the old + // per-row orders/pickup/drop shape. + // tenantid comes from the logged-in session (see the top-level `tenantid` + // const above), not from pickCust — pickCust is a tenant *location* + // object (GET /admin/tenants/:id/locations) and was never confirmed to + // carry a tenantid back-reference, so `pickCust.tenantid` was silently + // undefined on every submit. + // + // Always send raw pickup* fields, never pickuplocationid — live-confirmed + // pickuplocationid currently 500s on POST /admin/expressbooking (and by + // extension the bulk endpoint, same handler); a saved location's own + // address/pincode/city/lat/lng (already on pickCust) work fine sent raw. + // Note: pickCust.pincode, not .postcode — that was also wrong (the real + // GET /admin/tenants/:id/locations field is `pincode`). + const bookings = dropCust.map((customer) => ({ + tenantid: Number(tenantid), + pickupaddress: pickCust.address || '', + pickuppincode: pickCust.pincode || '', + pickupcity: pickCust.city || '', + pickuplatitude: Number(pickCust.latitude) || 0, + pickuplongitude: Number(pickCust.longitude) || 0, + customer_phone: customer.contactno?.toString() || '', + customer_name: customer.firstname || '', + deliveryaddress: customer.address || '', + deliverypincode: customer.postcode || '', + deliverycity: customer.city || '', + deliverylatitude: Number(customer.latitude) || 0, + deliverylongitude: Number(customer.longitude) || 0, + deliverytime, + service_option: 'Normal', + finalprice: +customer.totalcharge || 0, + notes: otherinstructions || '', + parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: +customer.totalcharge || 0 }] + })); + + setLoading(true); + setBtnLoading(true); + try { + const res = await createExpressBookingBulk(bookings); + if (res.success) { + opentoast('Orders Created Successfully', 'success', 2000); + navigate('/doormile/orders'); + } else { + opentoast(res?.message || 'Order creation failed. Please try again.', 'warning', 3000); + } + } catch (err) { + // doormileAxios's response interceptor rejects with error.response.data + // (not the raw axios error), so the message lives at err.message here — + // err.response was always undefined at this point, so this always fell + // through to the generic "Something went wrong." fallback before. + opentoast(err?.message || 'Something went wrong.', 'error', 3000); + } finally { + setLoading(false); + setBtnLoading(false); + } + }; + + // ============================== derived ============================== + const canSubmit = !!locationid && !!pickupSlot && dropCust.length > 0; + const prereqOk = !!locationid; + const previewMode = dropCust.length > 0 ? 'drops' : users.length > 0 ? 'preview' : 'empty'; + + // ============================== render ============================== + return ( + <> + {loading && } + t.zIndex.drawer + 1 }} open={btnLoading}> + + + + + {/* Title bar */} + + + Create Multiple Orders + + + + + Bulk-create deliveries from CSV/Excel or saved customers. + + + + {/* 50 / 50 workspace */} + + {/* LEFT — input fields */} + + {/* Card: Schedule & Pickup */} + + + + Schedule & Pickup + + + + + {locationsStatus === 'loading' ? ( + + Loading business locations… + + ) : locationsStatus === 'no-tenant' && isStaffLogin ? ( + // Staff login (express-console-api.md: token tenantid + // 0/null = "Doormile's own staff... sees everything, all + // tenants") — pick which tenant this bulk order is for. + option?.tenantname || ''} + isOptionEqualToValue={(option, value) => option?.tenantid === value?.tenantid} + onChange={(event, value) => setSelectedTenantId(value ? String(value.tenantid) : '')} + renderInput={(params) => ( + + )} + /> + ) : locationsStatus === 'no-tenant' ? ( + + This login has no tenant assigned — sign in as a tenant user + + ) : locationsStatus === 'error' ? ( + gettenantlocations(tenantid)} + sx={{ + display: 'flex', + alignItems: 'center', + px: 1.5, + py: 1, + borderRadius: '10px', + bgcolor: '#fef2f2', + border: '1px solid #fecaca', + color: '#b91c1c', + fontWeight: 600, + fontSize: 12.5, + cursor: 'pointer' + }} + > + Could not load business locations — tap to retry + + ) : locationsStatus === 'empty' ? ( + + No saved business locations for this tenant + + ) : tenantLocations?.length === 1 ? ( + + + + ) + }} + sx={{ + '& .MuiOutlinedInput-root': { borderRadius: '10px', height: '36px', alignItems: 'center' }, + '& .MuiOutlinedInput-input': { padding: '0 14px !important' } + }} + /> + ) : ( + (option ? locationDisplayName(option) : '')} + renderOption={(props, option) => ( + + + + + {locationDisplayName(option)} + + {option.isprimary && ( + + PRIMARY + + )} + + + {option.address || option.city || ''} + + + + )} + value={locationValue} + onChange={(event, value, reason) => { + if (reason === 'clear' || !value) { + setLocationid(0); + setLocationValue(null); + setPickCust(null); + } else { + setLocationid(value.tenantlocationid || 0); + setLocationValue(value); + setPickCust(value); + } + }} + renderInput={(params) => ( + + )} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + height: '36px', + alignItems: 'center', + padding: '0 9px !important' + }, + '& .MuiAutocomplete-input': { padding: '0 !important' } + }} + /> + )} + + + { + if (!v || !dayjs(v).isValid()) { + setStartdate(dayjs().format('MM-DD-YYYY')); + return; + } + const diffDays = dayjs().diff(dayjs(v), 'd'); + if (diffDays <= 0) { + setStartdate(dayjs(v).format('MM-DD-YYYY')); + setSelectedtime(''); + setPickupSlot(null); + } else { + opentoast('Choose an upcoming date', 'warning'); + setStartdate(dayjs().format('MM-DD-YYYY')); + } + }} + /> + + + { + if (!locationid) { + OpenToast('Please select a Business Location first.', 'warning', 3000); + return; + } + setSlotDropdownOpen(true); + }} + onClose={() => setSlotDropdownOpen(false)} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + height: '36px', + alignItems: 'center', + padding: '0 9px !important' + }, + '& .MuiAutocomplete-input': { padding: '0 !important' } + }} + onChange={(e, newValue, reason) => { + if (reason === 'clear' || !newValue) { + setSelectedtime(null); + setPickupSlot(null); + return; + } + if (!newValue.time) { + OpenToast('This slot has no time configured.', 'warning', 3000); + return; + } + const formattedTime = dayjs(newValue.time, 'HH:mm').format('hh:mm A'); + setSelectedtime(formattedTime); + const finalDateTime = dayjs(`${startdate} ${formattedTime}`, 'MM-DD-YYYY hh:mm A').format('YYYY-MM-DD hh:mm A'); + setPickupSlot(finalDateTime); + }} + getOptionLabel={(option) => (option ? option.name : '')} + renderInput={(params) => ( + + + + ) + }} + /> + )} + /> + + + {pickCust ? ( + + + + + + + {pickCust.locationname || '—'} + + + {pickCust.address || '—'} + + + + ) : ( + Pickup auto-fills once a Business Location is selected. + )} + + + + + {/* Card: Notes */} + + + Order Notes + Applied to every order + + + + + setOtherinstructions(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + height: '36px', + alignItems: 'center', + fontSize: '12px', + background: '#ffffff' + }, + '& .MuiOutlinedInput-input': { padding: '0 14px !important', fontSize: '12px' } + }} + /> + + + + + {/* Card: Summary + Submit */} + + + + Bulk Summary + + + Live totals + + + {(() => { + const metric = ({ icon: Icon, label, value, accent, active }) => ( + + + + + + + {label} + + + {value} + + + + ); + return ( + + + {metric({ + icon: FaRoute, + label: 'Distance', + value: totaldist ? `${totaldist} km` : '—', + accent: '#0ea5e9', + active: !!totaldist + })} + + + {metric({ icon: FaBoxes, label: 'Quantity', value: totalQty || 0, accent: '#10b981', active: !!totalQty })} + + + {metric({ + icon: FaMoneyBillWave, + label: 'Cash Collect', + value: `₹${Number(totalCash).toFixed(2)}`, + accent: '#f59e0b', + active: !!totalCash + })} + + + {metric({ + icon: FaTruck, + label: 'Deliveries', + value: dropCust.length, + accent: '#8b5cf6', + active: dropCust.length > 0 + })} + + + ); + })()} + + {dropCust.length > 0 && ( +
    +
    + +
    Total Charge
    +
    +
    + ₹{Number(totalAmt).toFixed(2)} +
    +
    + )} + + + + +
    +
    + + {/* RIGHT — file / drop preview */} + + + {/* Preview header */} + + + + {previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'} + + + + {previewMode === 'drops' && ( + + )} + {previewMode === 'preview' && ( + } + sx={{ + height: 22, + fontWeight: 700, + fontSize: 11, + bgcolor: 'rgba(245,158,11,0.12)', + color: '#b45309', + border: '1px solid rgba(245,158,11,0.30)', + '& .MuiChip-icon': { color: '#b45309' } + }} + /> + )} + {previewMode !== 'empty' && + (() => { + const handleHeaderPick = (val) => { + if (!prereqOk) { + OpenToast('Please select a Business Location first.', 'warning', 3000); + return; + } + setUploadType(val); + if (val === 0) document.getElementById('upload-file')?.click(); + else if (val === 1) { + setIsCustomerOpen(true); + setSearchCustList(''); + } + }; + return ( + + + + + ); + })()} + + + {fileName && ( + + + + {fileName} + + + )} + + {previewMode === 'preview' && users.length >= 1 && ( + + + + Process & Calculate Distances + + + Click continue to import spreadsheet rows and calculate drop charges. + + + + + )} + + {/* Scrollable preview body */} + + {/* Drops — table */} + {previewMode === 'drops' && ( + + + + + # + Customer + Address + + Qty + + + Cash + + Km + + Charge + + + {' '} + + + + + {dropCust.map((customer, index) => ( + + {index + 1} + + {customer.firstname} + + + {customer.latitude == null || customer.longitude == null ? ( + + setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c))) + } + onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)} + bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined} + /> + ) : ( + + + {customer.address} + + + )} + + + {uploadType === 0 ? ( + customer.quantity + ) : ( + handleQuantityChange(customer._rowkey, e.target.value)} + inputProps={{ min: 0 }} + sx={{ width: 64, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }} + /> + )} + + + {uploadType === 0 ? ( + `₹${Number(customer.collectionamt || 0).toFixed(2)}` + ) : ( + { + const v = Number(e.target.value); + handleCollectionAmtChange(customer._rowkey, v > 0 ? v : 0); + }} + inputProps={{ min: 0 }} + InputProps={{ startAdornment: ₹ }} + sx={{ width: 90, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }} + /> + )} + + {customer.distance || '—'} + + ₹{Number(customer?.totalcharge || 0).toFixed(2)} + + + + handleCheckboxChange1(customer)} sx={{ color: '#ef4444', p: 0.5 }}> + + + + + + ))} + + Total + + + {totalQty} + + + ₹{Number(totalCash).toFixed(2)} + + {totaldist} + + ₹{Number(totalAmt).toFixed(2)} + + + + +
    +
    + )} + + {/* File preview — table */} + {previewMode === 'preview' && ( + + + + + # + Name + Contact + Address + + Qty + + + Cash + + + + + {users.map((u, i) => ( + + {i + 1} + + {u.firstname || '—'} + + {u.contactno || '—'} + + + + {u.address || '—'} + + + + + {u.quantity ?? '—'} + + + {u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'} + + + ))} + +
    +
    + )} + + {/* Empty state */} + {previewMode === 'empty' && + (() => { + const handleEmptyPick = (val) => { + if (!prereqOk) { + OpenToast('Please select a Business Location first.', 'warning', 3000); + return; + } + setUploadType(val); + setDropCust([]); + setUsers([]); + setFileName(''); + if (val === 0) document.getElementById('upload-file')?.click(); + else if (val === 1) { + setIsCustomerOpen(true); + setSearchCustList(''); + } + }; + const tile = ({ value, icon: Icon, title, sub, accent }) => ( + handleEmptyPick(value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleEmptyPick(value); + } + }} + sx={{ + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + textAlign: 'left', + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: '10px', + border: '1.5px solid #eef2f6', + bgcolor: '#fff', + opacity: prereqOk ? 1 : 0.6, + cursor: prereqOk ? 'pointer' : 'not-allowed', + transition: 'all 0.18s ease', + '&:hover': prereqOk + ? { borderColor: accent, boxShadow: `0 4px 12px -4px ${accent}40`, transform: 'translateY(-1px)' } + : undefined + }} + > + + + + + {title} + + {sub} + + + + ); + return ( + + + + + Choose a Drop Source to begin + + Select a source below to import or pick your delivery customers. + + + {tile({ + value: 0, + icon: MdOutlineCloudUpload, + title: 'Excel / CSV', + sub: 'Bulk upload a sheet', + accent: '#0ea5e9' + })} + {tile({ value: 1, icon: FaUsers, title: 'Selection', sub: 'Pick saved customers', accent: '#8b5cf6' })} + + + ); + })()} +
    +
    +
    +
    +
    + + + + {/* Saved customers dialog */} + setIsCustomerOpen(false)} + fullWidth + fullScreen={isMobile} + sx={{ '& .MuiDialog-paper': { borderRadius: { xs: 0, sm: '16px' }, overflow: 'hidden' } }} + > + + + + {`Select Drop Customers (${dropCust.length || 0})`} + + + setSearchCustList(e.target.value)} + sx={{ bgcolor: 'white', borderRadius: '10px', '& .MuiOutlinedInput-input': { p: '10px 14px' } }} + startAdornment={ + + + + } + endAdornment={ + setSearchCustList('')}> + + + } + autoComplete="off" + /> + + + + + + {filteredCustomerList?.length === 0 ? ( + + + + ) : ( + + {filteredCustomerList?.map((customer, index) => { + const rowkey = `sel-${customer.appcustomerid}`; + const checked = dropCust.some((c) => c._rowkey === rowkey); + return ( + + handleCheckboxChange(event, customer)} />} + label={ + + + {customer.name} ({customer.phone}) + + + Address not on file — add one after selecting + + + } + /> + + ); + })} + + )} + + + + + + + + ); +}; + +export default MultipleOrders; diff --git a/src/pages/nearle/orders/orders.js b/src/pages/nearle/orders/orders.js index edafb83..caaaf65 100644 --- a/src/pages/nearle/orders/orders.js +++ b/src/pages/nearle/orders/orders.js @@ -704,6 +704,7 @@ const Orders = () => { - - newValue && setSelectedDate(newValue)} - slotProps={{ - textField: { - size: 'small', - sx: { - width: 180, - '& .MuiOutlinedInput-root': { - borderRadius: '20px', - '& fieldset': { borderColor: DT.borderSubtle }, - '&:hover fieldset': { borderColor: BRAND }, - '&.Mui-focused fieldset': { borderColor: BRAND } - } - } - } - }} - /> - + v && setSelectedDate(dayjs(v))} + /> diff --git a/src/pages/nearle/riders/riders.js b/src/pages/nearle/riders/riders.js index c0f941b..a4684f5 100644 --- a/src/pages/nearle/riders/riders.js +++ b/src/pages/nearle/riders/riders.js @@ -34,9 +34,7 @@ import { useTheme } from '@mui/material/styles'; var utc = require('dayjs/plugin/utc'); import dayjs from 'dayjs'; dayjs.extend(utc); -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { DateInput } from '@astryxdesign/core/DateInput'; import { MdCheckCircle, MdCancel, @@ -420,8 +418,8 @@ const Riders = () => { } /> @@ -553,7 +551,7 @@ const Riders = () => { value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} - placeholder="Search riders (ctrl+k)" + placeholder="Search riders (ctrl+k)" /> @@ -607,27 +605,13 @@ const Riders = () => { - - newValue && setHistoryDate(newValue)} - slotProps={{ - textField: { - size: 'small', - sx: { - width: 180, - '& .MuiOutlinedInput-root': { - borderRadius: '20px', - '& fieldset': { borderColor: DT.borderSubtle }, - '&:hover fieldset': { borderColor: BRAND }, - '&.Mui-focused fieldset': { borderColor: BRAND } - } - } - } - }} - /> - + v && setHistoryDate(dayjs(v))} + /> diff --git a/src/themes/dt/status.js b/src/themes/dt/status.js new file mode 100644 index 0000000..4cfafd3 --- /dev/null +++ b/src/themes/dt/status.js @@ -0,0 +1,95 @@ +// ============================================================================ +// Canonical lifecycle-status registry. +// +// Five pages (deliveries, orders, ordersDetails, Tenants, and the dispatch +// panels) each carried their own private `STATUS_META` map. They agreed on the +// hexes but disagreed on everything else — orders.js keys off the raw +// `GET /admin/bookings` enum (`pending_pickup`, `miler_assigned`, …) while +// deliveries.js keys off api.js's generic `pending`/`accepted`/`delivered` +// mapping, so the same order rendered under two different labels depending on +// which page you were looking at. +// +// This module is the one place that knows what a status LOOKS like. It does +// not know what a status MEANS to a given page — the pending/accepted bucket +// rules stay in each page's query layer, because those genuinely differ (see +// the long comment above orders.js's STATUS_TABS). +// +// Each entry carries four renderings of the same state so a page never has to +// pick a colour by hand: +// color — hex, for the surfaces that take a raw accent (StatCard, +// AccentAvatar, chart series). Matches CLAUDE.md's status palette. +// badge — Astryx . Non-semantic tinted variants on purpose: +// per Astryx's Badge guidance the solid semantic variants +// (success/warning/error) are for states that demand attention, and +// a table where every row shouts is a table where nothing does. +// `cancelled` is the one state that keeps a loud variant. +// dot — Astryx , which only has five values, so +// several lifecycle states collapse onto `accent` here. +// icon — react-icons component (not an element) so callers size it. +// ============================================================================ +import { + MdHourglassEmpty, + MdPersonPin, + MdLocationOn, + MdInventory2, + MdRoute, + MdSkipNext, + MdCheckCircle, + MdCancel, + MdList, + MdHelpOutline +} from 'react-icons/md'; + +export const STATUS_META = { + all: { label: 'All', color: '#000000', badge: 'neutral', dot: 'neutral', icon: MdList }, + pending: { label: 'Pending', color: '#f59e0b', badge: 'yellow', dot: 'warning', icon: MdHourglassEmpty }, + accepted: { label: 'Accepted', color: '#6366f1', badge: 'blue', dot: 'accent', icon: MdPersonPin }, + arrived: { label: 'Arrived', color: '#06b6d4', badge: 'cyan', dot: 'accent', icon: MdLocationOn }, + picked: { label: 'Picked', color: '#8b5cf6', badge: 'purple', dot: 'accent', icon: MdInventory2 }, + active: { label: 'Active', color: '#14b8a6', badge: 'teal', dot: 'success', icon: MdRoute }, + skipped: { label: 'Skipped', color: '#f97316', badge: 'orange', dot: 'warning', icon: MdSkipNext }, + delivered: { label: 'Delivered', color: '#10b981', badge: 'green', dot: 'success', icon: MdCheckCircle }, + cancelled: { label: 'Cancelled', color: '#ef4444', badge: 'error', dot: 'error', icon: MdCancel }, + inactive: { label: 'Inactive', color: '#ef4444', badge: 'red', dot: 'error', icon: MdCancel } +}; + +// Raw backend enums that render as one of the states above. Kept separate from +// STATUS_META so the canonical list stays readable and so a page can still ask +// "is this a known alias?" rather than silently falling back. +// +// The booking enums come from `GET /admin/bookings` (confirmed live — see +// orders.js). `converted_to_consignment` is deliberately `accepted` and not +// `picked`: it fires when the rider marks pickup COMPLETE, but the operator +// workflow on the Orders page treats everything before hand-off as assigned. +export const STATUS_ALIASES = { + pending_pickup: 'pending', + pending_assignment: 'pending', + miler_assigned: 'accepted', + pickup_scheduled: 'accepted', + converted_to_consignment: 'accepted', + in_transit: 'active', + intransit: 'active', + completed: 'delivered', + cancel: 'cancelled', + canceled: 'cancelled' +}; + +// Fallback for a status the backend invents that nobody has mapped yet. Renders +// as a neutral badge with the raw string as its label, so an unknown state is +// visible and debuggable rather than blank. +const unknownStatus = (status) => ({ + label: String(status || 'Unknown'), + color: '#94a3b8', + badge: 'neutral', + dot: 'neutral', + icon: MdHelpOutline +}); + +// Resolve any status string — canonical key, backend alias, or arbitrary +// casing — to its visual meta. Always returns an object; never throws. +export function getStatusMeta(status) { + if (!status) return unknownStatus(status); + const raw = String(status).trim(); + const key = raw.toLowerCase(); + return STATUS_META[key] || STATUS_META[STATUS_ALIASES[raw]] || STATUS_META[STATUS_ALIASES[key]] || unknownStatus(raw); +} diff --git a/src/themes/dt/tokens.js b/src/themes/dt/tokens.js index ee0fc7f..e15f95e 100644 --- a/src/themes/dt/tokens.js +++ b/src/themes/dt/tokens.js @@ -30,6 +30,12 @@ export const DT = { // Semantic status palette — lifecycle colours, deliberately distinct from the // brand (see root CLAUDE.md §6). Pages have historically inlined these hexes; // new code should reference STATUS so there is one place to change them. +// +// This map is the RAW HEX layer, for surfaces that take an arbitrary accent +// (StatCard, AccentAvatar, chart series). To RENDER a status — a row badge or +// a filter tab — use `themes/dt/status.js` instead: it resolves backend enums +// and aliases, and carries the matching Astryx Badge/StatusDot variants and +// icon alongside the hex. Reach for STATUS only when you need the bare colour. // This also replaces the `theme.palette.error/success/...` lookups that used // to pull the same colours out of the MUI theme. // --------------------------------------------------------------------------- @@ -57,6 +63,26 @@ export const soft = (c) => a(c, '18'); // soft chip / avatar bg export const ring = (c) => a(c, '26'); // focus ring color export const edge = (c) => a(c, '55'); // resting border +// --------------------------------------------------------------------------- +// ⛔ LEGACY — MUI-ONLY. Everything from here to the end of this file emits MUI +// `sx` objects and cannot be used by an Astryx page. +// +// They stay only because the pages still awaiting conversion import them +// (`pillFieldSx` ×3 files, `tableScrollSx` ×4, `tableHeadSx` ×3, +// `tableRowSx` ×3). They are deleted along with the MUI dependency once the +// last page converts — do NOT add a new call site. +// +// Astryx equivalents: +// pillFieldSx → nothing. Field chrome comes from the theme +// (`themes/astryx.js` → components['text-input']), so every +// input matches without a per-call-site helper. +// tableScrollSx → from themes/dt/primitives (also supplies +// the sticky header MUI's TableContainer used to provide). +// tableHeadSx → — header casing and +// row rules are the component's own chrome. +// tableRowSx → same; `hasHover` covers the hover tint. +// --------------------------------------------------------------------------- + // Pill input sx — used by every filter Autocomplete/TextField on a page. // Neutral, corporate filter field: white surface, hairline border, brand // focus ring. Width is driven by parent flex/grid so this helper stays