updates on the redesign page for all the pages

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

View File

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

View File

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

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

@@ -0,0 +1,113 @@
# CLAUDE.md — `src/pages/api/`
Rules for editing `api.js`. This is the **central API layer** — every page calls into it. The root `CLAUDE.md` covers project-wide conventions; this file is the scoped rule sheet for the API layer specifically.
---
## 1. Function signature patterns
### TanStack `useQuery` / `useInfiniteQuery` consumers
Destructure from `queryKey` in the order the call site declared it. The leading `_` is the query name and is intentionally discarded.
```js
// Plain query — destructure { queryKey }, skip the [0] name slot
export const fetchorderscount = async ({ queryKey }) => {
const [, appId, startdate, enddate, currentStatus, tenantid, locationid] = queryKey;
const url = `${process.env.REACT_APP_URL}/orders/getordersummary/?applocationid=${appId}&...`;
const response = await axios.get(url);
return response.data.details;
};
// Infinite query — also receives pageParam (default to 1)
export const fetchOrders = async ({ pageParam = 1, queryKey }) => {
const [, appId, currentStatus, debouncedSearch, startdate, enddate, rowsPerPage, tenantid, locationid] = queryKey;
const url = `${process.env.REACT_APP_URL}/orders/tenant/getorders/?applocationid=${appId}&...&pageno=${pageParam}&pagesize=${rowsPerPage}`;
const response = await axios.get(url);
return {
rows: response.data.details,
nextPage: response.data.details.length === Number(rowsPerPage) ? pageParam + 1 : undefined
};
};
```
**Hard rules:**
- The query-key array order at the call site MUST match the destructure order here. Re-ordering one without the other silently breaks every caller.
- Infinite queries return `{ rows, nextPage }`. `nextPage` is `undefined` when the page size wasn't filled (signals end-of-stream to `getNextPageParam`).
- Some legacy functions return `{ data, nextPage }` instead of `{ rows, nextPage }` (e.g. `getallcustomers`). Match the existing shape rather than "fixing" it — call sites depend on the field name.
---
## 2. Direct positional-argument calls
A few functions take plain positional arguments instead of `queryKey` — usually when they're invoked from a `useMutation` or imperatively. Example: `getTenants(appId)`, `gettenantlocations(tenantid)`.
Pick the signature based on how the function is called:
- Called via `useQuery({ queryFn: fn })` → destructure `{ queryKey }`.
- Called via `useQuery({ queryFn: () => fn(arg) })` → take positional args.
- Called from a mutation or imperatively → take positional args.
---
## 3. Base URL selection
| Use base | When |
|---|---|
| `process.env.REACT_APP_URL` | Default for ~95% of endpoints |
| `process.env.REACT_APP_URL2` | `/users/update`, `/tenants/update`, `/tenants/update/services`, archival `/orders/getorders`, `/partners/getriderlogs` |
| Hardcoded `https://routes.workolik.com` | Bike solver + reconcile-steps + batch efficiency |
| Hardcoded `https://routemate.workolik.com` | Auto / multi-trip solver |
| Hardcoded `https://jupiter.nearle.app` | Login + final `/deliveries/createdeliveries` commit |
When adding a new endpoint, check whether the backend actually serves it on URL or URL2 — don't guess. URL2 lives on a separate service.
---
## 4. Error handling pattern
```js
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/...`);
return response.data.details; // or .summary, .data, etc — depends on backend shape
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return null; // or return [] / {} — match what the caller expects
}
```
- Toast on failure via `OpenToast` from `components/third-party/OpenToast`. Don't `throw` — TanStack Query's `onError` is rarely wired by callers.
- Return a sensible empty default (`null`, `[]`, `{}`) so the call site's destructuring doesn't crash.
- Don't `console.log(err)` AND toast — toast is enough. Some legacy functions do both; new functions should not.
---
## 5. When NOT to add to `api.js`
The user has tolerated a number of pages making direct inline `axios.put` / `axios.post` calls for mutations (e.g. `Tenants.js` calls `axios.put('/tenants/update')` inline). Match the surrounding file:
- **Adding a new shared GET** → put it in `api.js`.
- **Adding a one-off mutation used in only one page** → tolerated inline in that page; doesn't need a new export here.
- **Adding a polling endpoint used by multiple pages** → put it here as a named export so the query cache keys are coherent.
---
## 6. Response shape quirks
Backend response shapes are inconsistent. Don't assume `response.data.details` — check what the specific endpoint returns:
| Backend field | Used by |
|---|---|
| `response.data.details` | Most list endpoints |
| `response.data.summary` | `getcustomersummary`, `gettenantsummary`, `getpricinglist` summary calls |
| `response.data.data` | `getRiderPeriodicLogs`, `getallcustomers` infinite-query page payload |
| `response.data.message` | Mutation success messages (for toast) |
| `response.data.status` | Boolean success flag — check before reading `.details` on some endpoints |
When unsure, log the response once during dev and pick the matching field.
---
## 7. Things that look broken but are intentional
- `const userid = localStorage.getItem('userid');` at module top — read once at module load, intentional for the `fetchAppLocations` helper. Don't move it inside the function.
- Some functions take a `pageno` 0-indexed and others 1-indexed (`pageno: pageParam + 1` vs `pageno: pageParam`). Backend inconsistency — leave it alone unless you confirm the backend side.
- A few legacy commented-out function bodies are kept above their current implementation as historical reference. Don't delete them in a drive-by edit.

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff