updates on the ui changes and frully removed the mui and converted into astryx and color change

This commit is contained in:
2026-08-13 18:24:04 +05:30
parent 498bb7cbc0
commit 8cd8e34b11
122 changed files with 15111 additions and 22411 deletions

View File

@@ -1,7 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { Autocomplete, TextField, CircularProgress } from '@mui/material';
import { debounce } from '@mui/material/utils';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Spinner } from '@astryxdesign/core/Spinner';
import { Text } from '@astryxdesign/core/Text';
import { DT } from 'themes/dt/tokens';
// Free OSM Nominatim geocoder — replaces Google Places Autocomplete /
// react-geocode across the console. No API key required. Nominatim's usage
@@ -73,10 +78,15 @@ export async function geocodeAddress(address, { bias } = {}) {
}
}
// Address search-as-you-type — replaces Google Places Autocomplete
// (usePlacesWidget / window.google.maps.places.Autocomplete /
// AutocompleteService) across the console. Renders as an MUI Autocomplete
// with freeSolo so the operator can still submit free text.
// Address search-as-you-type — replaces Google Places Autocomplete across the
// console.
//
// Astryx note: this is a TextInput plus a hand-rolled suggestion list rather
// than Astryx's `Typeahead`. Typeahead is a *select-from-source* control — its
// value is a chosen item and arbitrary text can't be submitted. Every call
// site here needs free text (operators routinely type an address Nominatim
// can't match and submit it anyway), which is what the old MUI Autocomplete's
// `freeSolo` gave us. Keeping that behaviour means owning the dropdown.
//
// Props:
// value / onChange(text) — controlled free-text input value
@@ -91,103 +101,161 @@ const AddressAutocomplete = ({
onChange,
onPlaceSelected,
bias,
sx,
textFieldSx,
fullWidth = true,
disabled,
inputRef,
TextFieldProps = {}
status
}) => {
const [inputValue, setInputValue] = useState(value || '');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const wrapRef = useRef(null);
const activeRef = useRef(0);
useEffect(() => {
if (value !== undefined && value !== inputValue) setInputValue(value || '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const fetchPredictions = useMemo(
() =>
debounce((query, bias1, callback) => {
if (!query) {
callback([]);
return;
}
const params = buildParams(query, bias1);
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } })
.then((res) => (res.ok ? res.json() : []))
.then((results) => callback(results || []))
.catch(() => callback([]));
}, 500),
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
const fetchPredictions = useDebouncedCallback((query) => {
const seq = ++activeRef.current;
const params = buildParams(query, bias);
fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } })
.then((res) => (res.ok ? res.json() : []))
.then((results) => {
if (seq !== activeRef.current) return; // a newer keystroke won
setLoading(false);
setOptions(results || []);
setIsOpen((results || []).length > 0);
})
.catch(() => {
if (seq !== activeRef.current) return;
setLoading(false);
setOptions([]);
});
}, 500);
useEffect(() => {
if (!inputValue) {
activeRef.current++; // invalidate any in-flight request
setOptions([]);
setLoading(false);
setIsOpen(false);
return;
}
setLoading(true);
fetchPredictions(inputValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue, biasKey]);
// Close the list on an outside click.
useEffect(() => {
const onDocDown = (e) => {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setIsOpen(false);
};
document.addEventListener('mousedown', onDocDown);
return () => document.removeEventListener('mousedown', onDocDown);
}, []);
const listStyle = useMemo(
() => ({
position: 'absolute',
zIndex: 1300,
insetInlineStart: 0,
insetInlineEnd: 0,
marginTop: 4,
background: DT.surface,
border: `1px solid ${DT.borderSubtle}`,
borderRadius: DT.radiusInner + 'px',
boxShadow: DT.shadowPop,
overflow: 'hidden',
maxHeight: 260,
overflowY: 'auto'
}),
[]
);
useEffect(() => {
let active = true;
if (!inputValue) {
setOptions([]);
setLoading(false);
return undefined;
}
setLoading(true);
fetchPredictions(inputValue, bias, (results) => {
if (!active) return;
setLoading(false);
setOptions(results);
});
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue, bias?.lat, bias?.lng, fetchPredictions]);
const pick = (option) => {
setInputValue(option.display_name || '');
onChange?.(option.display_name || '');
onPlaceSelected?.(toPlace(option));
setIsOpen(false);
};
return (
<Autocomplete
id={id}
freeSolo
fullWidth={fullWidth}
disabled={disabled}
sx={sx}
options={options}
filterOptions={(x) => x}
getOptionLabel={(option) => (typeof option === 'string' ? option : option.display_name || '')}
isOptionEqualToValue={(option, val) => option.place_id === val?.place_id}
inputValue={inputValue}
onInputChange={(event, newInputValue) => {
setInputValue(newInputValue);
onChange?.(newInputValue);
}}
onChange={(event, selected) => {
if (selected && typeof selected !== 'string') {
setInputValue(selected.display_name || '');
onPlaceSelected?.(toPlace(selected));
}
}}
renderInput={(params) => (
<TextField
{...params}
{...TextFieldProps}
inputRef={inputRef}
label={label}
placeholder={placeholder}
sx={textFieldSx}
autoComplete="off"
InputProps={{
...params.InputProps,
...TextFieldProps.InputProps,
startAdornment: TextFieldProps.InputProps?.startAdornment ?? params.InputProps.startAdornment,
endAdornment: (
<>
{loading ? <CircularProgress color="inherit" size={16} /> : null}
{TextFieldProps.InputProps?.endAdornment ?? params.InputProps.endAdornment}
</>
)
}}
/>
<div ref={wrapRef} style={{ position: 'relative', width: fullWidth ? '100%' : undefined }}>
<TextInput
id={id}
label={label || placeholder}
isLabelHidden={!label}
placeholder={placeholder}
value={inputValue}
isDisabled={disabled}
status={status}
width="100%"
onChange={(next) => {
setInputValue(next);
onChange?.(next);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') setIsOpen(false);
}}
/>
{loading && (
<span style={{ position: 'absolute', insetInlineEnd: 10, top: label ? 34 : 10 }}>
<Spinner size="sm" />
</span>
)}
/>
{isOpen && options.length > 0 && (
<ul style={{ ...listStyle, listStyle: 'none', margin: 0, padding: 0 }} role="listbox" aria-label="Address suggestions">
{options.map((option) => (
<li key={option.place_id}>
<button
type="button"
onClick={() => pick(option)}
style={{
display: 'block',
width: '100%',
textAlign: 'start',
padding: '8px 12px',
background: 'transparent',
border: 'none',
cursor: 'pointer'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = DT.surfaceAlt;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<Text display="block" size="sm" maxLines={2}>
{option.display_name}
</Text>
</button>
</li>
))}
</ul>
)}
</div>
);
};
AddressAutocomplete.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
onPlaceSelected: PropTypes.func,
bias: PropTypes.object,
fullWidth: PropTypes.bool,
disabled: PropTypes.bool,
status: PropTypes.object
};
export default AddressAutocomplete;

View File

@@ -4,19 +4,28 @@ Rules for editing the shared Doormile Express UI primitives.
These components are imported across every page. Changes here have **fan-out impact** — measure twice, cut once.
> **Astryx migration in progress.** This folder is being converted from MUI to
> Astryx (`@astryxdesign/core`) ahead of the pages that consume it, so a
> component here may already be Astryx while its call sites are still MUI —
> that mix is expected, not a bug. `src/pages/nearle/login.js` is the reference
> for Astryx component usage. §2–§4 below still describe the MUI-era `DT`/`sx`
> conventions and apply only to the components not yet marked ✅ Astryx.
---
## 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 |
| `PageHeader.js` | Page title + live subtitle + action slot | ✅ **Astryx** — `Heading`/`Text`/`HStack`/`StatusDot` |
| `StatCard.js` | KPI / metric tile | ✅ **Astryx** — `Card`/`Text`/`Center`/`Skeleton` |
| `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 |
| `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`). |
| `TitleCard.js` | Old page header | ⛔ **Legacy** — do not use on new pages. The replacement is `PageHeader.js`. |
---
@@ -62,7 +71,7 @@ The component supports two visual modes, controlled by the `pill` prop:
---
## 4. `DebounceSearchBar` — debounce + ⌘/Ctrl+K shortcut
## 4. `DebounceSearchBar` — debounce + ⌘/Ctrl+K shortcut (Astryx)
```jsx
<DebounceSearchBar
@@ -70,13 +79,13 @@ The component supports two visual modes, controlled by the `pill` prop:
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
/>
```
- **There is no `sx` prop.** It was removed with the Astryx conversion and stripped from all 12 call sites in the same pass. The field's appearance now comes from Astryx's `TextInput` theme, so search looks identical on every page instead of each page carrying its own pill styling. Don't reintroduce a styling prop — restyle via the theme (`src/themes/astryx.js`) so every page moves together.
- **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.
- ⌘/Ctrl+K to focus and `Esc` to blur are wired globally inside this component (Astryx's `TextInput` forwards `ref` to the real `<input>`, so the shortcut survived the conversion). Don't add competing keyboard shortcuts at the page level using the same keys.
- Width comes from the parent — the component sets `width="100%"`, the old `fullWidth`.
---
@@ -91,15 +100,23 @@ Don't add a wrapper that just renames an MUI primitive (e.g. `<NearleButton>`).
---
## 6. When to inline instead
## 6. Tokens are shared — do not inline them
The `DT` token block at the top of each page is repeated intentionally. Don't extract it into a shared module here. Reasons:
> **This section previously said the opposite** ("repeat the `DT` block per
> page, don't extract"). That was tried and it failed: 17 pages each grew their
> own copy and the copies drifted apart — `orders.js` was still on
> `radiusCard: 16` and the old heavy shadows while every other page had moved
> to `14` and the restrained set. That drift is what made pages stop looking
> alike.
- 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.
`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.**
Same logic for `SoftPaper`, `AccentAvatar`, `pillFieldSx` — these are 5–20 line helpers that live inside each page file. Don't extract.
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
one place instead of hidden at the top of a 2000-line page.
---

View File

@@ -1,9 +1,13 @@
import PropTypes from 'prop-types';
import { useState } from 'react';
import { Box, Button, Divider, Stack, Typography } from '@mui/material';
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 { DT, soft, edge } from 'themes/dt/tokens';
import dayjs from 'dayjs';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { Divider } from '@astryxdesign/core/Divider';
import { DateInput } from '@astryxdesign/core/DateInput';
// Drop-in replacement for the unmaintained `mui-daterange-picker` package
// (pinned to React 17, never updated — see the React 19 upgrade plan).
@@ -11,9 +15,13 @@ import { DT, soft, edge } from 'themes/dt/tokens';
// on every selection (preset or custom), `definedRanges` renders the quick
// picks. `open`/`toggle` are accepted for API compatibility but unused —
// visibility is already gated by the parent Dialog.
//
// Astryx note: DateInput speaks ISO `YYYY-MM-DD` strings, where the MUI
// DatePicker spoke dayjs objects. The conversion happens here so the
// `onChange` contract (native Date objects) is unchanged for callers.
export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
const [customStart, setCustomStart] = useState(null);
const [customEnd, setCustomEnd] = useState(null);
const [customStart, setCustomStart] = useState(undefined);
const [customEnd, setCustomEnd] = useState(undefined);
const pickPreset = (range) => {
onChange({ startDate: range.startDate, endDate: range.endDate, label: range.label });
@@ -21,70 +29,53 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
const pickCustom = (nextStart, nextEnd) => {
if (nextStart && nextEnd) {
onChange({ startDate: nextStart.toDate(), endDate: nextEnd.toDate(), label: undefined });
onChange({ startDate: dayjs(nextStart).toDate(), endDate: dayjs(nextEnd).toDate(), label: undefined });
}
};
return (
<Stack spacing={2.5} sx={{ width: '100%', pb: 1 }}>
<Stack direction="row" flexWrap="wrap" gap={1}>
<VStack gap={4} padding={0} width="100%">
<HStack gap={2} wrap="wrap">
{definedRanges.map((range) => (
<Button
key={range.label}
onClick={() => pickPreset(range)}
sx={{
borderRadius: DT.radiusPill + 'px',
px: 2,
py: 0.75,
fontWeight: 700,
fontSize: 13,
textTransform: 'none',
color: DT.textPrimary,
bgcolor: soft('#f59e0b'),
border: '1px solid',
borderColor: edge('#f59e0b'),
'&:hover': { bgcolor: soft('#f59e0b'), borderColor: '#f59e0b' }
}}
>
{range.label}
</Button>
<Button key={range.label} label={range.label} variant="secondary" size="sm" onClick={() => pickPreset(range)} />
))}
</Stack>
</HStack>
<Divider sx={{ borderColor: DT.divider }} />
<Divider />
<Box>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 700, textTransform: 'uppercase' }}>
<VStack gap={2} padding={0}>
<Text type="supporting" weight="bold" style={{ textTransform: 'uppercase' }}>
Custom range
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 1 }}>
<DatePicker
label="Start date"
value={customStart}
onChange={(v) => {
setCustomStart(v);
pickCustom(v, customEnd);
}}
format="DD-MM-YYYY"
slotProps={{ textField: { size: 'small', fullWidth: true } }}
/>
<DatePicker
label="End date"
value={customEnd}
minDate={customStart || undefined}
onChange={(v) => {
setCustomEnd(v);
pickCustom(customStart, v);
}}
format="DD-MM-YYYY"
slotProps={{ textField: { size: 'small', fullWidth: true } }}
/>
</Stack>
</LocalizationProvider>
</Box>
</Stack>
</Text>
<HStack gap={2} wrap="wrap">
<DateInput
label="Start date"
value={customStart}
hasClear
onChange={(v) => {
setCustomStart(v);
pickCustom(v, customEnd);
}}
/>
<DateInput
label="End date"
value={customEnd}
min={customStart}
hasClear
onChange={(v) => {
setCustomEnd(v);
pickCustom(customStart, v);
}}
/>
</HStack>
</VStack>
</VStack>
);
};
DateRangePicker.propTypes = {
onChange: PropTypes.func,
definedRanges: PropTypes.array
};
export default DateRangePicker;

View File

@@ -1,19 +1,26 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useRef } from 'react';
import { OutlinedInput, InputAdornment, Tooltip, IconButton } from '@mui/material';
import { SearchOutlined } from '@mui/icons-material';
import ClearIcon from '@mui/icons-material/Clear';
import { useDebounce } from 'use-debounce';
const DebounceSearchBar = ({
value,
onChange,
onDebouncedChange, // 🔹 NEW
debounceTime = 500,
placeholder = 'Search (ctrl+k)',
sx
}) => {
const textFieldRef = useRef(null);
import { TextInput } from '@astryxdesign/core/TextInput';
// ==============================|| DEBOUNCED SEARCH BAR ||============================== //
// 500ms-debounced search input with a global ⌘/Ctrl+K focus shortcut and Esc
// to blur.
//
// Astryx migration note: the old MUI `OutlinedInput` took an `sx` prop from
// every call site carrying that page's pill styling (radius/tint/border/focus
// ring). Astryx's TextInput owns that styling through the theme, so `sx` is
// gone and has been stripped from all call sites — the field now looks
// identical on every page instead of drifting per page. Width is driven by the
// parent (width="100%"), same as the old `fullWidth`.
//
// TextInput's `ref` forwards to the real <input>, so the focus shortcut works
// unchanged. `label` is required for accessibility but stays visually hidden —
// the placeholder carries the visible affordance, as before.
const DebounceSearchBar = ({ value, onChange, onDebouncedChange, debounceTime = 500, placeholder = 'Search (ctrl+k)' }) => {
const inputRef = useRef(null);
const [debouncedValue] = useDebounce(value, debounceTime);
@@ -28,10 +35,10 @@ const DebounceSearchBar = ({
const handleKeyPress = (event) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
textFieldRef.current?.focus();
inputRef.current?.focus();
}
if (event.key === 'Escape' && document.activeElement === textFieldRef.current) {
textFieldRef.current.blur();
if (event.key === 'Escape' && document.activeElement === inputRef.current) {
inputRef.current.blur();
}
};
document.addEventListener('keydown', handleKeyPress);
@@ -39,26 +46,16 @@ const DebounceSearchBar = ({
}, []);
return (
<OutlinedInput
sx={{ ...sx }}
inputRef={textFieldRef}
<TextInput
ref={inputRef}
label={placeholder}
isLabelHidden
placeholder={placeholder}
autoComplete="off"
value={value}
fullWidth
onChange={(e) => onChange(e.target.value)}
startAdornment={
<InputAdornment position="start" sx={{ mr: -0.5 }}>
<SearchOutlined />
</InputAdornment>
}
endAdornment={
<Tooltip title="Clear">
<IconButton sx={{ visibility: value ? 'visible' : 'hidden' }} onClick={() => onChange('')}>
<ClearIcon />
</IconButton>
</Tooltip>
}
onChange={(next) => onChange(next)}
startIcon="search"
hasClear
width="100%"
/>
);
};

View File

@@ -1,35 +1,56 @@
// LoaderWithImage.jsx
import React from 'react';
import { Box, CircularProgress } from '@mui/material';
import PropTypes from 'prop-types';
import { Center } from '@astryxdesign/core/Center';
import nelogo from '../../assets/images/doormile-mark.png';
import { DT } from 'themes/dt/tokens';
// ==============================|| BRANDED RING SPINNER ||============================== //
// A brand-red ring rotating around the Doormile mark. Used inline for
// "loading more…" states, and by CircularLoader as a full-screen overlay.
//
// Astryx has no large circular spinner — its `Spinner` tops out at 18px, which
// is a glyph next to text, not a page-level indicator. The ring is therefore a
// plain CSS border animation on an Astryx `Center`, coloured from the DT brand
// token. Keyframes are injected once from this module rather than per instance.
const KEYFRAMES_ID = 'doormile-ring-spin';
const RingKeyframes = () => (
<style id={KEYFRAMES_ID}>{`
@keyframes ${KEYFRAMES_ID} { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.doormile-ring { animation-duration: 2.4s; }
}
`}</style>
);
export default function LoaderWithImage({ size = 70, imgSize = 40, alt = 'loader' }) {
return (
<Box position="relative" display="inline-flex" justifyContent="center" alignItems="center">
<CircularProgress size={size} />
<Box
position="absolute"
display="flex"
justifyContent="center"
alignItems="center"
sx={{
width: imgSize,
height: imgSize
<Center isInline width={size} height={size} style={{ position: 'relative' }}>
<RingKeyframes />
<span
className="doormile-ring"
style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
border: `${Math.max(3, Math.round(size / 14))}px solid ${DT.divider}`,
borderTopColor: DT.brand,
animation: `${KEYFRAMES_ID} 0.9s linear infinite`
}}
>
<img
src={nelogo}
alt={alt}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
objectFit: 'cover',
transform: 'scale(1.4)'
}}
/>
</Box>
</Box>
/>
<img
src={nelogo}
alt={alt}
style={{ width: imgSize, height: imgSize, borderRadius: '50%', objectFit: 'cover', transform: 'scale(1.4)' }}
/>
</Center>
);
}
LoaderWithImage.propTypes = {
size: PropTypes.number,
imgSize: PropTypes.number,
alt: PropTypes.string
};

View File

@@ -1,151 +1,106 @@
import React, { forwardRef, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Selector } from '@astryxdesign/core/Selector';
import { Autocomplete, TextField, Avatar, Stack } from '@mui/material';
import { MdMyLocation } from 'react-icons/md';
import { fetchAppLocations } from 'pages/api/api';
// Pill variant — opt-in via `pill` prop. Mirrors the design used across the
// deliveries / dispatch filter rows (rounded pill, soft tinted bg, accent
// 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(
(
{
locaName,
setAppId,
setLocoName,
setPage,
sx,
textfeildSx,
textfieldSx,
pill = false,
accentColor = '#6366f1',
icon,
placeholder = 'Select Zone',
paperComponent
},
ref
) => {
// Tenant-scoped cache key — fetchAppLocations() now narrows the hub list
// to the logged-in tenant's own city, so caching under one shared flat
// key would leak a previous tenant's list into a different tenant's
// session on the same browser if it ever skipped a full logout clear.
const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`;
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]'));
// ==============================|| ZONE PICKER ||============================== //
// Astryx `Selector` over the hub-derived zone list.
//
// Migration notes: the MUI version carried a `pill` variant plus
// `accentColor` / `icon` / `paperComponent` / `sx` / `textfieldSx` props so
// each page could restyle the field itself. Astryx's Selector owns that
// styling, so all of those are gone and the picker now looks identical
// everywhere. `width` is the one layout prop kept, since call sites genuinely
// differ (220px on pricing, 280px elsewhere).
//
// `ref` still works: three pages (deliveries, ordersDetails, ordersSummary)
// call `locationRef.current.focus()` to pull the operator back to the zone
// picker after a "select your zone first" toast. Selector doesn't forward a
// ref, so we expose `focus()` ourselves and drive its trigger button.
useEffect(() => {
// Zones are derived from GET /admin/hubs (the new API has no dedicated
// zones resource) — see fetchAppLocations in pages/api/api.js.
const fetchLocations = async () => {
try {
const updatedLocations = await fetchAppLocations();
localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations));
setLocations(updatedLocations);
} catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err);
}
};
const LocationAutocomplete = forwardRef(({ locaName, setAppId, setLocoName, setPage, placeholder = 'Select Zone', width = 280 }, ref) => {
// Tenant-scoped cache key — fetchAppLocations() now narrows the hub list
// to the logged-in tenant's own city, so caching under one shared flat
// key would leak a previous tenant's list into a different tenant's
// session on the same browser if it ever skipped a full logout clear.
const applocationsCacheKey = `applocations_${localStorage.getItem('tenantid') || 'staff'}`;
const [locations, setLocations] = useState(JSON.parse(localStorage.getItem(applocationsCacheKey) || '[]'));
const wrapRef = useRef(null);
if (locations.length === 0) {
fetchLocations();
useImperativeHandle(ref, () => ({
focus: () => wrapRef.current?.querySelector('button')?.focus()
}));
useEffect(() => {
// Zones are derived from GET /admin/hubs (the new API has no dedicated
// zones resource) — see fetchAppLocations in pages/api/api.js.
const fetchLocations = async () => {
try {
const updatedLocations = await fetchAppLocations();
localStorage.setItem(applocationsCacheKey, JSON.stringify(updatedLocations));
setLocations(updatedLocations);
} catch (err) {
console.error('Error fetching locations in LocationAutocomplete:', err);
}
}, [locations.length, applocationsCacheKey]);
};
// 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 ring = a('26');
const soft = a('18');
if (locations.length === 0) {
fetchLocations();
}
}, [locations.length, applocationsCacheKey]);
const pillSx = pill
? {
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}` },
'&.Mui-focused fieldset': { borderColor: accentColor, borderWidth: 1.5 }
},
'& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: '#94a3b8' }
}
: {};
const options = useMemo(() => (locations || []).map((l) => ({ value: l.locationname, label: l.locationname })), [locations]);
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>
);
const handleChange = (value) => {
if (!value) {
// null (not 0) — 0 is a real, meaningful selection here: the synthetic
// "All" option (fetchAppLocations in api.js appends
// { locationname: 'All', applocationid: 0 }). Callers that gate other
// pickers on "has a zone been chosen yet" (e.g. deliveries.js's
// Tenant/Rider dropdowns) need to tell "nothing picked" apart from
// "deliberately picked All" — both used to collapse to the same falsy 0,
// so choosing All incorrectly still tripped the "select a zone first" gate.
setAppId?.(null);
setLocoName?.('');
setPage?.(0);
return;
}
const picked = (locations || []).find((l) => l.locationname === value);
if (picked) {
setAppId?.(picked.applocationid);
setLocoName?.(picked.locationname);
setPage?.(0);
}
};
return (
<Autocomplete
id="location-autocomplete"
options={locations || []}
// Controlled by the caller's `locaName` string (the selected zone's
// locationname) so an external reset (e.g. setLocoName('All')) is
// reflected in the input — previously uncontrolled, so callers
// passing `locaName` had no effect and the pill silently kept
// showing the last user-picked zone.
value={locaName ? (locations || []).find((l) => l.locationname === locaName) ?? null : null}
isOptionEqualToValue={(option, value) => option?.applocationid === value?.applocationid}
getOptionLabel={(option) => option?.locationname ?? ''}
PaperComponent={paperComponent}
sx={{ ...sx }}
onChange={(event, value, reason) => {
if (reason === 'clear') {
// null (not 0) — 0 is a real, meaningful selection here: the
// synthetic "All" option (fetchAppLocations in api.js appends
// { locationname: 'All', applocationid: 0 }). Callers that gate
// other pickers on "has a zone been chosen yet" (e.g.
// deliveries.js's Tenant/Rider dropdowns) need to tell "nothing
// picked" apart from "deliberately picked All" — both used to
// collapse to the same falsy 0, so choosing All incorrectly
// still tripped the "select a zone first" gate.
setAppId?.(null);
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 || {}) }} />
)
}
return (
<div ref={wrapRef} style={{ width }}>
<Selector
label="Zone"
isLabelHidden
placeholder={placeholder}
options={options}
value={locaName || undefined}
onChange={handleChange}
hasClear
hasSearch
searchPlaceholder="Search zones…"
/>
);
}
);
</div>
);
});
LocationAutocomplete.displayName = 'LocationAutocomplete';
LocationAutocomplete.propTypes = {
locaName: PropTypes.string,
setAppId: PropTypes.func,
setLocoName: PropTypes.func,
setPage: PropTypes.func,
placeholder: PropTypes.string,
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};
export default LocationAutocomplete;

View File

@@ -1,5 +1,10 @@
import PropTypes from 'prop-types';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { Card } from '@astryxdesign/core/Card';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { DT } from 'themes/dt/tokens';
// ============================================================================
// MobileCard — shared primitives that turn a desktop data-table row into an
@@ -9,66 +14,59 @@ import { Box, Paper, Stack, Typography } from '@mui/material';
// just slot content into these shells. Desktop layouts are untouched — these
// only render inside an `isMobile` branch.
//
// Tokens mirror the `DT` block in deliveries.js so cards match page surfaces.
// Astryx note: the old `sx` prop is gone (only `MobileCardList` ever received
// one, always the same `p: 1.25`, now the default padding). The left accent
// rail and the selected-state wash keep an inline `style` because the accent
// is a caller-supplied hex — the same escape hatch StatCard uses.
// ============================================================================
const BORDER = '#e2e8f0';
const MUTED = '#94a3b8';
const PRIMARY_TEXT = '#0f172a';
// Vertical list wrapper — drop-in replacement for <TableContainer>/<TableBody>
// on mobile. `scroll` makes it an internal scroll region (matches the table's
// maxHeight behaviour); omit it to let the page scroll naturally.
export const MobileCardList = ({ children, scroll = false, onScroll, sx, ...rest }) => (
<Stack
spacing={1.25}
export const MobileCardList = ({ children, scroll = false, onScroll }) => (
<VStack
gap={3}
padding={3}
onScroll={onScroll}
sx={{
p: 1.5,
...(scroll && { maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', overflowX: 'hidden' }),
...sx
}}
{...rest}
isScrollable={scroll}
style={scroll ? { maxHeight: 'calc(100vh - 220px)', overflowX: 'hidden' } : undefined}
>
{children}
</Stack>
</VStack>
);
MobileCardList.propTypes = {
children: PropTypes.node,
scroll: PropTypes.bool,
onScroll: PropTypes.func,
sx: PropTypes.object
onScroll: PropTypes.func
};
// Card shell — coloured accent rail on the left, a header slot (status badge /
// title / action buttons), then any field grid / collapse content as children.
export const MobileCard = ({ accent = '#C01227', header, footer, selected = false, onClick, children, sx }) => (
<Paper
elevation={0}
export const MobileCard = ({ accent = DT.brand, header, footer, selected = false, onClick, children }) => (
<Card
padding={0}
elevation="low"
onClick={onClick}
sx={{
style={{
position: 'relative',
overflow: 'hidden',
// Cards live inside a flex-column list; without this, a scroll-capped
// list (maxHeight) would SHRINK each card to a sliver (flex-shrink:1)
// and clip its content instead of scrolling. Keep natural height.
flexShrink: 0,
borderRadius: 2.5,
border: '1px solid',
borderColor: selected ? accent : BORDER,
background: selected ? `${accent}0a` : '#fff',
boxShadow: '0 4px 14px rgba(15,23,42,0.05)',
transition: 'border-color 0.15s, box-shadow 0.15s',
...sx
borderColor: selected ? accent : DT.borderSubtle,
background: selected ? `${accent}0a` : DT.surface,
transition: 'border-color 0.15s, box-shadow 0.15s'
}}
>
<Box sx={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, bgcolor: accent }} />
<Box sx={{ p: 1.5, pl: 2 }}>
<span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, backgroundColor: accent }} />
<VStack gap={0} padding={3} style={{ paddingLeft: 16 }}>
{header}
{children}
{footer}
</Box>
</Paper>
</VStack>
</Card>
);
MobileCard.propTypes = {
@@ -77,58 +75,52 @@ MobileCard.propTypes = {
footer: PropTypes.node,
selected: PropTypes.bool,
onClick: PropTypes.func,
children: PropTypes.node,
sx: PropTypes.object
children: PropTypes.node
};
// Grid wrapper for MobileField cells. Two columns by default; pass `columns`
// to change. Keeps every card's body alignment identical.
export const MobileFieldGrid = ({ children, columns = 2, sx }) => (
<Box
sx={{
export const MobileFieldGrid = ({ children, columns = 2 }) => (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 1,
mt: 1.25,
...sx
gap: 'var(--spacing-2)',
marginTop: 'var(--spacing-3)'
}}
>
{children}
</Box>
</div>
);
MobileFieldGrid.propTypes = {
children: PropTypes.node,
columns: PropTypes.number,
sx: PropTypes.object
columns: PropTypes.number
};
// A single label/value cell. `full` makes it span the whole row; `value` can be
// a string/number or any node (chip, stack, etc.).
export const MobileField = ({ label, value, children, full = false, align = 'left' }) => (
<Box sx={{ gridColumn: full ? '1 / -1' : 'auto', minWidth: 0, textAlign: align }}>
<Typography
sx={{
fontSize: 9.5,
fontWeight: 800,
letterSpacing: 0.5,
textTransform: 'uppercase',
color: MUTED,
lineHeight: 1.4
}}
<div style={{ gridColumn: full ? '1 / -1' : 'auto', minWidth: 0, textAlign: align }}>
<Text
display="block"
size="3xs"
weight="bold"
color="secondary"
style={{ letterSpacing: 0.5, textTransform: 'uppercase', lineHeight: 1.4 }}
>
{label}
</Typography>
<Box sx={{ mt: 0.25, minWidth: 0 }}>
</Text>
<div style={{ marginTop: 2, minWidth: 0 }}>
{children !== undefined ? (
children
) : (
<Typography sx={{ fontSize: 13, fontWeight: 600, color: PRIMARY_TEXT }} noWrap>
<Text display="block" weight="semibold" maxLines={1}>
{value ?? '—'}
</Typography>
</Text>
)}
</Box>
</Box>
</div>
</div>
);
MobileField.propTypes = {

View File

@@ -1,45 +1,39 @@
import PropTypes from 'prop-types';
import { Box, Stack, Typography } from '@mui/material';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Heading } from '@astryxdesign/core/Heading';
import { Text } from '@astryxdesign/core/Text';
import { StatusDot } from '@astryxdesign/core/StatusDot';
// ==============================|| PAGE HEADER (Doormile-style clean title) ||============================== //
// A plain, un-boxed page title + optional live subtitle + an action slot
// (zone selector, primary button, etc.). Replaces the heavy boxed/gradient
// header so every operator page opens with a calm, corporate heading.
//
// Astryx: Heading level={1} resolves to --text-heading-1-size (1.5rem), the
// same size the old MUI `variant="h3"` rendered — the visual weight of every
// page title is unchanged by the migration. The old responsive
// `direction={{ xs: 'column', sm: 'row' }}` is now wrap="wrap" on a single
// HStack: the action slot drops to its own line once it can't fit, which is
// the behaviour that breakpoint was approximating.
export default function PageHeader({ title, subtitle, live = false, action }) {
return (
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', sm: 'center' }}
spacing={1.5}
sx={{ mb: { xs: 2, md: 2.5 } }}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h3" sx={{ fontWeight: 700, letterSpacing: '-0.02em', color: 'grey.800' }}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={2}>
<VStack gap={0.5} padding={0} minWidth={0}>
<Heading level={1} maxLines={1}>
{title}
</Typography>
</Heading>
{subtitle && (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ mt: 0.5 }}>
{live && (
<Box
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: 'success.main',
boxShadow: '0 0 0 3px rgba(16,185,129,0.18)'
}}
/>
)}
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
{subtitle}
</Typography>
</Stack>
<HStack gap={1} vAlign="center">
{live && <StatusDot variant="success" label="Live" isPulsing />}
<Text type="supporting">{subtitle}</Text>
</HStack>
)}
</Box>
{action && <Box sx={{ flexShrink: 0, width: { xs: '100%', sm: 'auto' } }}>{action}</Box>}
</Stack>
</VStack>
{action && <HStack gap={1.5}>{action}</HStack>}
</HStack>
);
}

View File

@@ -1,68 +1,58 @@
import PropTypes from 'prop-types';
import { Avatar, Box, Card, CardContent, Skeleton, Stack, Typography } from '@mui/material';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { Skeleton } from '@astryxdesign/core/Skeleton';
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== //
// Clean metric card: muted label, large value, a single soft-tinted rounded
// avatar holding the icon. No coloured top-stripe, no rainbow — colour appears
// only in the small avatar so a row of cards reads calm and corporate.
// tile holding the icon. No coloured top-stripe, no rainbow — colour appears
// only in the small tile so a row of cards reads calm and corporate.
//
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
// `color` is a hex accent (defaults to brand purple); `caption` is the small
// `color` is a hex accent (defaults to brand red); `caption` is the small
// muted line under the value (e.g. "96% of total").
//
// Astryx note: the icon tile keeps an inline `style` because the accent is a
// per-call hex from the caller (each KPI tile carries its own semantic status
// colour — see CLAUDE.md's status palette), which Astryx's fixed `variant`
// and semantic `color` enums can't express. Same escape hatch login.js uses
// for the brand gradient panel.
export default function StatCard({ title, value, icon, color = '#C01227', caption, loading = false }) {
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
return (
<Card sx={{ height: '100%' }}>
<CardContent sx={{ p: { xs: 1.75, md: 2 }, '&:last-child': { pb: { xs: 1.75, md: 2 } } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={1}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: 'text.secondary',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{title}
</Typography>
{loading ? (
<Skeleton variant="rounded" sx={{ width: 72, height: 30, mt: 0.5 }} animation="wave" />
) : (
<Typography
sx={{
mt: 0.25,
fontWeight: 700,
letterSpacing: '-0.015em',
color: 'grey.800',
lineHeight: 1.15,
fontSize: { xs: '1.375rem', md: '1.5rem' },
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{value}
</Typography>
)}
{caption && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
{caption}
</Typography>
)}
</Box>
{icon && (
<Avatar
variant="rounded"
sx={{ width: 42, height: 42, borderRadius: 2, bgcolor: `${color}14`, color, flexShrink: 0 }}
>
{icon}
</Avatar>
<Card height="100%" padding={3}>
<HStack justify="between" vAlign="center" gap={1}>
<VStack gap={0.5} padding={0} minWidth={0} width="100%">
<Text type="supporting" maxLines={1}>
{title}
</Text>
{loading ? (
<Skeleton width={72} height={30} radius={2} />
) : (
<Text size="2xl" weight="bold" display="block" maxLines={1} hasTabularNumbers>
{value}
</Text>
)}
</Stack>
</CardContent>
{caption && (
<Text type="supporting" size="sm" display="block">
{caption}
</Text>
)}
</VStack>
{icon && (
<Center
width={42}
height={42}
style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}
>
{icon}
</Center>
)}
</HStack>
</Card>
);
}

View File

@@ -1,6 +1,10 @@
import React from 'react';
import { TableRow, TableCell, Skeleton } from '@mui/material';
import PropTypes from 'prop-types';
import { TableRow, TableCell } from '@astryxdesign/core/Table';
import { Skeleton } from '@astryxdesign/core/Skeleton';
// Placeholder rows for a loading table. Renders into an Astryx <TableBody>, so
// the host page's table must already be on Astryx's Table.
export default function TableLoader({ rows = 5, columns = 6, height = 40 }) {
return (
<>
@@ -8,7 +12,7 @@ export default function TableLoader({ rows = 5, columns = 6, height = 40 }) {
<TableRow key={rowIndex}>
{[...Array(columns)].map((_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="rectangular" height={height} width={70} animation="wave" />
<Skeleton width={70} height={height} radius={2} index={colIndex} />
</TableCell>
))}
</TableRow>
@@ -16,3 +20,9 @@ export default function TableLoader({ rows = 5, columns = 6, height = 40 }) {
</>
);
}
TableLoader.propTypes = {
rows: PropTypes.number,
columns: PropTypes.number,
height: PropTypes.number
};