updates on the astryx desing

This commit is contained in:
2026-08-14 16:09:14 +05:30
parent d3dd687a8d
commit a46212f291
106 changed files with 10244 additions and 24738 deletions

View File

@@ -3,24 +3,13 @@
"version": "2.1.0", "version": "2.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
"@astryxdesign/core": "^0.1.9", "@astryxdesign/core": "^0.1.9",
"@astryxdesign/theme-neutral": "^0.1.9", "@astryxdesign/theme-neutral": "^0.1.9",
"@emotion/cache": "^11.10.7",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/base": "^5.0.0-beta.70",
"@mui/icons-material": "^5.14.19",
"@mui/lab": "^5.0.0-alpha.127",
"@mui/material": "^5.12.1",
"@mui/x-date-pickers": "^7.29.4",
"@reduxjs/toolkit": "^2.12.0", "@reduxjs/toolkit": "^2.12.0",
"@stylexjs/stylex": "^0.19.0", "@stylexjs/stylex": "^0.19.0",
"@svgr/webpack": "^7.0.0", "@svgr/webpack": "^7.0.0",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"antd": "^5.11.5",
"autosuggest-highlight": "^3.3.4",
"axios": "^1.3.5", "axios": "^1.3.5",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"chance": "^1.1.11", "chance": "^1.1.11",
@@ -60,7 +49,6 @@
"simplebar": "^6.2.5", "simplebar": "^6.2.5",
"simplebar-react": "^3.2.4", "simplebar-react": "^3.2.4",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"stylis-plugin-rtl": "^2.1.1",
"use-debounce": "^10.0.5", "use-debounce": "^10.0.5",
"util": "^0.12.5", "util": "^0.12.5",
"web-vitals": "^3.3.1", "web-vitals": "^3.3.1",

View File

@@ -1,8 +1,6 @@
// project import // project import
import Routes from 'routes'; import Routes from 'routes';
import ThemeCustomization from 'themes';
import Locales from 'components/Locales'; import Locales from 'components/Locales';
// import RTLLayout from 'components/RTLLayout';
import ScrollTop from 'components/ScrollTop'; import ScrollTop from 'components/ScrollTop';
import Notistack from 'components/third-party/Notistack'; import Notistack from 'components/third-party/Notistack';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -46,24 +44,20 @@ const App = () => {
return ( return (
<> <>
<ThemeCustomization> <InternetStatus />
<InternetStatus /> <Locales>
{/* <RTLLayout> */} <ScrollTop>
<Locales> {/* <AuthProvider> */}
<ScrollTop> <>
{/* <AuthProvider> */} <Notistack>
<> {/* <Routes />
<Notistack> <Snackbar /> */}
{/* <Routes /> <AppContent />
<Snackbar /> */} </Notistack>
<AppContent /> </>
</Notistack> {/* </AuthProvider> */}
</> </ScrollTop>
{/* </AuthProvider> */} </Locales>
</ScrollTop>
</Locales>
{/* </RTLLayout> */}
</ThemeCustomization>
</> </>
); );
}; };

View File

@@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import { useEffect } from 'react';
// material-ui
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
// third-party
import rtlPlugin from 'stylis-plugin-rtl';
// project import
import { ThemeDirection } from 'config';
import useConfig from 'hooks/useConfig';
// ==============================|| RTL LAYOUT ||============================== //
const RTLLayout = ({ children }) => {
const { themeDirection } = useConfig();
useEffect(() => {
document.dir = themeDirection;
}, [themeDirection]);
const cacheRtl = createCache({
key: themeDirection === ThemeDirection.RTL ? 'rtl' : 'css',
prepend: true,
stylisPlugins: themeDirection === ThemeDirection.RTL ? [rtlPlugin] : []
});
return <CacheProvider value={cacheRtl}>{children}</CacheProvider>;
};
RTLLayout.propTypes = {
children: PropTypes.node
};
export default RTLLayout;

View File

@@ -8,9 +8,12 @@ import { Text } from '@astryxdesign/core/Text';
import { Skeleton } from '@astryxdesign/core/Skeleton'; import { Skeleton } from '@astryxdesign/core/Skeleton';
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== // // ==============================|| STAT / KPI CARD (Doormile-style) ||============================== //
// Clean metric card: muted label, large value, a single soft-tinted rounded // Clean metric card: muted eyebrow label, large tabular value, a single
// tile holding the icon. No coloured top-stripe, no rainbow — colour appears // soft-tinted rounded tile holding the icon (with a faint matching ring for
// only in the small tile so a row of cards reads calm and corporate. // depth). A low resting elevation lifts the tile off the page instead of
// reading as a flat bordered box. No coloured top-stripe, no rainbow —
// colour appears only in the icon tile so a row of cards reads calm and
// corporate.
// //
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}. // `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
// `color` is a hex accent (defaults to brand red); `caption` is the small // `color` is a hex accent (defaults to brand red); `caption` is the small
@@ -24,16 +27,16 @@ import { Skeleton } from '@astryxdesign/core/Skeleton';
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) { export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
return ( return (
<Card height="100%" padding={3}> <Card height="100%" padding={2} elevation="low">
<HStack justify="between" vAlign="center" gap={1}> <HStack justify="between" vAlign="center" gap={2}>
<VStack gap={0.5} padding={0} minWidth={0} width="100%"> <VStack gap={0.5} padding={0} minWidth={0} width="100%">
<Text type="supporting" maxLines={1}> <Text type="supporting" size="xsm" weight="semibold" maxLines={1}>
{title} {title}
</Text> </Text>
{loading ? ( {loading ? (
<Skeleton width={72} height={30} radius={2} /> <Skeleton width={60} height={26} radius={2} />
) : ( ) : (
<Text size="2xl" weight="bold" display="block" maxLines={1} hasTabularNumbers> <Text size="xl" weight="bold" display="block" maxLines={1} hasTabularNumbers>
{value} {value}
</Text> </Text>
)} )}
@@ -45,9 +48,15 @@ export default function StatCard({ title, value, icon, color = '#000000', captio
</VStack> </VStack>
{icon && ( {icon && (
<Center <Center
width={42} width={36}
height={42} height={36}
style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }} style={{
flexShrink: 0,
borderRadius: 'var(--radius-element)',
backgroundColor: `${color}16`,
color,
border: `1px solid ${color}2A`
}}
> >
{icon} {icon}
</Center> </Center>

41
src/globalPolish.css Normal file
View File

@@ -0,0 +1,41 @@
/* Global visual polish that used to come from MUI's <CssBaseline>/<GlobalStyles>
in themes/index.js (ThemeCustomization). Kept as plain CSS now that every
page runs on Astryx — see root CLAUDE.md §6.6. Astryx's own reset
(@astryxdesign/core/reset.css, imported in index.js) covers the base
element resets; this file only carries the two enterprise-polish touches
that reset doesn't: crisp font rendering and a thin, unobtrusive scrollbar. */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
* {
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgba(0, 0, 0, 0.28) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background-color: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.26);
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.45);
}
*::-webkit-scrollbar-corner {
background-color: transparent;
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect } from 'react';
// Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))` across the
// Astryx-converted pages. MUI's `md` breakpoint is 900px and `down()` is
// exclusive, so this is the identical `max-width: 899.95px` query it used to
// generate. First introduced in `pages/nearle/dispatch/dispatchShared.js`;
// lifted here once more than one feature folder needed it.
const NARROW_VIEWPORT_QUERY = '(max-width: 899.95px)';
export function useIsNarrowViewport() {
const [isNarrow, setIsNarrow] = useState(
() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(NARROW_VIEWPORT_QUERY).matches
);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mql = window.matchMedia(NARROW_VIEWPORT_QUERY);
const onChange = (e) => setIsNarrow(e.matches);
setIsNarrow(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isNarrow;
}
export default useIsNarrowViewport;

View File

@@ -13,6 +13,7 @@ import 'simplebar/dist/simplebar.css';
// Doormile brand theme applied per-page during migration). // Doormile brand theme applied per-page during migration).
import '@astryxdesign/core/reset.css'; import '@astryxdesign/core/reset.css';
import '@astryxdesign/core/astryx.css'; import '@astryxdesign/core/astryx.css';
import './globalPolish.css';
import { LinkProvider } from '@astryxdesign/core/Link'; import { LinkProvider } from '@astryxdesign/core/Link';
// apex-chart // apex-chart

View File

@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { OpenToast } from 'components/third-party/OpenToast'; import { OpenToast } from 'components/third-party/OpenToast';
import logger from 'utils/logger'; import logger from 'utils/logger';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { import {
getMilers, getMilers,
getMiler, getMiler,
@@ -518,7 +519,20 @@ export const notifyRider = async (
// deliveries.js itself doesn't need a full rewrite — same compatibility-shim // deliveries.js itself doesn't need a full rewrite — same compatibility-shim
// approach as fetchAllRiders' `{details}` wrapper. // approach as fetchAllRiders' `{details}` wrapper.
export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
const [, , , , , , rowsPerPage] = queryKey; // Every caller builds the key as
// [name, appId, userid, status, startdate, enddate, rowsPerPage, …]
// (Dispatch.js, deliveries.js, reports/ordersDetails.js,
// reports/profitability.js — all four verified). Only rowsPerPage used to be
// read here, which meant startdate/enddate were accepted and then ignored:
// changing the date on the Dispatch page rebuilt the query key and refetched,
// but issued an identical request, so the row set — and every batch count
// derived from it — was byte-identical for today, yesterday and last week.
//
// GET /admin/bookings takes only pageno/pagesize (doormileApi.js; no date
// parameter is documented in express-console-api.md and guessing one risks a
// silent 400 or, worse, a silently-ignored filter), so the range is applied
// client-side below, after the rows are normalised.
const [, , , , startdate, enddate, rowsPerPage] = queryKey;
// Unlike the 3 joins below (customers/milers/tenants — each individually // Unlike the 3 joins below (customers/milers/tenants — each individually
// guarded so a failed join just degrades a display field, not the whole // guarded so a failed join just degrades a display field, not the whole
// page), a failed bookings call is the one thing this function can't // page), a failed bookings call is the one thing this function can't
@@ -632,8 +646,30 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
}; };
}); });
// Apply the requested date range to the booking's CREATION day. This has to
// agree with what the batch bucketing reads (Dispatch.js's
// BATCH_TIME_FIELD / deliveries.js's BATCH_TIME_KEYS, both `orderdate`) —
// filtering on one field while bucketing on another is how you get a row
// that is counted for the day but belongs to no batch in it.
//
// parseDoormileTimestamp, not bare dayjs(): some Doormile timestamps carry a
// false trailing Z, and near midnight an unstripped one shifts the row +5:30
// into the next calendar day, dropping it from the selected date.
//
// A missing/blank bound means "unbounded on that side", which preserves the
// old behaviour for any caller that doesn't pass real dates.
const inRange = (row) => {
if (!startdate && !enddate) return true;
const t = parseDoormileTimestamp(row.orderdate);
if (!t.isValid()) return false;
const day = t.format('YYYY-MM-DD');
if (startdate && day < String(startdate)) return false;
if (enddate && day > String(enddate)) return false;
return true;
};
return { return {
rows, rows: rows.filter(inRange),
// Whether to fetch another page must be based on the RAW bookings page // Whether to fetch another page must be based on the RAW bookings page
// (bookings.length), not the post-filter `rows.length` — most bookings on // (bookings.length), not the post-filter `rows.length` — most bookings on
// any given page are still pending, not dispatched, so the filtered count // any given page are still pending, not dispatched, so the filtered count
@@ -764,12 +800,21 @@ export const getallridersummary = async () => {
// A miler has no `status` field — availabilitystatus is the real one // A miler has no `status` field — availabilitystatus is the real one
// (confirmed live: Available/Assigned/On_Pickup/Offline/Blocked). // (confirmed live: Available/Assigned/On_Pickup/Offline/Blocked).
// "Active" here means not Offline and not Blocked, matching riders.js's // "Active" here means not Offline and not Blocked, matching riders.js's
// own tab filter. // own tab filter. `available`/`onDelivery` are a KPI-card-friendly
// breakdown of that same "active" bucket — Available is free/idle,
// On Delivery covers both Assigned (accepted, not yet picked up) and
// On_Pickup (en route) — computed separately so an unrecognised status
// value still falls into "active" (unchanged behaviour) without also
// silently inflating one of the two new buckets.
const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length; const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
return { total: milers.length, active, inactive: milers.length - active }; const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length;
const onDelivery = milers.filter((m) =>
['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())
).length;
return { total: milers.length, active, inactive: milers.length - active, available, onDelivery };
} catch (err) { } catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000); OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000);
return { total: 0, active: 0, inactive: 0 }; return { total: 0, active: 0, inactive: 0, available: 0, onDelivery: 0 };
} }
}; };

View File

@@ -1,42 +1,30 @@
import React, { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md'; import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi'; import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack'; import { DT } from 'themes/dt/tokens';
import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/tokens'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================ // ============================================================================
// GET /admin/users confirmed live: { id, email, first_name, phone, role, // GET /admin/users confirmed live: { id, email, first_name, phone, role,
@@ -48,15 +36,18 @@ import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/toke
// to match the GET shape. // to match the GET shape.
// ============================================================================ // ============================================================================
const BRAND = '#000000'; const BRAND = DT.brand;
const ROLES = ['admin', 'manager', 'executive', 'rep']; const ROLES = ['admin', 'manager', 'executive', 'rep'];
const ROLE_OPTIONS = ROLES.map((r) => ({ value: r, label: r.charAt(0).toUpperCase() + r.slice(1) }));
const STATUS_OPTIONS = [
{ value: 'Active', label: 'Active' },
{ value: 'Inactive', label: 'Inactive' }
];
const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '—'); const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '—');
const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' }; const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
const AppUsers = () => { const AppUsers = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
@@ -139,6 +130,8 @@ const AppUsers = () => {
saveMutation.mutate({ id, data }); saveMutation.mutate({ id, data });
}; };
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
return ( return (
<> <>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />} {(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
@@ -147,162 +140,146 @@ const AppUsers = () => {
title="App Users" title="App Users"
subtitle="Live · Staff Logins" subtitle="Live · Staff Logins"
live live
action={ action={<Button label="New User" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
New User
</Button>
}
/> />
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}> <Grid columns={{ minWidth: 220, max: 2, repeat: 'fit' }} gap={3}>
<Grid item xs={6}> <StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} />
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} /> <StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
</Grid>
<Grid item xs={6}>
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
</Grid>
</Grid> </Grid>
<Paper <VStack gap={0} padding={0} style={{ marginTop: 16 }}>
elevation={0} <Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
sx={{ mt: { xs: 1.5, md: 2 }, p: { xs: 1, md: 1.5 }, borderTopLeftRadius: DT.radiusCard / 8, borderTopRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, borderBottom: 0, background: '#fff' }} <HStack justify="between" vAlign="center" gap={3} wrap="wrap">
> <Text weight="bold">
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}> {users.length} total · {rows.length} shown
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}> </Text>
{users.length} total · {rows.length} shown <div style={{ width: 280, maxWidth: '100%' }}>
</Typography> <DebounceSearchBar
<Box sx={{ width: { xs: '100%', sm: 280 } }}> value={searchword}
<DebounceSearchBar onChange={setSearchword}
value={searchword} onDebouncedChange={setDebouncedSearch}
onChange={setSearchword} placeholder="Search staff (ctrl+k)"
onDebouncedChange={setDebouncedSearch} />
placeholder="Search staff (ctrl+k)" </div>
/> </HStack>
</Box> </Card>
</Stack>
</Paper>
<Paper elevation={0} sx={{ borderBottomLeftRadius: DT.radiusCard / 8, borderBottomRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, overflow: 'hidden', background: '#fff' }}> <Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' }, ...tableScrollSx }}> <TableScroll minWidth={880}>
<Table stickyHeader sx={{ minWidth: isMobile ? 820 : 880 }}> <Table density="balanced" dividers="rows" hasHover>
<TableHead> <TableHeader>
<TableRow sx={tableHeadSx}>
<TableCell>#</TableCell>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Phone</TableCell>
<TableCell>Role</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <OrdersTableSkeleton col={4} />}
{rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} sx={{ py: 6 }}> <TableHeaderCell>#</TableHeaderCell>
<Stack alignItems="center" spacing={1.5}> <TableHeaderCell>Name</TableHeaderCell>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}> <TableHeaderCell>Email</TableHeaderCell>
<MdOutlineAdminPanelSettings size={28} /> <TableHeaderCell>Phone</TableHeaderCell>
</Avatar> <TableHeaderCell>Role</TableHeaderCell>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <TableHeaderCell>Status</TableHeaderCell>
No staff users to show <TableHeaderCell>Actions</TableHeaderCell>
</Typography>
</Stack>
</TableCell>
</TableRow> </TableRow>
) : ( </TableHeader>
rows.map((row, index) => ( <TableBody>
<TableRow key={row.id || index} sx={tableRowSx}> {isLoading && <TableLoader rows={5} columns={7} />}
<TableCell>{index + 1}</TableCell> {rows.length === 0 && !isLoading ? (
<TableCell> <TableRow>
<Typography variant="body2" sx={{ fontWeight: 700 }}> <TableCell colSpan={7}>
{row.first_name} <EmptyState
</Typography> title="No staff users to show"
</TableCell> description="Create a staff login to give someone console access."
<TableCell>{row.email}</TableCell> icon={<MdOutlineAdminPanelSettings size={28} />}
<TableCell>{row.phone || '—'}</TableCell> />
<TableCell>{roleLabel(row.role)}</TableCell>
<TableCell>{row.status || '—'}</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Edit user">
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
<MdEdit size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Delete user">
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
<MdDeleteOutline size={16} />
</IconButton>
</Tooltip>
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} rows.map((row, index) => (
</TableBody> <TableRow key={row.id || index}>
</Table> <TableCell>{index + 1}</TableCell>
</TableContainer> <TableCell>
</Paper> <Text weight="bold">{row.first_name}</Text>
</TableCell>
<TableCell>{row.email}</TableCell>
<TableCell>{row.phone || '—'}</TableCell>
<TableCell>{roleLabel(row.role)}</TableCell>
<TableCell>{row.status || '—'}</TableCell>
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Edit user"
tooltip="Edit user"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete user"
tooltip="Delete user"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth> <Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={480} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>{form.id ? 'Edit User' : 'New Staff User'}</DialogTitle> <Layout
<DialogContent> header={
<Grid container spacing={2} sx={{ mt: 0.5 }}> <DialogHeader
<Grid item xs={12}> title={form.id ? 'Edit User' : 'New Staff User'}
<Stack spacing={1}> subtitle={form.id ? `Updating "${form.first_name || 'user'}"` : 'Add a staff login'}
<InputLabel>Name</InputLabel> onOpenChange={(next) => !next && setDialogOpen(false)}
<TextField value={form.first_name} onChange={(e) => setForm((f) => ({ ...f, first_name: e.target.value }))} /> startContent={
</Stack> <AccentAvatar color={BRAND} selected size={36}>
</Grid> <MdOutlineAdminPanelSettings size={18} />
<Grid item xs={12}> </AccentAvatar>
<Stack spacing={1}> }
<InputLabel>Email</InputLabel> />
<TextField type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} /> }
</Stack> content={
</Grid> <LayoutContent padding={4}>
<Grid item xs={12}> <VStack gap={3} padding={0}>
<Stack spacing={1}> <TextInput label="Name" value={form.first_name} onChange={set('first_name')} isRequired />
<InputLabel>Phone</InputLabel> <TextInput label="Email" type="email" value={form.email} onChange={set('email')} isRequired />
<TextField value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} /> <TextInput label="Phone" value={form.phone} onChange={set('phone')} isOptional />
</Stack> <TextInput
</Grid> label={form.id ? 'New Password (leave blank to keep unchanged)' : 'Password'}
<Grid item xs={12}> type="password"
<Stack spacing={1}> value={form.password}
<InputLabel>{form.id ? 'New Password (leave blank to keep unchanged)' : 'Password'}</InputLabel> onChange={set('password')}
<TextField type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} /> isRequired={!form.id}
</Stack> isOptional={!!form.id}
</Grid> />
<Grid item xs={12} sm={6}> <Grid columns={{ minWidth: 160 }} gap={3}>
<Stack spacing={1}> <Selector label="Role" options={ROLE_OPTIONS} value={form.role} onChange={set('role')} />
<InputLabel>Role</InputLabel> <Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
<Select fullWidth value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value }))}> </Grid>
{ROLES.map((r) => ( </VStack>
<MenuItem key={r} value={r}> </LayoutContent>
{roleLabel(r)} }
</MenuItem> footer={
))} <LayoutFooter hasDivider>
</Select> <HStack gap={2} justify="end">
</Stack> <Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={saveMutation.isPending} />
</Grid> <Button
<Grid item xs={12} sm={6}> label={form.id ? 'Save Changes' : 'Create'}
<Stack spacing={1}> variant="primary"
<InputLabel>Status</InputLabel> onClick={handleSave}
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}> isLoading={saveMutation.isPending}
<MenuItem value="Active">Active</MenuItem> />
<MenuItem value="Inactive">Inactive</MenuItem> </HStack>
</Select> </LayoutFooter>
</Stack> }
</Grid> />
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
{form.id ? 'Save Changes' : 'Create'}
</Button>
</DialogActions>
</Dialog> </Dialog>
</> </>
); );

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,28 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
// material-ui
import {
Autocomplete,
Avatar,
Button,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdPersonAddAlt1 } from 'react-icons/md';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { MdPersonAddAlt1 } from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Divider } from '@astryxdesign/core/Divider';
// project import
import MainCard from 'components/MainCard';
import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import { enqueueSnackbar } from 'notistack'; import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
import { useNavigate } from 'react-router'; import { DT } from 'themes/dt/tokens';
import { DT, tint } from 'themes/dt/tokens'; import { AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
// Same field set as nearle_console_express's createCustomer.js (Location, // Same field set as nearle_console_express's createCustomer.js (Location,
// Choose Client, Name, Phone, Email, Door No, Address, Location/City/State/ // Choose Client, Name, Phone, Email, Door No, Address, Location/City/State/
@@ -43,8 +38,6 @@ import { DT, tint } from 'themes/dt/tokens';
// ignored server-side — unconfirmed, this endpoint isn't in the "confirmed // ignored server-side — unconfirmed, this endpoint isn't in the "confirmed
// end-to-end" list. // end-to-end" list.
const Createcustomer = () => { const Createcustomer = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const loggedInTenantId = localStorage.getItem('tenantid'); const loggedInTenantId = localStorage.getItem('tenantid');
@@ -66,11 +59,12 @@ const Createcustomer = () => {
const [appLocaLat, setAppLocaLat] = useState(); const [appLocaLat, setAppLocaLat] = useState();
const [appLocaLng, setAppLocaLng] = useState(); const [appLocaLng, setAppLocaLng] = useState();
const { data: tenantList = [], isLoading: tenantListLoading } = useQuery({ const { data: tenantList = [] } = useQuery({
queryKey: ['admin-tenants-picker'], queryKey: ['admin-tenants-picker'],
queryFn: getAdminTenants, queryFn: getAdminTenants,
enabled: isStaffLogin enabled: isStaffLogin
}); });
const tenantOptions = (tenantList || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
// LocationAutocomplete only reports back applocationid/locationname — it // LocationAutocomplete only reports back applocationid/locationname — it
// caches the full hub list (with lat/lng) in localStorage under a // caches the full hub list (with lat/lng) in localStorage under a
@@ -125,13 +119,8 @@ const Createcustomer = () => {
}); });
}; };
const opentoast = (message) => { const opentoast = (message) =>
enqueueSnackbar(message, { enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
const createCustomerMutation = useMutation({ const createCustomerMutation = useMutation({
mutationFn: (payload) => createTenantCustomer(payload), mutationFn: (payload) => createTenantCustomer(payload),
@@ -147,9 +136,7 @@ const Createcustomer = () => {
opentoast(res.message || 'Could not create customer'); opentoast(res.message || 'Could not create customer');
} }
}, },
onError: (err) => { onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create customer')
opentoast(err.response?.data?.message || err.message || 'Failed to create customer');
}
}); });
const createprofile = () => { const createprofile = () => {
@@ -174,238 +161,86 @@ const Createcustomer = () => {
<> <>
{createCustomerMutation.isPending && <Loader />} {createCustomerMutation.isPending && <Loader />}
<Grid item xs={12} sx={{ mb: 2 }}> <PageHeader
<Paper title="Create Customer"
sx={{ subtitle="Add a customer record"
p: 2.5, action={
borderRadius: DT.radiusCard + 'px', <AccentAvatar color={BRAND} selected size={40}>
boxShadow: DT.shadowSoft, <MdPersonAddAlt1 size={20} />
border: '1px solid', </AccentAvatar>
borderColor: DT.borderSubtle, }
background: `linear-gradient(135deg, ${tint('#000000')} 0%, ${tint('#3A3A3A')} 100%)` />
}}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 48, height: 48, bgcolor: DT.brand }}>
<MdPersonAddAlt1 size={22} />
</Avatar>
<Typography variant="h3">Create Customer</Typography>
</Stack>
</Paper>
</Grid>
<MainCard
padding={6}
>
<Grid container spacing={isMobile ? 2 : 3}>
<Grid item xs={12}>
<MainCard height="100%" padding={5}>
<Grid container spacing={isMobile ? 2 : 3}>
{/* ===================================================== || Location & Client || ===================================================== */}
<Grid item xs={12} md={6}>
<Stack spacing={1.25}>
<InputLabel>Location</InputLabel>
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
</Stack>
</Grid>
{isStaffLogin && (
<Grid item xs={12} md={6}>
<Stack spacing={1.25}>
<InputLabel>Choose Client</InputLabel>
<Autocomplete
fullWidth
loading={tenantListLoading}
options={tenantList || []}
getOptionLabel={(option) => option?.tenantname || ''}
renderInput={(params) => <TextField {...params} placeholder="Choose Client" />}
onChange={(e, val) => setTid(val ? val.tenantid : '')}
/>
</Stack>
</Grid>
)}
{/* ===================================================== || Name / Phone / Email || ===================================================== */} <Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<Grid item xs={12} sm={6}> <VStack gap={4} padding={0}>
<Stack spacing={1.25}> <Grid columns={{ minWidth: 260 }} gap={3}>
<InputLabel htmlFor="customer-firstname">Name</InputLabel> <VStack gap={1.25} padding={0}>
<TextField <Text type="label" weight="semibold" color="secondary">
fullWidth Location
id="customer-firstname" </Text>
placeholder="First Name" <LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
onChange={(e) => setFirstname(e.target.value)} </VStack>
value={firstname} {isStaffLogin && (
autoComplete="off" <Selector
/> label="Choose Client"
</Stack> options={tenantOptions}
</Grid> value={tid ? String(tid) : ''}
<Grid item xs={12} sm={6}> onChange={(v) => setTid(v || '')}
<Stack spacing={1.25}> placeholder="Choose Client"
<InputLabel htmlFor="customer-lastname">Last Name</InputLabel> hasSearch
<TextField />
fullWidth )}
id="customer-lastname" <TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
placeholder="Last Name" <TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
onChange={(e) => setLastname(e.target.value)} <TextInput
value={lastname} label="Phone Number"
autoComplete="off" placeholder="Phone Number"
/> value={phone}
</Stack> onChange={(v) => {
</Grid> const digits = v.replace(/\D/g, '');
<Grid item xs={12} sm={6}> if (digits.length <= 10) setPhone(digits);
<Stack spacing={1.25}> }}
<InputLabel htmlFor="customer-phone">Phone Number</InputLabel> isRequired
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}> />
<Select defaultValue="+1" disabled sx={{ cursor: 'not-allowed' }}> <TextInput label="Email" type="email" placeholder="Email Address" value={email} onChange={setEmail} isOptional />
<MenuItem value="+1">+91</MenuItem> <TextInput label="Door No" placeholder="Door No" value={doorno} onChange={setDoorno} isOptional />
</Select>
<TextField
type="number"
id="customer-phone"
fullWidth
placeholder="Phone Number"
onChange={(e) => {
if (e.target.value.toString().length <= 10) {
setPhone(e.target.value);
}
}}
value={phone}
autoComplete="off"
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="customer-email">Email</InputLabel>
<TextField
type="email"
fullWidth
id="customer-email"
placeholder="Email Address"
onChange={(e) => setEmail(e.target.value)}
value={email}
autoComplete="off"
/>
</Stack>
</Grid>
{/* ===================================================== || Door No || ===================================================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="customer-doorno">Door No</InputLabel>
<TextField
fullWidth
id="customer-doorno"
placeholder="Door No"
onChange={(e) => setDoorno(e.target.value)}
value={doorno}
autoComplete="off"
/>
</Stack>
</Grid>
{/* ===================================================== || Address section — collected, not persisted || ===================================================== */}
<Grid item xs={12}>
<Typography variant="caption" sx={{ color: DT.textMuted, fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved on submit.
</Typography>
</Grid>
<Grid item xs={12}>
<Stack spacing={1.25}>
<InputLabel>Address</InputLabel>
<AddressAutocomplete
id="customer-address"
fullWidth
value={inputValue2}
onChange={(text) => setInputValue2(text)}
onPlaceSelected={handlePlaceSelected}
bias={{ lat: appLocaLat, lng: appLocaLng }}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel>Location</InputLabel>
<TextField
fullWidth
placeholder="Location"
onChange={(e) => setPickCust((p) => ({ ...p, suburb: e.target.value }))}
value={pickCust.suburb || ''}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel>City</InputLabel>
<TextField
fullWidth
placeholder="City"
onChange={(e) => setPickCust((p) => ({ ...p, city: e.target.value }))}
value={pickCust.city || ''}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel>State</InputLabel>
<TextField
fullWidth
placeholder="State"
onChange={(e) => setPickCust((p) => ({ ...p, state: e.target.value }))}
value={pickCust.state || ''}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel>Post Code</InputLabel>
<TextField
fullWidth
type="number"
placeholder="Postcode"
onChange={(e) => setPickCust((p) => ({ ...p, postcode: e.target.value }))}
value={pickCust.postcode || ''}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1.25}>
<InputLabel>Landmark</InputLabel>
<TextField fullWidth placeholder="Landmark" onChange={(e) => setLandmark(e.target.value)} value={landmark} autoComplete="off" />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel>Latitude</InputLabel>
<TextField fullWidth disabled value={startPoint.latitude || ''} sx={{ cursor: 'not-allowed' }} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel>Longitude</InputLabel>
<TextField fullWidth disabled value={startPoint.longitude || ''} sx={{ cursor: 'not-allowed' }} />
</Stack>
</Grid>
</Grid>
</MainCard>
</Grid> </Grid>
<Grid item xs={12}>
<Stack <Divider />
direction={{ xs: 'column', sm: 'row' }}
justifyContent="flex-end" <Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
alignItems={{ xs: 'stretch', sm: 'center' }} The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved on submit.
spacing={2} </Text>
>
<Button variant="contained" onClick={createprofile} fullWidth={isMobile}> <AddressAutocomplete
Create id="customer-address"
</Button> label="Address"
</Stack> value={inputValue2}
onChange={setInputValue2}
onPlaceSelected={handlePlaceSelected}
bias={{ lat: appLocaLat, lng: appLocaLng }}
/>
<Grid columns={{ minWidth: 220 }} gap={3}>
<TextInput label="Location" placeholder="Location" value={pickCust.suburb || ''} onChange={(v) => setPickCust((p) => ({ ...p, suburb: v }))} />
<TextInput label="City" placeholder="City" value={pickCust.city || ''} onChange={(v) => setPickCust((p) => ({ ...p, city: v }))} />
<TextInput label="State" placeholder="State" value={pickCust.state || ''} onChange={(v) => setPickCust((p) => ({ ...p, state: v }))} />
<TextInput
label="Post Code"
placeholder="Postcode"
value={pickCust.postcode || ''}
onChange={(v) => setPickCust((p) => ({ ...p, postcode: v.replace(/\D/g, '') }))}
/>
<TextInput label="Landmark" placeholder="Landmark" value={landmark} onChange={setLandmark} />
<TextInput label="Latitude" value={startPoint.latitude || ''} isDisabled />
<TextInput label="Longitude" value={startPoint.longitude || ''} isDisabled />
</Grid> </Grid>
</Grid>
</MainCard> <HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createCustomerMutation.isPending} />
</HStack>
</VStack>
</Card>
</> </>
); );
}; };

View File

@@ -1,36 +1,31 @@
import { useState } from 'react'; import { useState } from 'react';
// material-ui
import {
Avatar,
Button,
FormControlLabel,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Switch,
TextField,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdPersonAddAlt1 } from 'react-icons/md';
import { useMutation } from '@tanstack/react-query';
// project import
import MainCard from 'components/MainCard';
import { createAdminTenant } from 'pages/api/doormileApi';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { DT, tint } from 'themes/dt/tokens'; import { useMutation } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { MdPersonAddAlt1 } from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Switch } from '@astryxdesign/core/Switch';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { createAdminTenant } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
const STATUS_OPTIONS = [
{ value: 'Active', label: 'Active' },
{ value: 'Inactive', label: 'Inactive' }
];
const Createclient = () => { const Createclient = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const [tenantname, setTenantname] = useState(''); const [tenantname, setTenantname] = useState('');
@@ -43,13 +38,8 @@ const Createclient = () => {
const [status, setStatus] = useState('Active'); const [status, setStatus] = useState('Active');
const [requiredeliveryotp, setRequiredeliveryotp] = useState(false); const [requiredeliveryotp, setRequiredeliveryotp] = useState(false);
const opentoast = (message) => { const opentoast = (message) =>
enqueueSnackbar(message, { enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
const createTenantMutation = useMutation({ const createTenantMutation = useMutation({
mutationFn: (payload) => createAdminTenant(payload), mutationFn: (payload) => createAdminTenant(payload),
@@ -65,9 +55,7 @@ const Createclient = () => {
opentoast(res.message || 'Client already exists'); opentoast(res.message || 'Client already exists');
} }
}, },
onError: (err) => { onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create client')
opentoast(err.response?.data?.message || err.message || 'Failed to create client');
}
}); });
const createprofile = () => { const createprofile = () => {
@@ -97,114 +85,45 @@ const Createclient = () => {
<> <>
{createTenantMutation.isPending && <Loader />} {createTenantMutation.isPending && <Loader />}
<Grid item xs={12} sx={{ mb: 2 }}> <PageHeader
<Paper title="Create Client"
sx={{ subtitle="Add a new tenant"
p: 2.5, action={
borderRadius: DT.radiusCard + 'px', <AccentAvatar color={BRAND} selected size={40}>
boxShadow: DT.shadowSoft, <MdPersonAddAlt1 size={20} />
border: '1px solid', </AccentAvatar>
borderColor: DT.borderSubtle, }
background: `linear-gradient(135deg, ${tint('#000000')} 0%, ${tint('#3A3A3A')} 100%)` />
}}
> <Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<Stack direction="row" spacing={1.5} alignItems="center"> <VStack gap={4} padding={0}>
<Avatar sx={{ width: 48, height: 48, bgcolor: DT.brand }}> <Grid columns={{ minWidth: 260 }} gap={3}>
<MdPersonAddAlt1 size={22} /> <TextInput label="Client / Business Name" placeholder="e.g. DailyGrubs" value={tenantname} onChange={setTenantname} isRequired />
</Avatar> <Selector label="Status" options={STATUS_OPTIONS} value={status} onChange={setStatus} />
<Typography variant="h3">Create Client</Typography> <TextInput
</Stack> label="Primary Contact Number"
</Paper> placeholder="Contact Number"
</Grid> value={primarycontact}
<MainCard onChange={(v) => {
padding={6} const digits = v.replace(/\D/g, '');
> if (digits.length <= 10) setPrimarycontact(digits);
<Grid container spacing={isMobile ? 2 : 3}> }}
<Grid item xs={12}> isRequired
<MainCard height="100%" padding={5}> />
<Grid container spacing={isMobile ? 2 : 3}> <TextInput label="Primary Email" type="email" placeholder="Primary Email Address" value={primaryemail} onChange={setPrimaryemail} isRequired />
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="tenant-name">Client / Business Name</InputLabel>
<TextField
fullWidth
id="tenant-name"
placeholder="e.g. DailyGrubs"
onChange={(e) => setTenantname(e.target.value)}
value={tenantname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="tenant-status">Status</InputLabel>
<Select id="tenant-status" fullWidth value={status} onChange={(e) => setStatus(e.target.value)}>
<MenuItem value="Active">Active</MenuItem>
<MenuItem value="Inactive">Inactive</MenuItem>
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="tenant-contact">Primary Contact Number</InputLabel>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Select defaultValue="+1" disabled sx={{ cursor: 'not-allowed' }}>
<MenuItem value="+1">+91</MenuItem>
</Select>
<TextField
type="number"
id="tenant-contact"
fullWidth
placeholder="Contact Number"
onChange={(e) => {
if (e.target.value.toString().length <= 10) {
setPrimarycontact(e.target.value);
}
}}
value={primarycontact}
autoComplete="off"
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="tenant-email">Primary Email</InputLabel>
<TextField
type="email"
fullWidth
id="tenant-email"
placeholder="Primary Email Address"
onChange={(e) => setPrimaryemail(e.target.value)}
value={primaryemail}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={<Switch checked={requiredeliveryotp} onChange={(e) => setRequiredeliveryotp(e.target.checked)} />}
label="Require delivery OTP for this client (off by default)"
/>
</Grid>
</Grid>
</MainCard>
</Grid> </Grid>
<Grid item xs={12}>
<Stack <Switch
direction={{ xs: 'column', sm: 'row' }} label="Require delivery OTP for this client (off by default)"
justifyContent="flex-end" value={requiredeliveryotp}
alignItems={{ xs: 'stretch', sm: 'center' }} onChange={setRequiredeliveryotp}
spacing={2} />
>
<Button variant="contained" onClick={createprofile} fullWidth={isMobile}> <HStack justify="end">
Create <Button label="Create" variant="primary" onClick={createprofile} isLoading={createTenantMutation.isPending} />
</Button> </HStack>
</Stack> </VStack>
</Grid> </Card>
</Grid>
</MainCard>
</> </>
); );
}; };

View File

@@ -1,32 +1,24 @@
import React, { useState } from 'react'; import { useState } from 'react';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
InputLabel,
Pagination,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography
} from '@mui/material';
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md'; import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput';
import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Pagination } from '@astryxdesign/core/Pagination';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import { import {
getCompetitorBranches, getCompetitorBranches,
@@ -38,8 +30,8 @@ import {
updateCarrierPricing, updateCarrierPricing,
deleteCarrierPricing deleteCarrierPricing
} from 'pages/api/doormileApi'; } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack'; import { DT } from 'themes/dt/tokens';
import { DT, soft, tableScrollSx } from 'themes/dt/tokens'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================ // ============================================================================
// Field names confirmed live (create+verify+delete round-trip against // Field names confirmed live (create+verify+delete round-trip against
@@ -59,7 +51,7 @@ import { DT, soft, tableScrollSx } from 'themes/dt/tokens';
// plain text fields, not Number-cast. // plain text fields, not Number-cast.
// ============================================================================ // ============================================================================
const BRAND = '#000000'; const BRAND = DT.brand;
const TABS = [ const TABS = [
{ key: 'branches', label: 'Competitor Branches' }, { key: 'branches', label: 'Competitor Branches' },
@@ -228,6 +220,9 @@ const CompetitiveIntel = () => {
const total = (tab === 'branches' ? branchesRes?.total : carrierRes?.total) || 0; const total = (tab === 'branches' ? branchesRes?.total : carrierRes?.total) || 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const setBranch = (key) => (value) => setBranchForm((f) => ({ ...f, [key]: value }));
const setCarrier = (key) => (value) => setCarrierForm((f) => ({ ...f, [key]: value }));
return ( return (
<> <>
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />} {(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
@@ -237,305 +232,173 @@ const CompetitiveIntel = () => {
subtitle="Live · Market Benchmarking" subtitle="Live · Market Benchmarking"
live live
action={ action={
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}> <Button
{tab === 'branches' ? 'New Branch' : 'New Carrier Rate'} label={tab === 'branches' ? 'New Branch' : 'New Carrier Rate'}
</Button> variant="primary"
icon={<MdAdd size={16} />}
onClick={openCreate}
/>
} }
/> />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}> <TabList value={tab} onChange={changeTab} hasDivider>
{TABS.map((t) => ( {TABS.map((t) => (
<Box <Tab key={t.key} value={t.key} label={t.label} />
key={t.key}
onClick={() => changeTab(t.key)}
sx={{
px: 2,
py: 0.75,
borderRadius: 999,
cursor: 'pointer',
fontWeight: 700,
fontSize: 13,
bgcolor: tab === t.key ? BRAND : soft(BRAND),
color: tab === t.key ? '#fff' : BRAND,
border: `1px solid ${tab === t.key ? BRAND : soft(BRAND)}`
}}
>
{t.label}
</Box>
))} ))}
</Stack> </TabList>
<Typography variant="body2" sx={{ color: DT.textSecondary, fontWeight: 700, mb: 1 }}> <Text color="secondary" weight="bold" style={{ marginTop: 12, marginBottom: 8 }}>
{total} total · page {page} of {pageCount} {total} total · page {page} of {pageCount}
</Typography> </Text>
<Paper <Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
elevation={0} <TableScroll minWidth={tab === 'branches' ? 920 : 780}>
sx={{ border: '1px solid', borderColor: DT.borderSubtle, borderRadius: DT.radiusCard / 8 + 'px', overflow: 'hidden', background: '#fff' }} <Table density="balanced" dividers="rows" hasHover>
> <TableHeader>
<TableContainer sx={{ maxHeight: 'calc(100vh - 300px)', ...tableScrollSx }}>
<Table stickyHeader sx={{ minWidth: tab === 'branches' ? 920 : 780 }}>
<TableHead>
{tab === 'branches' ? ( {tab === 'branches' ? (
<TableRow <TableRow>
sx={{ <TableHeaderCell>#</TableHeaderCell>
'& th': { <TableHeaderCell>Company</TableHeaderCell>
backgroundColor: DT.surfaceAlt, <TableHeaderCell>Area</TableHeaderCell>
color: DT.textSecondary, <TableHeaderCell>Phone</TableHeaderCell>
fontSize: 11, <TableHeaderCell>Address</TableHeaderCell>
fontWeight: 800, <TableHeaderCell>Rate/Kg</TableHeaderCell>
textTransform: 'uppercase', <TableHeaderCell>Actions</TableHeaderCell>
borderBottom: `1px solid ${DT.borderSubtle}`
}
}}
>
<TableCell>#</TableCell>
<TableCell>Company</TableCell>
<TableCell>Area</TableCell>
<TableCell>Phone</TableCell>
<TableCell>Address</TableCell>
<TableCell align="center">Rate/Kg</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow> </TableRow>
) : ( ) : (
<TableRow <TableRow>
sx={{ <TableHeaderCell>#</TableHeaderCell>
'& th': { <TableHeaderCell>Company</TableHeaderCell>
backgroundColor: DT.surfaceAlt, <TableHeaderCell>Weight Slab</TableHeaderCell>
color: DT.textSecondary, <TableHeaderCell>Zone</TableHeaderCell>
fontSize: 11, <TableHeaderCell>Rate</TableHeaderCell>
fontWeight: 800, <TableHeaderCell>Actions</TableHeaderCell>
textTransform: 'uppercase',
borderBottom: `1px solid ${DT.borderSubtle}`
}
}}
>
<TableCell>#</TableCell>
<TableCell>Company</TableCell>
<TableCell>Weight Slab</TableCell>
<TableCell>Zone</TableCell>
<TableCell align="center">Rate</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow> </TableRow>
)} )}
</TableHead> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={7} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} sx={{ py: 6 }}> <TableCell colSpan={7}>
<Stack alignItems="center" spacing={1.5}> <EmptyState title="No records to show" description="Add a record to start benchmarking against it." icon={<MdRadar size={28} />} />
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdRadar size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No records to show
</Typography>
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( rows.map((row, index) => (
<TableRow <TableRow key={row.id || index}>
key={row.id || index}
sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}
>
<TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell> <TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell>
{tab === 'branches' ? ( {tab === 'branches' ? (
<> <>
<TableCell>{row.company || '—'}</TableCell> <TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.area || '—'}</TableCell> <TableCell>{row.area || '—'}</TableCell>
<TableCell>{row.phone || '—'}</TableCell> <TableCell>{row.phone || '—'}</TableCell>
<TableCell sx={{ maxWidth: 220 }}> <TableCell style={{ maxWidth: 220 }}>
<Typography variant="body2" noWrap title={row.address}> <Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.address}>
{row.address || '—'} {row.address || '—'}
</Typography> </Text>
</TableCell> </TableCell>
<TableCell align="center">{row.rate_per_kg || '—'}</TableCell> <TableCell>{row.rate_per_kg || '—'}</TableCell>
</> </>
) : ( ) : (
<> <>
<TableCell>{row.company || '—'}</TableCell> <TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.weight_slab || '—'}</TableCell> <TableCell>{row.weight_slab || '—'}</TableCell>
<TableCell>{row.zone || '—'}</TableCell> <TableCell>{row.zone || '—'}</TableCell>
<TableCell align="center">{row.rate || '—'}</TableCell> <TableCell>{row.rate || '—'}</TableCell>
</> </>
)} )}
<TableCell align="right"> <TableCell>
<Stack direction="row" spacing={0.5} justifyContent="flex-end"> <HStack gap={1} justify="end">
<Tooltip title="Edit"> <IconButton label="Edit" tooltip="Edit" size="sm" variant="ghost" icon={<MdEdit size={16} />} onClick={() => openEdit(row)} />
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}> <IconButton
<MdEdit size={16} /> label="Delete"
</IconButton> tooltip="Delete"
</Tooltip> size="sm"
<Tooltip title="Delete"> variant="destructive"
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}> icon={<MdDeleteOutline size={16} />}
<MdDeleteOutline size={16} /> onClick={() => handleDelete(row)}
</IconButton> />
</Tooltip> </HStack>
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableScroll>
</Paper> </Card>
{pageCount > 1 && ( {pageCount > 1 && (
<Stack alignItems="center" sx={{ mt: 2 }}> <HStack justify="center" style={{ marginTop: 16 }}>
<Pagination count={pageCount} page={page} onChange={(e, value) => setPage(value)} color="primary" shape="rounded" /> <Pagination page={page} onChange={setPage} totalPages={pageCount} />
</Stack> </HStack>
)} )}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={720} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}> <Layout
{tab === 'branches' ? (branchForm.id ? 'Edit Branch' : 'New Competitor Branch') : carrierForm.id ? 'Edit Carrier Rate' : 'New Carrier Rate'} header={
</DialogTitle> <DialogHeader
<DialogContent> title={tab === 'branches' ? (branchForm.id ? 'Edit Branch' : 'New Competitor Branch') : carrierForm.id ? 'Edit Carrier Rate' : 'New Carrier Rate'}
{tab === 'branches' ? ( onOpenChange={(next) => !next && setDialogOpen(false)}
<Grid container spacing={2} sx={{ mt: 0.5 }}> startContent={
<Grid item xs={12} sm={6}> <AccentAvatar color={BRAND} selected size={36}>
<Stack spacing={1}> <MdRadar size={18} />
<InputLabel>Company</InputLabel> </AccentAvatar>
<TextField value={branchForm.company} onChange={(e) => setBranchForm((f) => ({ ...f, company: e.target.value }))} /> }
</Stack> />
</Grid> }
<Grid item xs={12} sm={6}> content={
<Stack spacing={1}> <LayoutContent padding={4}>
<InputLabel>Area</InputLabel> {tab === 'branches' ? (
<TextField value={branchForm.area} onChange={(e) => setBranchForm((f) => ({ ...f, area: e.target.value }))} /> <Grid columns={{ minWidth: 220 }} gap={3}>
</Stack> <TextInput label="Company" value={branchForm.company} onChange={setBranch('company')} />
</Grid> <TextInput label="Area" value={branchForm.area} onChange={setBranch('area')} />
<Grid item xs={12} sm={6}> <TextInput label="Phone" value={branchForm.phone} onChange={setBranch('phone')} />
<Stack spacing={1}> <TextInput label="Pincodes" value={branchForm.pincodes} onChange={setBranch('pincodes')} />
<InputLabel>Phone</InputLabel> <TextInput label="Address" value={branchForm.address} onChange={setBranch('address')} />
<TextField value={branchForm.phone} onChange={(e) => setBranchForm((f) => ({ ...f, phone: e.target.value }))} /> <TextInput
</Stack> label="Rate / Kg"
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Pincodes</InputLabel>
<TextField value={branchForm.pincodes} onChange={(e) => setBranchForm((f) => ({ ...f, pincodes: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Address</InputLabel>
<TextField value={branchForm.address} onChange={(e) => setBranchForm((f) => ({ ...f, address: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Rate / Kg</InputLabel>
<TextField
value={branchForm.rate_per_kg} value={branchForm.rate_per_kg}
onChange={(e) => setBranchForm((f) => ({ ...f, rate_per_kg: e.target.value }))} onChange={setBranch('rate_per_kg')}
placeholder="e.g. 600, or a note like 'Not Answered'" placeholder="e.g. 600, or a note like 'Not Answered'"
/> />
</Stack> <TextInput label="Time (days)" value={branchForm.time_in_days} onChange={setBranch('time_in_days')} />
</Grid> <TextInput label="Offers Pickup" value={branchForm.offers_pickup} onChange={setBranch('offers_pickup')} placeholder="yes / no" />
<Grid item xs={12} sm={6}> <TextInput label="Offers Drop" value={branchForm.offers_drop} onChange={setBranch('offers_drop')} placeholder="yes / no" />
<Stack spacing={1}> <TextInput label="Packing Charge" value={branchForm.packing_charge} onChange={setBranch('packing_charge')} />
<InputLabel>Time (days)</InputLabel> </Grid>
<TextField value={branchForm.time_in_days} onChange={(e) => setBranchForm((f) => ({ ...f, time_in_days: e.target.value }))} /> ) : (
</Stack> <Grid columns={{ minWidth: 220 }} gap={3}>
</Grid> <TextInput label="Company" value={carrierForm.company} onChange={setCarrier('company')} />
<Grid item xs={12} sm={4}> <TextInput label="Weight Slab" value={carrierForm.weight_slab} onChange={setCarrier('weight_slab')} placeholder="e.g. 500g - 1kg" />
<Stack spacing={1}> <TextInput label="Service Type" value={carrierForm.service_type} onChange={setCarrier('service_type')} />
<InputLabel>Offers Pickup</InputLabel> <TextInput label="Zone" value={carrierForm.zone} onChange={setCarrier('zone')} placeholder="e.g. Local / Within City" />
<TextField <TextInput label="Rate" value={carrierForm.rate} onChange={setCarrier('rate')} placeholder="e.g. 120-200" />
value={branchForm.offers_pickup} <TextInput label="Delivery Time" value={carrierForm.delivery_time} onChange={setCarrier('delivery_time')} />
onChange={(e) => setBranchForm((f) => ({ ...f, offers_pickup: e.target.value }))} </Grid>
placeholder="yes / no" )}
/> </LayoutContent>
</Stack> }
</Grid> footer={
<Grid item xs={12} sm={4}> <LayoutFooter hasDivider>
<Stack spacing={1}> <HStack gap={2} justify="end">
<InputLabel>Offers Drop</InputLabel> <Button
<TextField label="Cancel"
value={branchForm.offers_drop} variant="secondary"
onChange={(e) => setBranchForm((f) => ({ ...f, offers_drop: e.target.value }))} onClick={() => setDialogOpen(false)}
placeholder="yes / no" isDisabled={branchMutation.isPending || carrierMutation.isPending}
/> />
</Stack> <Button
</Grid> label="Save"
<Grid item xs={12} sm={4}> variant="primary"
<Stack spacing={1}> onClick={handleSave}
<InputLabel>Packing Charge</InputLabel> isLoading={branchMutation.isPending || carrierMutation.isPending}
<TextField />
value={branchForm.packing_charge} </HStack>
onChange={(e) => setBranchForm((f) => ({ ...f, packing_charge: e.target.value }))} </LayoutFooter>
/> }
</Stack> />
</Grid>
</Grid>
) : (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Company</InputLabel>
<TextField value={carrierForm.company} onChange={(e) => setCarrierForm((f) => ({ ...f, company: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Weight Slab</InputLabel>
<TextField
value={carrierForm.weight_slab}
onChange={(e) => setCarrierForm((f) => ({ ...f, weight_slab: e.target.value }))}
placeholder="e.g. 500g - 1kg"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Service Type</InputLabel>
<TextField
value={carrierForm.service_type}
onChange={(e) => setCarrierForm((f) => ({ ...f, service_type: e.target.value }))}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Zone</InputLabel>
<TextField
value={carrierForm.zone}
onChange={(e) => setCarrierForm((f) => ({ ...f, zone: e.target.value }))}
placeholder="e.g. Local / Within City"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Rate</InputLabel>
<TextField
value={carrierForm.rate}
onChange={(e) => setCarrierForm((f) => ({ ...f, rate: e.target.value }))}
placeholder="e.g. 120-200"
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Delivery Time</InputLabel>
<TextField
value={carrierForm.delivery_time}
onChange={(e) => setCarrierForm((f) => ({ ...f, delivery_time: e.target.value }))}
/>
</Stack>
</Grid>
</Grid>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
Save
</Button>
</DialogActions>
</Dialog> </Dialog>
</> </>
); );

View File

@@ -1,28 +1,7 @@
import React, { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import { useNavigate } from 'react-router';
Avatar, import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
Box, import { enqueueSnackbar } from 'notistack';
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Button,
Grid,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { import {
MdPeopleAlt, MdPeopleAlt,
MdOutlinePeopleAlt, MdOutlinePeopleAlt,
@@ -34,21 +13,33 @@ import {
MdPersonAddAlt1, MdPersonAddAlt1,
MdOutlineReceiptLong MdOutlineReceiptLong
} from 'react-icons/md'; } from 'react-icons/md';
import { FaRegEdit } from 'react-icons/fa';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Grid } from '@astryxdesign/core/Grid';
import { useNavigate } from 'react-router'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Divider } from '@astryxdesign/core/Divider';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi'; import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack'; import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT, soft, edge } from 'themes/dt/tokens'; import { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================ // ============================================================================
// GET /admin/customers confirmed live: { appcustomerid, createdat, email, // GET /admin/customers confirmed live: { appcustomerid, createdat, email,
@@ -65,8 +56,7 @@ import { AccentAvatar } from 'themes/dt/primitives';
// not persisted" pattern as `pages/nearle/clients/createCustomer.js`). // not persisted" pattern as `pages/nearle/clients/createCustomer.js`).
// ============================================================================ // ============================================================================
const BRAND = '#000000'; const BRAND = DT.brand;
const BRAND_LIGHT = '#3A3A3A';
const custId = (row) => row.appcustomerid; const custId = (row) => row.appcustomerid;
const custName = (row) => row.name || '—'; const custName = (row) => row.name || '—';
@@ -75,8 +65,7 @@ const custEmail = (row) => row.email || '—';
const custBookings = (row) => row.totalbookings ?? 0; const custBookings = (row) => row.totalbookings ?? 0;
const Customers = () => { const Customers = () => {
const theme = useTheme(); const isMobile = useIsNarrowViewport();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
@@ -100,8 +89,9 @@ const Customers = () => {
const stats = useMemo(() => { const stats = useMemo(() => {
const total = customers.length; const total = customers.length;
const withEmail = customers.filter((c) => c.email).length; const withEmail = customers.filter((c) => c.email).length;
const withPhone = customers.filter((c) => c.phone).length;
const totalBookings = customers.reduce((sum, c) => sum + (Number(c.totalbookings) || 0), 0); const totalBookings = customers.reduce((sum, c) => sum + (Number(c.totalbookings) || 0), 0);
return { total, withEmail, totalBookings }; return { total, withEmail, withPhone, totalBookings };
}, [customers]); }, [customers]);
const opentoast = (message, variant = 'error') => const opentoast = (message, variant = 'error') =>
@@ -177,395 +167,214 @@ const Customers = () => {
subtitle="Live · B2C Directory" subtitle="Live · B2C Directory"
live live
action={ action={
<Tooltip title="Adds a contact to your tenant's own customer book (used when creating orders) — separate from the order-derived B2C directory below"> <Tooltip content="Adds a contact to your tenant's own customer book (used when creating orders) — separate from the order-derived B2C directory below">
<Button <Button label="Add Customer" variant="primary" icon={<MdPersonAddAlt1 size={16} />} onClick={() => navigate('/doormile/customer/create')} />
variant="contained"
startIcon={<MdPersonAddAlt1 size={16} />}
onClick={() => navigate('/doormile/customer/create')}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: BRAND } }}
>
Add Customer
</Button>
</Tooltip> </Tooltip>
} }
/> />
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}> <Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<Grid item xs={6} md={3}> <StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} />
<StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} /> <StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" />
</Grid> <StatCard title="With Phone" value={stats.withPhone} icon={<MdOutlinePhoneAndroid size={20} />} color="#8b5cf6" />
<Grid item xs={6} md={3}> <StatCard title="Total Bookings" value={stats.totalBookings} icon={<MdOutlineReceiptLong size={20} />} color="#10b981" />
<StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" />
</Grid>
<Grid item xs={6} md={3}>
<StatCard title="Total Bookings" value={stats.totalBookings} icon={<MdOutlineReceiptLong size={20} />} color="#10b981" />
</Grid>
</Grid> </Grid>
<Paper <VStack gap={0} padding={0} style={{ marginTop: 16 }}>
elevation={0} <Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
sx={{ <HStack justify="between" vAlign="center" gap={3} wrap="wrap">
mt: { xs: 1.5, md: 2 }, <HStack gap={2} vAlign="center">
p: { xs: 1, md: 1.5 }, <AccentAvatar color={BRAND} size={32}>
borderTopLeftRadius: DT.radiusCard / 8, <MdPeopleAlt size={18} />
borderTopRightRadius: DT.radiusCard / 8, </AccentAvatar>
border: '1px solid', <VStack gap={0} padding={0}>
borderColor: DT.borderSubtle, <Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
borderBottom: 0, Directory
background: '#fff' </Text>
}} <Text weight="bold">
> {customers.length} total · {rows.length} shown
<Stack </Text>
direction={{ xs: 'column', sm: 'row' }} </VStack>
alignItems={{ xs: 'stretch', sm: 'center' }} </HStack>
justifyContent="space-between" <div style={{ width: 280, maxWidth: '100%' }}>
spacing={1.25} <DebounceSearchBar
> value={searchword}
<Stack direction="row" alignItems="center" spacing={1.25}> onChange={setSearchword}
<AccentAvatar color={BRAND} size={32}> onDebouncedChange={setDebouncedSearch}
<MdPeopleAlt size={18} /> placeholder="Search customers (ctrl+k)"
</AccentAvatar> />
<Stack> </div>
<Typography </HStack>
variant="caption" </Card>
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
>
Directory
</Typography>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
{customers.length} total · {rows.length} shown
</Typography>
</Stack>
</Stack>
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search customers (ctrl+k)"
/>
</Box>
</Stack>
</Paper>
<Paper <Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
elevation={0} {isMobile ? (
sx={{ rows.length === 0 && !isLoading ? (
borderBottomLeftRadius: DT.radiusCard / 8, <EmptyState title="No customers to show" description="Customers appear here once they've placed an order." icon={<MdPeopleAlt size={28} />} />
borderBottomRightRadius: DT.radiusCard / 8, ) : (
border: '1px solid', <MobileCardList scroll>
borderColor: DT.borderSubtle, {rows.map((row, index) => (
overflow: 'hidden', <MobileCard
background: '#fff' key={custId(row) || index}
}} accent={BRAND}
> header={
{isMobile ? ( <HStack justify="between" vAlign="center" gap={1}>
rows.length === 0 && !isLoading ? ( <HStack gap={2} vAlign="center" minWidth={0}>
<Stack alignItems="center" spacing={1.5} sx={{ py: 6, px: 2 }}> <AccentAvatar color={BRAND} size={36}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}> <MdPeopleAlt size={18} />
<MdPeopleAlt size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No customers to show
</Typography>
</Stack>
) : (
<MobileCardList scroll>
{rows.map((row, index) => (
<MobileCard
key={custId(row) || index}
accent={BRAND}
header={
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ minWidth: 0 }}>
<AccentAvatar color={BRAND} size={36}>
<MdPeopleAlt size={18} />
</AccentAvatar>
<Stack sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }} noWrap>
{custName(row)}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{custId(row) ?? '—'}
</Typography>
</Stack>
</Stack>
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND, flexShrink: 0 }}>
<MdEdit size={14} />
</IconButton>
</Stack>
}
>
<MobileFieldGrid>
<MobileField label="Phone" value={custPhone(row)} />
<MobileField label="Email" value={custEmail(row)} />
<MobileField label="Bookings" value={custBookings(row)} />
</MobileFieldGrid>
</MobileCard>
))}
</MobileCardList>
)
) : (
<TableContainer
sx={{
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
'&::-webkit-scrollbar': { width: 10, height: 10 },
'&::-webkit-scrollbar-thumb': { backgroundColor: edge(BRAND), borderRadius: 8 },
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
}}
>
<Table stickyHeader sx={{ minWidth: 720 }}>
<TableHead>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: 11,
fontWeight: 800,
letterSpacing: 0.6,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`
}
}}
>
<TableCell>#</TableCell>
<TableCell>Customer</TableCell>
<TableCell>Contact</TableCell>
<TableCell align="center">Bookings</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <OrdersTableSkeleton col={4} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={5} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdPeopleAlt size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No customers to show
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow
key={custId(row) || index}
sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}
>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
{String(index + 1).padStart(2, '0')}
</Typography>
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={16} />
</AccentAvatar> </AccentAvatar>
<Stack> <VStack gap={0} padding={0} minWidth={0}>
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Text weight="bold" maxLines={1}>
{custName(row)} {custName(row)}
</Typography> </Text>
<Typography variant="caption" sx={{ color: DT.textSecondary }}> <Text type="supporting" color="secondary">
ID #{custId(row) ?? '—'} ID #{custId(row) ?? '—'}
</Typography> </Text>
</Stack> </VStack>
</Stack> </HStack>
</TableCell> <IconButton label="Edit customer" tooltip="Edit customer" size="sm" variant="ghost" icon={<MdEdit size={14} />} onClick={() => openEdit(row)} />
<TableCell> </HStack>
<Stack> }
<Stack direction="row" alignItems="center" spacing={0.5}> >
<MdPhone size={12} color={DT.textMuted} /> <MobileFieldGrid>
<Typography variant="body2" sx={{ fontWeight: 700 }}> <MobileField label="Phone" value={custPhone(row)} />
{custPhone(row)} <MobileField label="Email" value={custEmail(row)} />
</Typography> <MobileField label="Bookings" value={custBookings(row)} />
</Stack> </MobileFieldGrid>
{row.email && ( </MobileCard>
<Stack direction="row" alignItems="center" spacing={0.5}> ))}
<MdMail size={12} color={DT.textMuted} /> </MobileCardList>
<Typography variant="caption" sx={{ color: DT.textSecondary }}> )
{row.email} ) : (
</Typography> <TableScroll minWidth={720}>
</Stack> <Table density="balanced" dividers="rows" hasHover>
)} <TableHeader>
</Stack> <TableRow>
</TableCell> <TableHeaderCell>#</TableHeaderCell>
<TableCell align="center">{custBookings(row)}</TableCell> <TableHeaderCell>Customer</TableHeaderCell>
<TableCell align="right"> <TableHeaderCell>Contact</TableHeaderCell>
<Tooltip title="Edit customer"> <TableHeaderCell>Bookings</TableHeaderCell>
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}> <TableHeaderCell>Actions</TableHeaderCell>
<MdEdit size={16} /> </TableRow>
</IconButton> </TableHeader>
</Tooltip> <TableBody>
{isLoading && <TableLoader rows={5} columns={5} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={5}>
<EmptyState title="No customers to show" description="Customers appear here once they've placed an order." icon={<MdPeopleAlt size={28} />} />
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} rows.map((row, index) => (
</TableBody> <TableRow key={custId(row) || index}>
</Table> <TableCell>
</TableContainer> <Text type="supporting" weight="bold" color="secondary">
)} {String(index + 1).padStart(2, '0')}
</Paper> </Text>
</TableCell>
<TableCell>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={16} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold">{custName(row)}</Text>
<Text type="supporting" color="secondary">
ID #{custId(row) ?? '—'}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell>
<VStack gap={0} padding={0}>
<HStack gap={1} vAlign="center">
<MdPhone size={12} color={DT.textMuted} />
<Text weight="bold">{custPhone(row)}</Text>
</HStack>
{row.email && (
<HStack gap={1} vAlign="center">
<MdMail size={12} color={DT.textMuted} />
<Text type="supporting" color="secondary">
{row.email}
</Text>
</HStack>
)}
</VStack>
</TableCell>
<TableCell>{custBookings(row)}</TableCell>
<TableCell>
<HStack justify="end">
<IconButton label="Edit customer" tooltip="Edit customer" size="sm" variant="ghost" icon={<MdEdit size={16} />} onClick={() => openEdit(row)} />
</HStack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
)}
</Card>
</VStack>
<Dialog <Dialog isOpen={!!editRow} onOpenChange={(next) => !next && setEditRow(null)} width={760} purpose="form">
open={!!editRow} <Layout
onClose={() => setEditRow(null)} header={
maxWidth="lg" <DialogHeader
fullWidth title={`Edit ${editRow ? custName(editRow) : 'Customer'}`}
fullScreen={isMobile} subtitle="Customer"
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }} onOpenChange={(next) => !next && setEditRow(null)}
> startContent={
<DialogTitle <AccentAvatar color={BRAND} selected size={36}>
sx={{ <MdEdit size={18} />
background: `linear-gradient(135deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)`, </AccentAvatar>
color: '#fff', }
py: 2 />
}} }
> content={
<Stack direction="row" alignItems="center" spacing={1.5}> <LayoutContent padding={4}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'rgba(255,255,255,0.2)', color: '#fff' }}> <VStack gap={4} padding={0}>
<FaRegEdit style={{ fontSize: 18 }} /> <Grid columns={{ minWidth: 220 }} gap={3}>
</Avatar> <TextInput label="Customer Name" value={form.name || ''} onChange={(v) => setForm((f) => ({ ...f, name: v }))} />
<Stack> <TextInput
<Typography label="Contact Number"
sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }} value={form.phone || ''}
> onChange={(v) => setForm((f) => ({ ...f, phone: v.replace(/\D/g, '').slice(0, 10) }))}
Customer startIcon={MdOutlinePhoneAndroid}
</Typography> />
<Typography sx={{ fontWeight: 800, fontSize: '1.1rem', lineHeight: 1.2, mt: 0.25 }}> <TextInput label="Email" type="email" value={form.email || ''} onChange={(v) => setForm((f) => ({ ...f, email: v }))} startIcon={MdMail} />
Edit {editRow ? custName(editRow) : 'Customer'} </Grid>
</Typography>
</Stack>
</Stack>
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Customer Name</Typography>
<TextField
variant="outlined"
fullWidth
value={form.name || ''}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Contact Number</Typography>
<Stack direction="row" spacing={1}>
<TextField variant="outlined" value="+91" disabled sx={{ width: 64, cursor: 'not-allowed' }} />
<TextField
variant="outlined"
fullWidth
value={form.phone || ''}
inputProps={{ maxLength: 10, inputMode: 'numeric', pattern: '[0-9]*' }}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value.replace(/\D/g, '') }))}
InputProps={{ startAdornment: <MdOutlinePhoneAndroid size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Email</Typography>
<TextField
variant="outlined"
fullWidth
type="email"
value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
InputProps={{ startAdornment: <MdMail size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Grid>
<Grid item xs={12}> <Divider />
<Typography variant="caption" sx={{ color: DT.textMuted, fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved
on submit.
</Typography>
</Grid>
<Grid item xs={12}> <Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
<Typography sx={{ mb: 1 }}>Address</Typography> The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved on submit.
<AddressAutocomplete </Text>
id="edit-customer-address"
fullWidth <AddressAutocomplete id="edit-customer-address" label="Address" value={addressInput} onChange={setAddressInput} onPlaceSelected={handleAddressPlaceSelected} />
value={addressInput}
onChange={(text) => setAddressInput(text)} <Grid columns={{ minWidth: 200 }} gap={3}>
onPlaceSelected={handleAddressPlaceSelected} <TextInput label="Location" value={pickAddress.suburb || ''} onChange={(v) => setPickAddress((p) => ({ ...p, suburb: v }))} />
/> <TextInput label="City" value={pickAddress.city || ''} onChange={(v) => setPickAddress((p) => ({ ...p, city: v }))} />
</Grid> <TextInput label="State" value={pickAddress.state || ''} onChange={(v) => setPickAddress((p) => ({ ...p, state: v }))} />
<Grid item xs={12} sm={6}> <TextInput label="Postcode" value={pickAddress.postcode || ''} onChange={(v) => setPickAddress((p) => ({ ...p, postcode: v }))} />
<Typography sx={{ mb: 1 }}>Location</Typography> <TextInput label="Landmark" value={pickAddress.landmark || ''} onChange={(v) => setPickAddress((p) => ({ ...p, landmark: v }))} />
<TextField <TextInput label="Latitude" value={latLng.latitude || ''} isDisabled />
variant="outlined" <TextInput label="Longitude" value={latLng.longitude || ''} isDisabled />
fullWidth </Grid>
value={pickAddress.suburb || ''} </VStack>
onChange={(e) => setPickAddress((p) => ({ ...p, suburb: e.target.value }))} </LayoutContent>
/> }
</Grid> footer={
<Grid item xs={12} sm={6}> <LayoutFooter hasDivider>
<Typography sx={{ mb: 1 }}>City</Typography> <HStack gap={2} justify="end">
<TextField <Button label="Cancel" variant="secondary" onClick={() => setEditRow(null)} isDisabled={saveMutation.isPending} />
variant="outlined" <Button label="Save Changes" variant="primary" onClick={() => saveMutation.mutate()} isLoading={saveMutation.isPending} />
fullWidth </HStack>
value={pickAddress.city || ''} </LayoutFooter>
onChange={(e) => setPickAddress((p) => ({ ...p, city: e.target.value }))} }
/> />
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>State</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.state || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, state: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Postcode</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.postcode || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, postcode: e.target.value }))}
/>
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Landmark</Typography>
<TextField
variant="outlined"
fullWidth
value={pickAddress.landmark || ''}
onChange={(e) => setPickAddress((p) => ({ ...p, landmark: e.target.value }))}
/>
</Grid>
<Grid item xs={12} sm={6} sx={{ cursor: 'not-allowed' }}>
<Typography sx={{ mb: 1 }}>Latitude</Typography>
<TextField variant="outlined" fullWidth disabled value={latLng.latitude || ''} sx={{ cursor: 'not-allowed' }} />
</Grid>
<Grid item xs={12} sm={6} sx={{ cursor: 'not-allowed' }}>
<Typography sx={{ mb: 1 }}>Longitude</Typography>
<TextField variant="outlined" fullWidth disabled value={latLng.longitude || ''} sx={{ cursor: 'not-allowed' }} />
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setEditRow(null)} sx={{ color: DT.textSecondary }}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: BRAND } }}
>
Save Changes
</Button>
</DialogActions>
</Dialog> </Dialog>
</> </>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { import {
MdTwoWheeler, MdTwoWheeler,
MdLocationOn, MdLocationOn,
@@ -7,11 +8,73 @@ import {
MdAccessTime, MdAccessTime,
MdInventory2 MdInventory2
} from 'react-icons/md'; } from 'react-icons/md';
import { List, ListItem } from '@astryxdesign/core/List';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
import { getStatusStyle, getActiveOrder } from './dispatchShared'; import StatusBadge from 'components/nearle_components/StatusBadge';
import { AccentAvatar } from 'themes/dt/primitives';
import { getActiveOrder } from './dispatchShared';
import { OpenToast } from 'components/third-party/OpenToast'; import { OpenToast } from 'components/third-party/OpenToast';
// ==============================|| ACTIVE DELIVERIES (sidebar) ||============================== //
// Rows, not cards. Astryx's design rule is that dense operator data reads as
// edge-to-edge rows — the old `.adcard` bordered cards cost ~16px of chrome per
// delivery, which is a lot of scrolling once a slot has 20+ riders out.
//
// Status comes from <StatusBadge> (themes/dt/status.js), which is now the only
// status-colour table in this folder — dispatchShared's rival copy has been
// deleted and its getStatusStyle reduced to an adapter over the same source.
// Small labelled metric — icon + value, used for the distance/ETA pair.
// The old markup carried a native `title` for the label ("Distance to drop");
// Astryx's BaseProps deliberately omits `title`, so the affordance moves to the
// real <Tooltip> rather than silently disappearing onto a dropped attribute.
const Metric = ({ icon, children, label }) => (
<Tooltip content={label}>
<HStack gap={0.5} vAlign="center">
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
{icon}
</Center>
<Text size="sm" type="supporting" hasTabularNumbers>
{children}
</Text>
</HStack>
</Tooltip>
);
Metric.propTypes = {
icon: PropTypes.node,
children: PropTypes.node,
label: PropTypes.string
};
// One line of secondary context (rider / drop area / pickup) under the customer.
// `maxLines={1}` turns on Text's own truncate tooltip, which replaces the
// `title` the old spans used to show the full value when clipped.
const ContextLine = ({ icon, children }) => (
<HStack gap={1} vAlign="center">
<Center isInline width={13} height={13} style={{ flexShrink: 0, color: 'var(--color-icon-secondary)' }}>
{icon}
</Center>
<Text size="sm" type="supporting" maxLines={1}>
{children}
</Text>
</HStack>
);
ContextLine.propTypes = {
icon: PropTypes.node,
children: PropTypes.node
};
const ActiveSection = ({ const ActiveSection = ({
visibleRiders, visibleRiders,
riders, riders,
@@ -75,32 +138,27 @@ const ActiveSection = ({
if (isLoading) { if (isLoading) {
return ( return (
<div className="empty-slot" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: '200px' }}> <VStack gap={2} padding={4} hAlign="center" vAlign="center" minHeight={200}>
<div style={{ marginBottom: 16 }}><LoaderWithImage size={44} imgSize={24} /></div> <LoaderWithImage size={44} imgSize={24} />
<div className="empty-slot-title">Loading active deliveries...</div> <Text type="supporting">Loading active deliveries…</Text>
</div> </VStack>
); );
} }
if (activeDeliveries.length === 0) { if (activeDeliveries.length === 0) {
return ( return (
<div className="empty-slot"> <EmptyState
<div className="empty-slot-icon"> title="No active deliveries"
<MdInventory2 /> description="No deliveries are currently in progress for this slot."
</div> icon={<MdInventory2 size={28} />}
<div className="empty-slot-title">No active deliveries</div> />
<div className="empty-slot-sub">
No deliveries are currently in progress for this slot
</div>
</div>
); );
} }
const renderActiveDeliveryCard = (o, i) => { const renderActiveDelivery = (o) => {
const rid = o.rider_id || o.userid; const rid = o.rider_id || o.userid;
const rider = riders.find((r) => String(r.id) === String(rid)); const rider = riders.find((r) => String(r.id) === String(rid));
const color = getRiderColor(rid); const color = getRiderColor(rid);
const statusStyle = getStatusStyle(o.orderstatus);
const lat = parseFloat(o.droplat || o.deliverylat); const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong); const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon); const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
@@ -108,7 +166,7 @@ const ActiveSection = ({
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`; const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || ''; const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
const riderName = o.rider_name || o.ridername || 'Unassigned'; const riderName = o.rider_name || o.ridername || 'Unassigned';
const isActive = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid); const isSelected = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
const initials = const initials =
riderName riderName
.split(/\s+/) .split(/\s+/)
@@ -118,82 +176,70 @@ const ActiveSection = ({
.join('') .join('')
.toUpperCase() || '•'; .toUpperCase() || '•';
const deliveredAt = o.deliverytime
? parseDoormileTimestamp(o.deliverytime).isValid()
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss')
: String(o.deliverytime)
: null;
// 20 km/h is the same city-average assumption the old card used.
const etaMin = estMeters !== null ? (estMeters / 1000 / 20) * 60 : null;
return ( return (
<div <ListItem
key={o.orderid} key={o.orderid}
className={`adcard${isActive ? ' is-active' : ''}`} label={customer}
style={{ '--ad-accent': color }} isSelected={!!isSelected}
onClick={() => { onClick={() => {
if (rider) handleRiderFocus(rider); if (rider) handleRiderFocus(rider);
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon }); if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
}} }}
> startContent={
<div className="adcard-top"> // Rider colour is an arbitrary per-rider hex assigned by the map's
<div className="adcard-avatar" style={{ background: color }}>{initials}</div> // palette, which no semantic token can express — this is the same
<div className="adcard-titles"> // caller-supplied-accent escape hatch StatCard uses.
<div className="adcard-rider" title={riderName}> <AccentAvatar color={color} selected size={32}>
<MdTwoWheeler style={{ fontSize: 13, flexShrink: 0 }} /> <Text size="sm" weight="bold" style={{ color: 'inherit' }}>
<span className="adcard-tx">{riderName}</span> {initials}
</div> </Text>
<div className="adcard-customer" title={customer}>{customer}</div> </AccentAvatar>
</div> }
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}> description={
<span <VStack gap={0.5} padding={0}>
className="adcard-status" <ContextLine icon={<MdTwoWheeler size={13} />}>{riderName}</ContextLine>
style={{ background: `${statusStyle.bg}1a`, color: statusStyle.bg }} {dropArea && <ContextLine icon={<MdLocationOn size={13} />}>{dropArea}</ContextLine>}
title={statusStyle.label} {o.pickupcustomer && <ContextLine icon={<MdRestaurant size={13} />}>{o.pickupcustomer}</ContextLine>}
> </VStack>
{statusStyle.label} }
</span> endContent={
{o.deliverytime && ( <VStack gap={1} padding={0} hAlign="end">
<span className="adcard-time" style={{ fontSize: '11px', color: '#64748b' }}> <StatusBadge status={o.orderstatus} />
{parseDoormileTimestamp(o.deliverytime).isValid() {deliveredAt && (
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss') <Text size="sm" type="supporting" hasTabularNumbers>
: String(o.deliverytime)} {deliveredAt}
</span> </Text>
)} )}
</div>
</div>
{dropArea && (
<div className="adcard-addr">
<span className="adcard-ic"><MdLocationOn /></span>
<span className="adcard-tx adcard-addr-tx" title={dropArea}>{dropArea}</span>
</div>
)}
<div className="adcard-foot">
{o.pickupcustomer ? (
<span className="adcard-pickup" title={o.pickupcustomer}>
<span className="adcard-ic"><MdRestaurant /></span>
<span className="adcard-tx">{o.pickupcustomer}</span>
</span>
) : (
<span />
)}
<span className="adcard-metrics">
{estMeters !== null && ( {estMeters !== null && (
<> <HStack gap={2} vAlign="center">
<span className="adcard-m adcard-m-eta" title="Distance to drop"> <Metric icon={<MdMyLocation size={14} />} label="Distance to drop">
<span className="adcard-ic"><MdMyLocation /></span>
{formatMeters(estMeters)} {formatMeters(estMeters)}
</span> </Metric>
<span className="adcard-m adcard-m-time" title="Estimated time to drop"> <Metric icon={<MdAccessTime size={14} />} label="Estimated time to drop">
<span className="adcard-ic"><MdAccessTime /></span> {etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`}
{(() => { </Metric>
const etaMin = estMeters / 1000 / 20 * 60; </HStack>
return etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`;
})()}
</span>
</>
)} )}
</span> </VStack>
</div> }
</div> />
); );
}; };
return <div className="adcard-list">{activeDeliveries.map(renderActiveDeliveryCard)}</div>; return (
<List density="balanced" hasDividers>
{activeDeliveries.map(renderActiveDelivery)}
</List>
);
}; };
export default ActiveSection; export default ActiveSection;

View File

@@ -20,8 +20,23 @@ Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors the
**Gaps are intentional** (8–9 AM, 12 PM–4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one. **Gaps are intentional** (8–9 AM, 12 PM–4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one.
### Time-field selection (`selectedTimeField`) ### Time-field selection (`BATCH_TIME_FIELD`)
Default `'due'` → bucket key is `['expecteddeliverytime']` (the booking's promised delivery slot, `serviceoptions[0].estimateddeliveryat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing. Fixed at `'created'` → bucket key is `['orderdate']` (the booking's `createdat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
This is a constant, not state. The operator-facing time-field dropdown and the slot-hour editor have both been **deleted** (they were commented-out dead JSX whose setters nothing called). Don't resurrect either without re-reading the ban below.
**Three fields have been tried here. Two were wrong:**
| Field | Why it failed |
|---|---|
| `assigntime` | Not an assignment time — `api.js` maps it to the booking's `updatedat`, which is re-stamped by any status change. Orders drifted into whichever batch held the current clock time. |
| `expecteddeliverytime` | Stable, but it's the *promised delivery* slot, not the wave. An order created at 12:22 whose SLA put its ETA at 16:30 counted towards Evening, so Evening showed orders nobody had placed yet. |
| `orderdate` (**current**) | Immutable, and matches the operator's model: an order created and assigned at 12:22 belongs to Afternoon. No batch later than the current clock time can show a count for today. |
### Date filtering is client-side
`GET /admin/bookings` takes only `pageno`/`pagesize` — it has no date parameter. `fetchDeliveries` in `api.js` applies the `startdate`/`enddate` from the query key **client-side**, filtering on `orderdate` so it agrees with the bucket field above.
Those two key positions were previously destructured-but-ignored, which meant changing the date refetched and returned an identical row set — every batch count was the same for today, yesterday and last week. If you add a filter to this query, make sure `fetchDeliveries` actually reads it; a query key position that nothing consumes is a silent no-op.
### ⛔ Never bucket on `assigntime` ### ⛔ Never bucket on `assigntime`
It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it. It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it.
@@ -38,6 +53,39 @@ Displaying it as a "last updated" stamp is fine — that's all the reports use i
--- ---
## 1b. Status colours — one table, and it isn't in this folder
`themes/dt/status.js` (`STATUS_META` / `getStatusMeta()`) is the **only** place a lifecycle status maps to a colour. Render statuses with `<StatusBadge status={...}/>` from `components/nearle_components/StatusBadge`.
This folder used to carry a rival `STATUS_STYLES` table in `dispatchShared.js`. It had drifted far enough to be actively misleading — `picked` and `active` were the *same* `#0ea5e9`, so the map showed a picked-up order and an in-transit order identically, while the Deliveries table showed them purple and teal. It has been deleted.
`dispatchShared.js` still exports **`getStatusStyle`**, but it is now a thin adapter over `getStatusMeta` returning `{ label, bg, fg }`. It exists for one reason: **Leaflet `divIcon` markers are built as HTML template strings**, and you cannot put a React component inside `L.divIcon({ html: '…' })`. That is the only sanctioned use.
- Rendering normal JSX? → `<StatusBadge>`.
- Building a divIcon HTML string, or setting an inline `style` on a marker? → `getStatusStyle`.
- Adding a status colour anywhere else in this folder? → don't; add it to `themes/dt/status.js`.
New backend status enums go in that file's `STATUS_ALIASES`, not in a local map. Unmapped values render as a neutral badge showing the raw string, so a missing alias is visible rather than silent.
---
## 1c. Astryx migration status for this folder
| File | State |
|---|---|
| `ActiveSection.js` | ✅ Astryx (List/ListItem rows, StatusBadge) |
| `dispatchShared.js` | ✅ no status table; exports `onActivate`, `useIsNarrowViewport`, `getStatusStyle` adapter |
| `ProfitabilitySection.js` | ✅ Astryx; its 1,596-line CSS is deleted |
| `CompareDataPanel.js` | ✅ MUI-free (score ring stays hand-rolled CSS — `conic-gradient` has no Astryx equivalent) |
| `Preview.js` | ✅ Astryx shell; **embeds `<Dispatch>`, which is still custom-CSS inside — a sanctioned temporary mix** |
| `Dispatch.js` + `Dispatch.css` | ⏳ **not converted.** Brand colours and dead CSS purged; structure still hand-rolled `<div>`/className |
**The folder no longer imports `@mui/*` anywhere.** Don't reintroduce it.
`Dispatch.js` is the remaining work and must follow the staged order in the plan: pure-refactor extraction of the safe presentational pieces first (header chrome, mode/batch row, KPI strip, sidebar list, GPS modal, popup chrome) with **zero** visual change, then convert each piece to Astryx individually, then purge its CSS region by region. Never extract the marker registry, `AnimatedRiderMarker`, the `MapContainer` tree, or the marker event handlers, and never move the state — only the rendering of already-computed values.
---
## 2. Leaflet integration ## 2. Leaflet integration
Dispatch.js uses `react-leaflet` for declarative tile/marker rendering, BUT a lot of marker behaviour is imperative: Dispatch.js uses `react-leaflet` for declarative tile/marker rendering, BUT a lot of marker behaviour is imperative:

View File

@@ -1,5 +1,4 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useTheme, useMediaQuery } from '@mui/material';
import { import {
MdPublic, MdPublic,
MdSwapHoriz, MdSwapHoriz,
@@ -21,12 +20,16 @@ import {
MdHourglassBottom MdHourglassBottom
} from 'react-icons/md'; } from 'react-icons/md';
import { import {
onActivate,
useIsNarrowViewport,
stepColor, stepColor,
getStatusStyle,
FINAL_STATUSES, FINAL_STATUSES,
SKIPPED_STATUSES, SKIPPED_STATUSES,
ordinal ordinal
} from './dispatchShared'; } from './dispatchShared';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { STATUS } from 'themes/dt/tokens';
// Right-side data panel rendered in Compare mode. Pure presentation + // Right-side data panel rendered in Compare mode. Pure presentation +
// memoized derivations: feed it the comparison state from Dispatch and // memoized derivations: feed it the comparison state from Dispatch and
@@ -65,8 +68,7 @@ function CompareDataPanel({
// scoped media-query block in Dispatch.css uses to stack the side-by-side // scoped media-query block in Dispatch.css uses to stack the side-by-side
// timing/clock columns vertically and let stat chips wrap). No data, // timing/clock columns vertically and let stat chips wrap). No data,
// handler, or derivation behaviour changes on any breakpoint. // handler, or derivation behaviour changes on any breakpoint.
const theme = useTheme(); const isMobile = useIsNarrowViewport();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// All derivations live in a single useMemo so the cost of re-running // All derivations live in a single useMemo so the cost of re-running
// them is paid only when an upstream input actually changes — not on // them is paid only when an upstream input actually changes — not on
@@ -105,7 +107,13 @@ function CompareDataPanel({
(sum.onTime / onTimeForScore) * 25 + (sum.onTime / onTimeForScore) * 25 +
((totalForScore - sum.anomalies) / totalForScore) * 15 ((totalForScore - sum.anomalies) / totalForScore) * 15
); );
const scoreColor = score >= 85 ? '#16a34a' : score >= 65 ? '#f59e0b' : '#dc2626'; // A compliance score is a health reading, not a lifecycle status, so it
// must NOT go through StatusBadge — it reads the raw hexes from the STATUS
// palette, which is the sanctioned "arbitrary accent" channel (root
// CLAUDE.md §6.3). Thresholds (85/65) and the ring mechanism below are
// deliberately unchanged; only the three constants are retargeted, so a QA
// A/B comparison should show identical scores and bands.
const scoreColor = score >= 85 ? STATUS.success : score >= 65 ? STATUS.warning : STATUS.error;
const scoreLabel = score >= 85 ? 'Excellent' : score >= 65 ? 'Acceptable' : 'Needs review'; const scoreLabel = score >= 85 ? 'Excellent' : score >= 65 ? 'Acceptable' : 'Needs review';
// KPIs derived from delivery timestamps. // KPIs derived from delivery timestamps.
@@ -253,7 +261,11 @@ function CompareDataPanel({
className={`cdp-seq-diff${ className={`cdp-seq-diff${
focusedCompareStep === d.sequenceStep ? ' is-focused' : '' focusedCompareStep === d.sequenceStep ? ' is-focused' : ''
}${focusable ? '' : ' is-nested'}`} }${focusable ? '' : ' is-nested'}`}
role="treeitem"
aria-selected={focusedCompareStep === d.sequenceStep}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)} onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
> >
<span <span
className="cdp-seq-diff-num" className="cdp-seq-diff-num"
@@ -433,7 +445,9 @@ function CompareDataPanel({
<div <div
className="cdp-section-head cdp-section-head-clickable" className="cdp-section-head cdp-section-head-clickable"
onClick={() => setSequenceOpen((v) => !v)} onClick={() => setSequenceOpen((v) => !v)}
onKeyDown={onActivate(() => setSequenceOpen((v) => !v))}
role="button" role="button"
tabIndex={0}
aria-expanded={sequenceOpen} aria-expanded={sequenceOpen}
title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'} title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'}
> >
@@ -455,7 +469,7 @@ function CompareDataPanel({
{sequenceOpen && ( {sequenceOpen && (
<div className="cdp-seq"> <div className="cdp-seq">
{outOfOrderSteps.length > 0 ? ( {outOfOrderSteps.length > 0 ? (
<ul className="cdp-seq-diffs"> <ul className="cdp-seq-diffs" role="tree" aria-label="Route sequence differences">
{seqRuns.map((run, runIdx) => { {seqRuns.map((run, runIdx) => {
if (run.items.length === 1) { if (run.items.length === 1) {
return renderDiffRow(run.items[0]); return renderDiffRow(run.items[0]);
@@ -472,7 +486,11 @@ function CompareDataPanel({
<React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}> <React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}>
<li <li
className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`} className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`}
role="treeitem"
aria-selected={groupFocused}
tabIndex={0}
onClick={() => toggleSeqGroup(runIdx)} onClick={() => toggleSeqGroup(runIdx)}
onKeyDown={onActivate(() => toggleSeqGroup(runIdx))}
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<span className="cdp-seq-group-num"> <span className="cdp-seq-group-num">
@@ -508,8 +526,8 @@ function CompareDataPanel({
</span> </span>
</li> </li>
{isOpen && ( {isOpen && (
<li className="cdp-seq-group-children-wrap"> <li className="cdp-seq-group-children-wrap" role="presentation">
<ul className="cdp-seq-group-children"> <ul className="cdp-seq-group-children" role="group">
{run.items.map((it) => renderDiffRow(it, false))} {run.items.map((it) => renderDiffRow(it, false))}
</ul> </ul>
</li> </li>
@@ -670,7 +688,9 @@ function CompareDataPanel({
<div <div
className="cdp-highlight is-best" className="cdp-highlight is-best"
onClick={() => focusStep(bestStep.sequenceStep)} onClick={() => focusStep(bestStep.sequenceStep)}
onKeyDown={onActivate(() => focusStep(bestStep.sequenceStep))}
role="button" role="button"
tabIndex={0}
title="Focus this step" title="Focus this step"
> >
<span className="cdp-highlight-rail" aria-hidden="true" /> <span className="cdp-highlight-rail" aria-hidden="true" />
@@ -710,7 +730,9 @@ function CompareDataPanel({
<div <div
className="cdp-highlight is-worst" className="cdp-highlight is-worst"
onClick={() => focusStep(worstStep.sequenceStep)} onClick={() => focusStep(worstStep.sequenceStep)}
onKeyDown={onActivate(() => focusStep(worstStep.sequenceStep))}
role="button" role="button"
tabIndex={0}
title="Focus this step" title="Focus this step"
> >
<span className="cdp-highlight-rail" aria-hidden="true" /> <span className="cdp-highlight-rail" aria-hidden="true" />
@@ -810,7 +832,7 @@ function CompareDataPanel({
? 'is-under' ? 'is-under'
: '' : ''
: ''; : '';
const statusStyle = getStatusStyle(focused.orderstatus);
const profit = parseFloat(focused.order?.profit || 0); const profit = parseFloat(focused.order?.profit || 0);
return ( return (
<section className="cdp-section"> <section className="cdp-section">
@@ -845,14 +867,7 @@ function CompareDataPanel({
Order #{focused.orderid} Order #{focused.orderid}
</div> </div>
</div> </div>
{focused.orderstatus && ( {focused.orderstatus && <StatusBadge status={focused.orderstatus} />}
<span
className="compare-delta-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div> </div>
<div className="compare-delta-grid"> <div className="compare-delta-grid">
<div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}> <div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}>
@@ -920,7 +935,7 @@ function CompareDataPanel({
Deviations ({deviations.length}) Deviations ({deviations.length})
</span> </span>
</div> </div>
<ul className="cdp-dev-list"> <ul className="cdp-dev-list" role="listbox" aria-label="Step deviations">
{deviations.map((d) => { {deviations.map((d) => {
const color = stepColor(d.sequenceStep - 1); const color = stepColor(d.sequenceStep - 1);
const kmSign = d.kmDelta >= 0 ? '+' : ''; const kmSign = d.kmDelta >= 0 ? '+' : '';
@@ -928,7 +943,11 @@ function CompareDataPanel({
<li <li
key={`dev-${d.sequenceStep}`} key={`dev-${d.sequenceStep}`}
className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`} className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`}
role="option"
aria-selected={focusedCompareStep === d.sequenceStep}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)} onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
> >
<span className="cdp-dev-num" style={{ background: color }}> <span className="cdp-dev-num" style={{ background: color }}>
{d.sequenceStep} {d.sequenceStep}
@@ -970,7 +989,7 @@ function CompareDataPanel({
{delivered}/{compareDeltas.length} delivered {delivered}/{compareDeltas.length} delivered
</span> </span>
</div> </div>
<ul className="cdp-step-list"> <ul className="cdp-step-list" role="listbox" aria-label="Route steps">
{compareDeltas.map((d) => { {compareDeltas.map((d) => {
const color = stepColor(d.sequenceStep - 1); const color = stepColor(d.sequenceStep - 1);
const statusLow = String(d.orderstatus || '').toLowerCase(); const statusLow = String(d.orderstatus || '').toLowerCase();
@@ -978,7 +997,7 @@ function CompareDataPanel({
const isSkipped = SKIPPED_STATUSES.has(statusLow); const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isCorrect = isDelivered && !d.anomaly; const isCorrect = isDelivered && !d.anomaly;
const isFocused = focusedCompareStep === d.sequenceStep; const isFocused = focusedCompareStep === d.sequenceStep;
const statusStyle = getStatusStyle(d.orderstatus);
const profit = parseFloat(d.order?.profit || 0); const profit = parseFloat(d.order?.profit || 0);
const kmSign = d.kmDelta >= 0 ? '+' : ''; const kmSign = d.kmDelta >= 0 ? '+' : '';
const timeCls = const timeCls =
@@ -1001,7 +1020,11 @@ function CompareDataPanel({
<li <li
key={`step-${d.sequenceStep}`} key={`step-${d.sequenceStep}`}
className={stepCls} className={stepCls}
role="option"
aria-selected={isFocused}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)} onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
> >
<span className="cdp-step-num" style={{ background: color }}> <span className="cdp-step-num" style={{ background: color }}>
{d.sequenceStep} {d.sequenceStep}
@@ -1021,14 +1044,7 @@ function CompareDataPanel({
<span className="cdp-step-title"> <span className="cdp-step-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`} {d.deliverycustomer || `Step ${d.sequenceStep}`}
</span> </span>
{d.orderstatus && ( {d.orderstatus && <StatusBadge status={d.orderstatus} />}
<span
className="cdp-step-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
</div> </div>
<div className="cdp-step-sub"> <div className="cdp-step-sub">
{d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''} {d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''}

View File

@@ -117,7 +117,7 @@
border-radius: 999px; border-radius: 999px;
background: rgba(123, 31, 162, 0.08); background: rgba(123, 31, 162, 0.08);
border: 1px solid rgba(123, 31, 162, 0.25); border: 1px solid rgba(123, 31, 162, 0.25);
color: #7b1fa2; color: var(--color-accent);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -221,12 +221,12 @@
.dispatch-container .logo-city-option.active { .dispatch-container .logo-city-option.active {
background: rgba(123, 31, 162, 0.1); background: rgba(123, 31, 162, 0.1);
color: #7b1fa2; color: var(--color-accent);
} }
.dispatch-container .logo-city-option-icon { .dispatch-container .logo-city-option-icon {
font-size: 14px; font-size: 14px;
color: #7b1fa2; color: var(--color-accent);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -238,7 +238,7 @@
} }
.dispatch-container .logo-city-option-check { .dispatch-container .logo-city-option-check {
color: #7b1fa2; color: var(--color-accent);
font-weight: 800; font-weight: 800;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -975,329 +975,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Slot-time-field dropdown — picks which timestamp column drives slot
bucketing. Styled to match the location-pill dropdown in the header so
both feel like the same kind of filter control. */
.dispatch-container .time-field-wrap {
position: relative;
display: inline-block;
flex-shrink: 0;
margin-right: 4px;
}
.dispatch-container .time-field-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(123, 31, 162, 0.08);
border: 1px solid rgba(123, 31, 162, 0.25);
color: #7b1fa2;
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.dispatch-container .time-field-btn:hover {
background: rgba(123, 31, 162, 0.14);
border-color: rgba(123, 31, 162, 0.45);
}
.dispatch-container .time-field-btn.open {
background: rgba(123, 31, 162, 0.18);
border-color: rgba(123, 31, 162, 0.55);
box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18);
}
.dispatch-container .time-field-btn svg {
font-size: 13px;
flex-shrink: 0;
}
.dispatch-container .time-field-caret {
font-size: 15px;
transition: transform 0.2s ease;
}
.dispatch-container .time-field-btn.open .time-field-caret {
transform: rotate(180deg);
}
.dispatch-container .time-field-text {
white-space: nowrap;
}
.dispatch-container .time-field-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: #fff;
border: 1px solid rgba(123, 31, 162, 0.18);
border-radius: 12px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16);
padding: 6px;
z-index: 1000;
animation: logo-city-menu-in 0.14s ease-out;
}
.dispatch-container .time-field-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: 0;
background: transparent;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background 0.12s ease;
}
.dispatch-container .time-field-option:hover {
background: rgba(123, 31, 162, 0.06);
}
.dispatch-container .time-field-option.active {
background: rgba(123, 31, 162, 0.1);
color: #7b1fa2;
}
.dispatch-container .time-field-option-icon {
font-size: 14px;
color: #7b1fa2;
flex-shrink: 0;
}
.dispatch-container .time-field-option-check {
margin-left: auto;
color: #7b1fa2;
font-weight: 800;
}
/* Slot timings editor — popover anchored to a small "Edit slots" button in
the batch row. Lets the operator tweak start/end hours, add new slots,
delete existing ones, or reset to the default 5-slot layout. */
.dispatch-container .slot-edit-wrap {
position: relative;
display: inline-block;
flex-shrink: 0;
margin-right: 4px;
}
.dispatch-container .slot-edit-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.04);
border: 1px dashed rgba(15, 23, 42, 0.18);
color: #475569;
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
font-family: inherit;
}
.dispatch-container .slot-edit-btn:hover {
background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.32);
color: #0f172a;
}
.dispatch-container .slot-edit-btn.open {
background: rgba(123, 31, 162, 0.1);
border-color: rgba(123, 31, 162, 0.5);
border-style: solid;
color: #7b1fa2;
}
.dispatch-container .slot-edit-btn svg {
font-size: 13px;
flex-shrink: 0;
}
.dispatch-container .slot-edit-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 340px;
background: #fff;
border: 1px solid rgba(123, 31, 162, 0.18);
border-radius: 14px;
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.2);
padding: 12px;
z-index: 1000;
animation: logo-city-menu-in 0.14s ease-out;
}
.dispatch-container .slot-edit-head {
margin-bottom: 10px;
}
.dispatch-container .slot-edit-title {
font-size: 13px;
font-weight: 800;
color: #0f172a;
}
.dispatch-container .slot-edit-sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.dispatch-container .slot-edit-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow-y: auto;
padding-right: 2px;
}
.dispatch-container .slot-edit-row {
display: grid;
grid-template-columns: 22px 70px 70px 1fr 28px;
align-items: center;
gap: 8px;
}
.dispatch-container .slot-edit-idx {
width: 22px;
height: 22px;
border-radius: 6px;
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
font-size: 11px;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dispatch-container .slot-edit-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.dispatch-container .slot-edit-field-label {
font-size: 9px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dispatch-container .slot-edit-field input {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 8px;
padding: 5px 8px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
font-family: inherit;
background: #fff;
}
.dispatch-container .slot-edit-field input:focus {
outline: none;
border-color: #7b1fa2;
box-shadow: 0 0 0 3px rgba(123, 31, 162, 0.18);
}
.dispatch-container .slot-edit-preview {
font-size: 11px;
color: #475569;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dispatch-container .slot-edit-remove {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid rgba(220, 38, 38, 0.32);
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
font-size: 16px;
font-weight: 800;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.dispatch-container .slot-edit-remove:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.14);
border-color: rgba(220, 38, 38, 0.55);
}
.dispatch-container .slot-edit-remove:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dispatch-container .slot-edit-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(15, 23, 42, 0.1);
}
.dispatch-container .slot-edit-add,
.dispatch-container .slot-edit-reset {
flex: 1;
border-radius: 8px;
padding: 7px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
font-family: inherit;
border: 1px solid transparent;
}
.dispatch-container .slot-edit-add {
background: #7b1fa2;
color: #fff;
border-color: #7b1fa2;
}
.dispatch-container .slot-edit-add:hover {
background: #6a1591;
}
.dispatch-container .slot-edit-reset {
background: #fff;
color: #475569;
border-color: rgba(15, 23, 42, 0.16);
}
.dispatch-container .slot-edit-reset:hover {
background: rgba(15, 23, 42, 0.04);
color: #0f172a;
}
.dispatch-container .batch-btn { .dispatch-container .batch-btn {
display: inline-flex; display: inline-flex;
@@ -2921,7 +2598,7 @@
/* ---------- Per-order cards inside a focused zone ---------- */ /* ---------- Per-order cards inside a focused zone ---------- */
/* Each order is a self-contained card so the list reads as a deck of delivery /* Each order is a self-contained card so the list reads as a deck of delivery
tickets rather than a wall of text. All accents use the console's base tickets rather than a wall of text. All accents use the console's base
purple (#7b1fa2) so the page sits inside the existing design language. */ purple (var(--color-accent)) so the page sits inside the existing design language. */
.dispatch-container .zone-order-grid { .dispatch-container .zone-order-grid {
display: flex; display: flex;
@@ -2948,7 +2625,7 @@
left: 0; left: 0;
width: 4px; width: 4px;
height: 100%; height: 100%;
background: #7b1fa2; background: var(--color-accent);
opacity: 0.85; opacity: 0.85;
transition: width 0.18s ease, opacity 0.18s ease; transition: width 0.18s ease, opacity 0.18s ease;
} }
@@ -2969,7 +2646,7 @@
} }
.dispatch-container .zone-order-card.active { .dispatch-container .zone-order-card.active {
border-color: #7b1fa2; border-color: var(--color-accent);
box-shadow: 0 10px 24px rgba(123, 31, 162, 0.22); box-shadow: 0 10px 24px rgba(123, 31, 162, 0.22);
background: linear-gradient(180deg, #ffffff 0%, #f7eaff 100%); background: linear-gradient(180deg, #ffffff 0%, #f7eaff 100%);
} }
@@ -3019,8 +2696,8 @@
font-weight: 800; font-weight: 800;
font-size: 13px; font-size: 13px;
flex-shrink: 0; flex-shrink: 0;
background: #7b1fa2; background: var(--color-accent);
border: 1px solid #7b1fa2; border: 1px solid var(--color-accent);
box-shadow: 0 2px 6px rgba(123, 31, 162, 0.22); box-shadow: 0 2px 6px rgba(123, 31, 162, 0.22);
} }
@@ -3044,7 +2721,7 @@
.dispatch-container .zone-order-rider { .dispatch-container .zone-order-rider {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: #7b1fa2; color: var(--color-accent);
margin-top: 2px; margin-top: 2px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -3085,7 +2762,7 @@
.dispatch-container .zone-order-time svg { .dispatch-container .zone-order-time svg {
font-size: 14px; font-size: 14px;
color: #7b1fa2; color: var(--color-accent);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -3199,7 +2876,7 @@
.dispatch-container .zone-order-chip.zone-order-trip { .dispatch-container .zone-order-chip.zone-order-trip {
background: rgba(123, 31, 162, 0.1); background: rgba(123, 31, 162, 0.1);
border-color: rgba(123, 31, 162, 0.25); border-color: rgba(123, 31, 162, 0.25);
color: #7b1fa2; color: var(--color-accent);
} }
.dispatch-container .zone-order-type { .dispatch-container .zone-order-type {
@@ -4260,17 +3937,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.dispatch-container .compare-delta-status {
display: inline-flex;
align-items: center;
padding: 4px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
flex-shrink: 0;
}
.dispatch-container .compare-delta-grid { .dispatch-container .compare-delta-grid {
display: grid; display: grid;
@@ -5389,7 +5055,7 @@
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
color: #7b1fa2; color: var(--color-accent);
border-bottom: 1px solid rgba(123, 31, 162, 0.18); border-bottom: 1px solid rgba(123, 31, 162, 0.18);
padding-bottom: 4px; padding-bottom: 4px;
} }
@@ -5571,7 +5237,7 @@
/* --- Header: purple gradient with order id + status + rider --- */ /* --- Header: purple gradient with order id + status + rider --- */
.dispatch-container .dispatch-popup .pu-header { .dispatch-container .dispatch-popup .pu-header {
background: linear-gradient(135deg, #7b1fa2 0%, #9c27b0 50%, #ab47bc 100%); background: var(--color-accent);
padding: 14px 16px 12px; padding: 14px 16px 12px;
color: #fff; color: #fff;
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
@@ -5706,13 +5372,13 @@
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: #7b1fa2; color: var(--color-accent);
} }
.dispatch-container .dispatch-popup .pu-header-step-value { .dispatch-container .dispatch-popup .pu-header-step-value {
font-size: 15px; font-size: 15px;
font-weight: 800; font-weight: 800;
color: #7b1fa2; color: var(--color-accent);
white-space: nowrap; white-space: nowrap;
} }
@@ -5737,7 +5403,7 @@
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: #7b1fa2; color: var(--color-accent);
border-bottom: 1px solid rgba(123, 31, 162, 0.18); border-bottom: 1px solid rgba(123, 31, 162, 0.18);
} }
@@ -5781,7 +5447,7 @@
height: 26px; height: 26px;
border-radius: 7px; border-radius: 7px;
background: rgba(123, 31, 162, 0.12); background: rgba(123, 31, 162, 0.12);
color: #7b1fa2; color: var(--color-accent);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -5841,7 +5507,7 @@
.dispatch-container .dispatch-popup .pu-distance-icon { .dispatch-container .dispatch-popup .pu-distance-icon {
display: inline-flex; display: inline-flex;
color: #7b1fa2; color: var(--color-accent);
} }
.dispatch-container .dispatch-popup .pu-distance-icon svg { .dispatch-container .dispatch-popup .pu-distance-icon svg {
@@ -6326,7 +5992,7 @@
height: 64px; height: 64px;
border-radius: 16px; border-radius: 16px;
background: rgba(123, 31, 162, 0.08); background: rgba(123, 31, 162, 0.08);
color: #7b1fa2; color: var(--color-accent);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -6384,7 +6050,7 @@
} }
.dispatch-container .ri-search-input:focus { .dispatch-container .ri-search-input:focus {
border-color: #7b1fa2; border-color: var(--color-accent);
background: #fff; background: #fff;
} }
@@ -6429,13 +6095,13 @@
} }
.dispatch-container .ri-rider-item.active { .dispatch-container .ri-rider-item.active {
border-color: #7b1fa2; border-color: var(--color-accent);
background: linear-gradient(180deg, #fbf3ff 0%, #f0e0fa 100%); background: linear-gradient(180deg, #fbf3ff 0%, #f0e0fa 100%);
box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18); box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18);
} }
.dispatch-container .ri-rider-item.active .ri-rider-name { .dispatch-container .ri-rider-item.active .ri-rider-name {
color: #7b1fa2; color: var(--color-accent);
} }
.dispatch-container .ri-rider-item.active .ri-rider-arrow { .dispatch-container .ri-rider-item.active .ri-rider-arrow {
@@ -6476,7 +6142,7 @@
} }
.dispatch-container .ri-rider-arrow { .dispatch-container .ri-rider-arrow {
color: #7b1fa2; color: var(--color-accent);
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
opacity: 0.4; opacity: 0.4;
@@ -6577,7 +6243,7 @@
.dispatch-container .ri-live.is-refetching { .dispatch-container .ri-live.is-refetching {
background: rgba(123, 31, 162, 0.12); background: rgba(123, 31, 162, 0.12);
color: #7b1fa2; color: var(--color-accent);
} }
.dispatch-container .ri-live-dot { .dispatch-container .ri-live-dot {
@@ -6590,7 +6256,7 @@
} }
.dispatch-container .ri-live.is-refetching .ri-live-dot { .dispatch-container .ri-live.is-refetching .ri-live-dot {
background: #7b1fa2; background: var(--color-accent);
box-shadow: 0 0 0 0 rgba(123, 31, 162, 0.55); box-shadow: 0 0 0 0 rgba(123, 31, 162, 0.55);
} }
@@ -6648,7 +6314,7 @@
height: 32px; height: 32px;
border-radius: 9px; border-radius: 9px;
background: rgba(123, 31, 162, 0.12); background: rgba(123, 31, 162, 0.12);
color: #7b1fa2; color: var(--color-accent);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -6727,7 +6393,7 @@
} }
.dispatch-container .ri-coords-label svg { .dispatch-container .ri-coords-label svg {
color: #7b1fa2; color: var(--color-accent);
font-size: 16px; font-size: 16px;
} }
@@ -6753,7 +6419,7 @@
can read the location without opening the popup. Styled to override the can read the location without opening the popup. Styled to override the
default leaflet tooltip chrome (rounded chip, brand purple). */ default leaflet tooltip chrome (rounded chip, brand purple). */
.dispatch-container .ri-map .leaflet-tooltip.ri-area-banner { .dispatch-container .ri-map .leaflet-tooltip.ri-area-banner {
background: #7b1fa2; background: var(--color-accent);
color: #fff; color: #fff;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
@@ -6766,7 +6432,7 @@
} }
.dispatch-container .ri-map .leaflet-tooltip.ri-area-banner::before { .dispatch-container .ri-map .leaflet-tooltip.ri-area-banner::before {
border-top-color: #7b1fa2; border-top-color: var(--color-accent);
} }
/* Mobile — collapse the sidebar above the main panel, single-column stats */ /* Mobile — collapse the sidebar above the main panel, single-column stats */
@@ -7177,10 +6843,6 @@
font-size: 9px; font-size: 9px;
} }
.dispatch-container .compare-delta-status {
padding: 2px 6px;
font-size: 9px;
}
.dispatch-container .compare-delta-grid { .dispatch-container .compare-delta-grid {
gap: 6px; gap: 6px;
@@ -7395,10 +7057,6 @@
font-size: 9px; font-size: 9px;
} }
.dispatch-container .compare-delta-status {
padding: 2px 6px;
font-size: 9px;
}
/* Delta Grid - cells and labels */ /* Delta Grid - cells and labels */
.dispatch-container .compare-delta-grid { .dispatch-container .compare-delta-grid {
@@ -8134,17 +7792,6 @@
min-width: 0; min-width: 0;
} }
.dispatch-container .cdp-step-status {
display: inline-flex;
align-items: center;
padding: 3px 9px;
font-size: 11px;
font-weight: 800;
border-radius: 999px;
letter-spacing: 0.05em;
text-transform: uppercase;
flex-shrink: 0;
}
.dispatch-container .cdp-step-sub { .dispatch-container .cdp-step-sub {
font-size: 13px; font-size: 13px;
@@ -10791,186 +10438,6 @@
font-size: 10.5px; font-size: 10.5px;
} }
/* ── ActiveSection Delivery Cards ────────────────────────────── */
.dispatch-container .adcard-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0 16px;
}
@keyframes adcard-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.dispatch-container .adcard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
cursor: pointer;
/* Explicit properties so the CSS animation (adcard-in) isn't interrupted by transition: all */
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s cubic-bezier(0.4, 0, 0.2, 1);
animation: adcard-in 0.28s cubic-bezier(0.4, 0, 0.2, 1) both;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
overflow: hidden;
border-left: 4px solid var(--ad-accent, var(--border));
}
.dispatch-container .adcard:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--ad-accent, var(--accent));
}
.dispatch-container .adcard.is-active {
border-color: var(--ad-accent, var(--accent));
box-shadow: 0 0 0 2px rgba(58, 58, 58, 0.15), var(--shadow-lg);
background: var(--accent-soft);
}
.dispatch-container .adcard-top {
display: flex;
align-items: center;
gap: 12px;
}
.dispatch-container .adcard-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
flex-shrink: 0;
}
.dispatch-container .adcard-titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dispatch-container .adcard-customer {
font-size: 12px;
font-weight: 500;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dispatch-container .adcard-rider {
display: flex;
align-items: center;
gap: 4px;
color: var(--text);
font-size: 14.5px;
font-weight: 700;
}
.dispatch-container .adcard-status {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 6px;
white-space: nowrap;
}
.dispatch-container .adcard-addr {
display: flex;
align-items: flex-start;
gap: 6px;
background: var(--bg-sub);
padding: 8px 10px;
border-radius: 8px;
font-size: 12px;
color: var(--text-muted);
}
.dispatch-container .adcard-ic {
display: flex;
align-items: center;
font-size: 14px;
color: var(--text-muted);
flex-shrink: 0;
margin-top: 1px;
}
.dispatch-container .adcard-tx {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.dispatch-container .adcard-addr-tx {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
}
.dispatch-container .adcard-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 11.5px;
color: var(--text-muted);
font-weight: 500;
margin-top: 2px;
}
.dispatch-container .adcard-pickup {
display: flex;
align-items: center;
gap: 4px;
color: var(--kitchen);
font-weight: 600;
min-width: 0;
}
.dispatch-container .adcard-pickup .adcard-ic {
color: var(--kitchen);
}
.dispatch-container .adcard-metrics {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
flex-shrink: 0;
}
.dispatch-container .adcard-m {
display: flex;
align-items: center;
gap: 3px;
color: var(--text-muted);
}
.dispatch-container .adcard-m-eta {
color: var(--ad-accent, var(--accent));
}
.dispatch-container .adcard-m-time {
color: #10b981;
}
/* ========================================================================= /* =========================================================================
Mobile / app-like layout (≤ 768px). Purely a LAYOUT pass — no behaviour Mobile / app-like layout (≤ 768px). Purely a LAYOUT pass — no behaviour

View File

@@ -22,7 +22,6 @@ import {
MdStraighten, MdStraighten,
MdLocationOn, MdLocationOn,
MdMarkunreadMailbox, MdMarkunreadMailbox,
MdMoveToInbox,
MdPlace, MdPlace,
MdTwoWheeler, MdTwoWheeler,
MdNotes, MdNotes,
@@ -61,7 +60,7 @@ import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLo
import { getConsignmentLogs } from 'pages/api/doormileApi'; import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { import {
STATUS_STYLES, onActivate,
getStatusStyle, getStatusStyle,
FINAL_STATUSES, FINAL_STATUSES,
SKIPPED_STATUSES, SKIPPED_STATUSES,
@@ -149,22 +148,9 @@ const BATCHES_DEFAULT_RAW = [
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 } { id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
]; ];
// v9: afternoon batch extended to 12:30 PM. Bumping from v8 wipes the // The `dispatch.slots.v1`…`v9` localStorage keys are no longer read or
// cached layouts that still hold the old endHour: 12 value. // written — batch windows are constants now (see BATCHES in the component).
const SLOTS_STORAGE_KEY = 'dispatch.slots.v9'; // Any leftover entries in an operator's browser are inert.
// Every prior storage key. Wiped once on mount so stale layouts
// from earlier code versions can't reappear on the next page load.
const LEGACY_SLOTS_STORAGE_KEYS = [
'dispatch.slots.v1',
'dispatch.slots.v2',
'dispatch.slots.v3',
'dispatch.slots.v4',
'dispatch.slots.v5',
'dispatch.slots.v6',
'dispatch.slots.v7',
'dispatch.slots.v8'
];
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a // Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
// fractional startHour (24h, half-hour steps). Mirrors the human-readable // fractional startHour (24h, half-hour steps). Mirrors the human-readable
@@ -218,12 +204,21 @@ const getBatchForHour = (h, batches) => {
// with a fallback to expecteddeliverytime so undelivered orders still bucket. // with a fallback to expecteddeliverytime so undelivered orders still bucket.
const TIME_FIELDS = [ const TIME_FIELDS = [
{ id: 'delivered', label: 'Delivered', keys: ['deliverytime'] }, { id: 'delivered', label: 'Delivered', keys: ['deliverytime'] },
// The batch-bucketing default. `expecteddeliverytime` is the booking's // The batch-bucketing default. `orderdate` is the booking's `createdat` —
// promised delivery slot (serviceoptions[0].estimateddeliveryat) — it is set // when the order came in. It is immutable, and it matches the operator's
// once and not re-stamped as the order progresses, which is exactly what a // mental model: an order created and assigned at 12:22 belongs to the
// "wave" needs. Do not point this at `assigntime`: that field is the // Afternoon wave, full stop.
// booking's last-modified column (see api.js) and drifts through the day. //
{ id: 'due', label: 'Due', keys: ['expecteddeliverytime'] }, // Two fields were tried here first and both were wrong:
// • `assigntime` — api.js maps it to the booking's last-modified column,
// so it is re-stamped by any status change and orders drifted into
// whichever batch held the current clock time.
// • `expecteddeliverytime` — stable, but it is the PROMISED DELIVERY slot,
// not the wave. An order created at 12:22 whose SLA puts its ETA at
// 16:30 counted towards Evening, so Evening showed orders that had not
// been placed yet. Bucketing on creation time also means no future batch
// can ever show a count for today, which is the correct behaviour.
{ id: 'created', label: 'Created', keys: ['orderdate'] },
{ id: 'assigned', label: 'Assigned', keys: ['assigntime'] }, { id: 'assigned', label: 'Assigned', keys: ['assigntime'] },
{ id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] }, { id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
{ id: 'started', label: 'Started', keys: ['starttime'] }, { id: 'started', label: 'Started', keys: ['starttime'] },
@@ -232,6 +227,11 @@ const TIME_FIELDS = [
{ id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] } { id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] }
]; ];
// The one time-field batch bucketing uses. Not operator-selectable — see the
// `created` entry in TIME_FIELDS for why the other candidates were rejected.
// deliveries.js's BATCH_TIME_KEYS must name the same row field.
const BATCH_TIME_FIELD = 'created';
const getTimeFieldValue = (r, fieldId) => { const getTimeFieldValue = (r, fieldId) => {
const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0]; const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0];
for (const k of field.keys) { for (const k of field.keys) {
@@ -416,10 +416,13 @@ const getStableRiderColor = (id) => {
return RIDER_COLORS[h % RIDER_COLORS.length]; return RIDER_COLORS[h % RIDER_COLORS.length];
}; };
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, // getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, STEP_PALETTE, stepColor —
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the // moved to ./dispatchShared.js so the extracted CompareDataPanel component can
// extracted CompareDataPanel component can import them without forcing // import them without forcing a circular dependency on Dispatch.js.
// a circular dependency on Dispatch.js. //
// getStatusStyle is now a thin adapter over themes/dt/status.js. The remaining
// call sites in this file are Leaflet divIcon HTML strings, which need a raw
// hex — normal JSX should render <StatusBadge> instead.
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => { const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
const map = useMap(); const map = useMap();
@@ -911,7 +914,6 @@ const Dispatch = ({
// Short-lived close timer for the general map order/marker popups. // Short-lived close timer for the general map order/marker popups.
// Gives the cursor a ~200ms window to travel from the marker onto the popup // Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close. // or vice versa without immediately triggering a close.
const activePopupMarkerRef = useRef(null);
const popupHoverTimerRef = useRef(null); const popupHoverTimerRef = useRef(null);
// Order shown in the centered popup overlay. Rendered outside the leaflet // Order shown in the centered popup overlay. Rendered outside the leaflet
// map (see `dispatch-popup-center` overlay near the bottom of the JSX) so // map (see `dispatch-popup-center` overlay near the bottom of the JSX) so
@@ -941,85 +943,24 @@ const Dispatch = ({
const [locationMenuOpen, setLocationMenuOpen] = useState(false); const [locationMenuOpen, setLocationMenuOpen] = useState(false);
const locationMenuRef = useRef(null); const locationMenuRef = useRef(null);
// Which timestamp column drives slot bucketing. Default = 'due' // Which timestamp column drives slot bucketing. Fixed at creation time —
// (expecteddeliverytime) so orders bucket into Morning/Afternoon/Evening by // see TIME_FIELDS. This was a useState with an operator-facing dropdown;
// the delivery slot they were promised for. The status-wise time-field // the dropdown was disabled for a correctness reason (picking "Assigned"
// dropdown is hidden for now (see commented-out block in JSX), so this stays // bucketed on a column the backend re-stamps, so orders visibly jumped
// fixed at 'due'. // batches) and its JSX has now been deleted rather than left commented out.
// A constant makes the lock explicit instead of relying on nothing calling
// the setter.
const selectedTimeField = BATCH_TIME_FIELD;
// Batch windows are fixed. They used to be operator-editable and persisted
// to localStorage, but the editor UI was disabled and is now deleted, so the
// state, the persist effect and the legacy-key cleanup all went with it.
// //
// Was 'assigned' (assigntime). That looked right — the spec says "bucket by // Dropping the localStorage READ is itself a fix: an operator who edited
// assign time" — but on the Doormile backend `assigntime` is mapped to the // their slot hours in an older build kept getting those stale custom hours
// booking's last-modified column (api.js), so it moves whenever the row is // forever, silently disagreeing with deliveries.js, which has always used
// touched. Today's live orders therefore migrated into whichever batch // the constants.
// contained the current clock time, and the 15s poll made the counts shift const BATCHES = BATCHES_DEFAULT;
// on their own. deliveries.js buckets on the same field for the same reason
// — the two pages must agree (see this folder's CLAUDE.md §1).
const [selectedTimeField, setSelectedTimeField] = useState('due');
const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false);
const timeFieldMenuRef = useRef(null);
// Operator-editable slot configuration. Seeded from localStorage so edits
// survive reloads; falls back to BATCHES_DEFAULT otherwise. Each entry has
// id/label/range/startHour/endHour just like the default list — the rest
// of the file reads BATCHES (derived below) without caring whether the
// values came from defaults or from operator edits.
const [slotsConfig, setSlotsConfig] = useState(() => {
if (typeof window === 'undefined') return BATCHES_DEFAULT;
try {
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
if (!raw) return BATCHES_DEFAULT;
const parsed = JSON.parse(raw);
// If the parsed slots length does not match BATCHES_DEFAULT_RAW, the data
// is stale (e.g. written from the older 5-slot layout). Discard it and
// load the default 3-batch layout.
if (!Array.isArray(parsed) || parsed.length !== BATCHES_DEFAULT_RAW.length) return BATCHES_DEFAULT;
// Re-derive label + range from the saved hours so any UI tweaks to the
// formatter (e.g. AM/PM style) flow through to old persisted slots. If
// the saved id matches a default batch, prefer that batch's friendly
// name ("Morning Batch", etc.) over the generated "Slot N · time" label.
return parsed.map((s, i) => {
const id = s.id || `slot-${i + 1}`;
const startHour = Number(s.startHour) || 0;
const endHour = Number(s.endHour) || 24;
const defaultMatch = BATCHES_DEFAULT.find((b) => b.id === id);
return {
id,
startHour,
endHour,
label: defaultMatch?.name || formatSlotLabel(i, startHour),
range: formatSlotRange(startHour, endHour)
};
});
} catch (e) {
return BATCHES_DEFAULT;
}
});
const BATCHES = slotsConfig;
const [slotEditOpen, setSlotEditOpen] = useState(false);
const slotEditRef = useRef(null);
// One-shot housekeeping: drop every prior slot-storage key so a stale
// layout written under an older schema version (e.g. v1/v2/v3 with five
// slots) can never resurface. Runs once per mount because deps are [].
useEffect(() => {
if (typeof window === 'undefined') return;
try {
LEGACY_SLOTS_STORAGE_KEYS.forEach((k) => window.localStorage.removeItem(k));
} catch (e) { /* private-mode / quota — ignore */ }
}, []);
// Persist edits whenever slotsConfig changes (skip the first render — the
// initializer already loaded from storage).
const slotsInitMountedRef = useRef(false);
useEffect(() => {
if (!slotsInitMountedRef.current) { slotsInitMountedRef.current = true; return; }
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify(
slotsConfig.map(({ id, startHour, endHour }) => ({ id, startHour, endHour }))
));
} catch (e) { /* quota / private-mode — ignore */ }
}, [slotsConfig]);
// Close the location dropdown on any click outside its wrapper. // Close the location dropdown on any click outside its wrapper.
useEffect(() => { useEffect(() => {
@@ -1033,30 +974,6 @@ const Dispatch = ({
return () => document.removeEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick);
}, [locationMenuOpen]); }, [locationMenuOpen]);
// Same click-outside behavior for the slot-time-field dropdown.
useEffect(() => {
if (!timeFieldMenuOpen) return;
const onDocClick = (e) => {
if (timeFieldMenuRef.current && !timeFieldMenuRef.current.contains(e.target)) {
setTimeFieldMenuOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [timeFieldMenuOpen]);
// Click-outside dismissal for the slot-edit popover.
useEffect(() => {
if (!slotEditOpen) return;
const onDocClick = (e) => {
if (slotEditRef.current && !slotEditRef.current.contains(e.target)) {
setSlotEditOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [slotEditOpen]);
// Rider Info view — operator picks a rider in the sidebar, the main panel // Rider Info view — operator picks a rider in the sidebar, the main panel
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode // shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
// ('rider-info') so it follows the same toggle pattern as the other modes. // ('rider-info') so it follows the same toggle pattern as the other modes.
@@ -2020,6 +1937,9 @@ const Dispatch = ({
// _ts on each ping to compute per-step dt, so we run it BEFORE // _ts on each ping to compute per-step dt, so we run it BEFORE
// stripping _ts on the way out. // stripping _ts on the way out.
const smoothed = kalmanSmoothGps(sorted); const smoothed = kalmanSmoothGps(sorted);
// The unused `_ts` binding IS the mechanism — destructuring it out is
// how the internal timestamp is stripped from each point.
// eslint-disable-next-line no-unused-vars
return smoothed.map(({ _ts, ...p }) => p); return smoothed.map(({ _ts, ...p }) => p);
}, },
enabled: compareOpen && focusedRider != null, enabled: compareOpen && focusedRider != null,
@@ -2742,7 +2662,15 @@ const Dispatch = ({
const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null; const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null;
return ( return (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s`, borderLeft: `3px solid ${r.color}` }}> <div
key={r.id}
className="rcard"
role="button"
tabIndex={0}
onClick={() => handleRiderFocus(r)}
onKeyDown={onActivate(() => handleRiderFocus(r))}
style={{ animationDelay: `${i * 0.05}s`, borderLeft: `3px solid ${r.color}` }}
>
<div className="rcard-top"> <div className="rcard-top">
<div className="rcard-emo" style={{ background: '#f1f5f9', borderColor: '#e2e8f0', color: '#64748b' }}><MdTwoWheeler /></div> <div className="rcard-emo" style={{ background: '#f1f5f9', borderColor: '#e2e8f0', color: '#64748b' }}><MdTwoWheeler /></div>
<div className="rcard-info"> <div className="rcard-info">
@@ -2972,7 +2900,7 @@ const Dispatch = ({
? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])) ? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]))
: null; : null;
return ordersToRender.map((o, idx) => { return ordersToRender.map((o) => {
const rid = o.rider_id; const rid = o.rider_id;
const active = rid ? activeRiders.has(rid) : true; const active = rid ? activeRiders.has(rid) : true;
let color = getRiderColor(rid); let color = getRiderColor(rid);
@@ -3650,170 +3578,6 @@ const Dispatch = ({
{shouldFetchLive && viewMode !== 'rider-info' && ( {shouldFetchLive && viewMode !== 'rider-info' && (
<div id="batch-row"> <div id="batch-row">
<span className="batch-label">Batch</span> <span className="batch-label">Batch</span>
{/* Status-wise (time-field) filter is hidden for now per spec —
bucketing is locked to `due` (expecteddeliverytime). Restore this
block to bring back the Delivered/Due/Assigned/... dropdown. Note
that picking "Assigned" there buckets on the booking's
last-modified column, which drifts through the day (see api.js).
<div className="time-field-wrap" ref={timeFieldMenuRef}>
<button
type="button"
className={`time-field-btn ${timeFieldMenuOpen ? 'open' : ''}`}
onClick={() => setTimeFieldMenuOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={timeFieldMenuOpen}
title="Bucket slots by this timestamp"
>
<MdAccessTime />
<span className="time-field-text">{TIME_FIELDS.find((f) => f.id === selectedTimeField)?.label || 'Delivered'}</span>
<MdExpandMore className="time-field-caret" />
</button>
{timeFieldMenuOpen && (
<div className="time-field-menu" role="listbox">
{TIME_FIELDS.map((f) => {
const isActive = f.id === selectedTimeField;
return (
<button
key={f.id}
type="button"
role="option"
aria-selected={isActive}
className={`time-field-option ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedTimeField(f.id);
setTimeFieldMenuOpen(false);
}}
>
<MdAccessTime className="time-field-option-icon" />
<span>{f.label}</span>
{isActive && <span className="time-field-option-check">✓</span>}
</button>
);
})}
</div>
)}
</div>
*/}
{/* Slot editor (Edit slots button + panel) is hidden for now per
spec — the three batches (Morning / Afternoon / Evening) are
fixed. Restore this block to bring back the operator-editable
start/end hours, add-slot, and reset-to-defaults controls.
<div className="slot-edit-wrap" ref={slotEditRef}>
<button
type="button"
className={`slot-edit-btn ${slotEditOpen ? 'open' : ''}`}
onClick={() => setSlotEditOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={slotEditOpen}
title="Adjust slot timings"
>
<MdAccessTime />
<span>Edit slots</span>
</button>
{slotEditOpen && (
<div className="slot-edit-panel" role="dialog" aria-label="Edit slot timings">
<div className="slot-edit-head">
<div className="slot-edit-title">Slot timings</div>
<div className="slot-edit-sub">Hours are 0–24 (24h clock). Half-hour steps allowed (e.g. 12.5 = 12:30). Start &lt; End.</div>
</div>
<div className="slot-edit-list">
{slotsConfig.map((s, idx) => (
<div key={s.id} className="slot-edit-row">
<span className="slot-edit-idx">{idx + 1}</span>
<label className="slot-edit-field">
<span className="slot-edit-field-label">Start</span>
<input
type="number"
min={0}
max={23.5}
step={0.5}
value={s.startHour}
onChange={(e) => {
const raw = parseFloat(e.target.value);
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0;
const v = Math.max(0, Math.min(23.5, snapped));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, startHour: v, label: formatSlotLabel(i, v), range: formatSlotRange(v, row.endHour) }
: row
));
}}
/>
</label>
<label className="slot-edit-field">
<span className="slot-edit-field-label">End</span>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={s.endHour}
onChange={(e) => {
const raw = parseFloat(e.target.value);
const snapped = Number.isFinite(raw) ? Math.round(raw * 2) / 2 : 0.5;
const v = Math.max(0.5, Math.min(24, snapped));
setSlotsConfig((cur) => cur.map((row, i) =>
i === idx
? { ...row, endHour: v, range: formatSlotRange(row.startHour, v) }
: row
));
}}
/>
</label>
<span className="slot-edit-preview" title={`${formatSlotLabel(idx, s.startHour)} — ${formatSlotRange(s.startHour, s.endHour)}`}>
{formatSlotRange(s.startHour, s.endHour)}
</span>
<button
type="button"
className="slot-edit-remove"
onClick={() => setSlotsConfig((cur) => cur.filter((_, i) => i !== idx).map((row, i) => ({
...row,
id: `slot-${i + 1}`,
label: formatSlotLabel(i, row.startHour)
})))}
disabled={slotsConfig.length <= 1}
title={slotsConfig.length <= 1 ? 'Keep at least one slot' : 'Remove this slot'}
>
×
</button>
</div>
))}
</div>
<div className="slot-edit-actions">
<button
type="button"
className="slot-edit-add"
onClick={() => setSlotsConfig((cur) => {
const last = cur[cur.length - 1];
const start = Math.min(23, (last?.endHour ?? 0));
const end = Math.min(24, start + 1);
const i = cur.length;
return [
...cur,
{
id: `slot-${i + 1}`,
startHour: start,
endHour: end,
label: formatSlotLabel(i, start),
range: formatSlotRange(start, end)
}
];
})}
>
+ Add slot
</button>
<button
type="button"
className="slot-edit-reset"
onClick={() => setSlotsConfig(BATCHES_DEFAULT)}
>
Reset to defaults
</button>
</div>
</div>
)}
</div>
*/}
{/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls {/* Inner scroller — keeps the "Slot" label fixed while the chip list scrolls
horizontally when it overflows. */} horizontally when it overflows. */}
<div className="batch-scroll"> <div className="batch-scroll">
@@ -3919,7 +3683,7 @@ const Dispatch = ({
{riderInfoIsError && ( {riderInfoIsError && (
<div className="ri-error"> <div className="ri-error">
Couldn't load this rider's log. {riderInfoError?.message || ''} Couldn’t load this rider’s log. {riderInfoError?.message || ''}
</div> </div>
)} )}
@@ -4313,12 +4077,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined} role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined} tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => { onKeyDown={canFocus ? onActivate(() => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })) : undefined}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined} title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
> >
<div className="zone-order-card-head"> <div className="zone-order-card-head">
@@ -4487,6 +4246,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined} role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined} tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? onActivate(() => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })) : undefined}
> >
<div className="zone-order-card-head"> <div className="zone-order-card-head">
<div className="zone-order-num">{o.step || idx + 1}</div> <div className="zone-order-num">{o.step || idx + 1}</div>
@@ -4632,6 +4392,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined} role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined} tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? onActivate(() => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon })) : undefined}
> >
<div className="zone-order-card-head"> <div className="zone-order-card-head">
<div className="zone-order-num"> <div className="zone-order-num">
@@ -4765,7 +4526,15 @@ const Dispatch = ({
const delivered = z.statusCounts.delivered || 0; const delivered = z.statusCounts.delivered || 0;
const profitNeg = z.totalProfit < 0; const profitNeg = z.totalProfit < 0;
return ( return (
<div key={z.id} className="rcard zone-card" onClick={() => setFocusedZone(z)} style={{ animationDelay: `${i * 0.05}s` }}> <div
key={z.id}
className="rcard zone-card"
role="button"
tabIndex={0}
onClick={() => setFocusedZone(z)}
onKeyDown={onActivate(() => setFocusedZone(z))}
style={{ animationDelay: `${i * 0.05}s` }}
>
<div className="zone-card-header"> <div className="zone-card-header">
<div className="zone-card-emoji"><MdMap /></div> <div className="zone-card-emoji"><MdMap /></div>
<div className="zone-card-titles"> <div className="zone-card-titles">
@@ -4844,7 +4613,15 @@ const Dispatch = ({
}) })
) : viewMode === 'kitchens' ? ( ) : viewMode === 'kitchens' ? (
kitchens.map((k, i) => ( kitchens.map((k, i) => (
<div key={k.id} className="rcard" onClick={() => setFocusedKitchen(k)} style={{ animationDelay: `${i * 0.05}s` }}> <div
key={k.id}
className="rcard"
role="button"
tabIndex={0}
onClick={() => setFocusedKitchen(k)}
onKeyDown={onActivate(() => setFocusedKitchen(k))}
style={{ animationDelay: `${i * 0.05}s` }}
>
<div className="rcard-top"> <div className="rcard-top">
<div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div> <div className="rcard-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div>
<div className="rcard-info"> <div className="rcard-info">
@@ -5203,15 +4980,9 @@ const Dispatch = ({
(o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid) (o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid)
); );
const statusStyle = getStatusStyle(t.orderstatus); // A status flag SVG used to be built here and never rendered —
const flagSvg = t.orderstatus // this marker draws `dropPinHtml` below instead. Removed with
? `<svg class="cmark-flag" viewBox="0 0 18 22" xmlns="http://www.w3.org/2000/svg"> // the getStatusStyle lookup that only fed it.
<line x1="1.5" y1="0" x2="1.5" y2="22" stroke="#0f172a" stroke-width="1.6" stroke-linecap="round"/>
<polygon points="2,1 17,1 13.5,5.5 17,10 2,10" fill="${statusStyle.bg}" stroke="#0f172a" stroke-width="0.6" stroke-linejoin="round"/>
${isDelivered ? '<polyline points="5,5.5 7,7.5 11,3.5" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>' : ''}
</svg>`
: '';
const dropClasses = ['compare-step-pin']; const dropClasses = ['compare-step-pin'];
if (isFocusedStep) dropClasses.push('is-focused'); if (isFocusedStep) dropClasses.push('is-focused');
if (isDelivered) dropClasses.push('is-delivered'); if (isDelivered) dropClasses.push('is-delivered');
@@ -6627,14 +6398,22 @@ const Dispatch = ({
{riderPositionModal && ( {riderPositionModal && (
<div <div
className="da-pos-modal-backdrop" className="da-pos-modal-backdrop"
role="dialog" // The dialog role belongs on the card below, not on the backdrop —
aria-modal="true" // the backdrop is a click-outside-to-dismiss scrim, which is
aria-label={`Last known position of ${riderPositionModal.name}`} // presentational. Marking it as the dialog made it a non-interactive
// element carrying a click handler. Dismissal is still available from
// the card's own close button, so no keyboard path is lost here.
role="presentation"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) setRiderPositionModal(null); if (e.target === e.currentTarget) setRiderPositionModal(null);
}} }}
> >
<div className="da-pos-modal-card"> <div
className="da-pos-modal-card"
role="dialog"
aria-modal="true"
aria-label={`Last known position of ${riderPositionModal.name}`}
>
<div className="da-pos-modal-head"> <div className="da-pos-modal-head">
<div className="da-pos-modal-title-wrap"> <div className="da-pos-modal-title-wrap">
<div <div

View File

@@ -1,33 +1,28 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import logger from '../../../utils/logger'; import logger from '../../../utils/logger';
import {
Autocomplete,
Backdrop,
Box,
Button,
Card,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { HiOutlineArrowLeft } from 'react-icons/hi'; import { HiOutlineArrowLeft } from 'react-icons/hi';
import { IoReload } from 'react-icons/io5'; import { IoReload } from 'react-icons/io5';
import { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md'; import { MdTwoWheeler, MdSwapHoriz, MdWarning, MdArrowBack } from 'react-icons/md';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Badge } from '@astryxdesign/core/Badge';
import { Banner } from '@astryxdesign/core/Banner';
import { Selector } from '@astryxdesign/core/Selector';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { import {
buildMilerLookup, buildMilerLookup,
@@ -43,7 +38,66 @@ import { OpenToast } from 'components/third-party/OpenToast';
import CSVExport from 'components/third-party/ReactTable'; import CSVExport from 'components/third-party/ReactTable';
import CircularLoader from 'components/CircularLoader'; import CircularLoader from 'components/CircularLoader';
import Dispatch from './Dispatch'; import Dispatch from './Dispatch';
import { stepColor } from './dispatchShared'; import { stepColor, useIsNarrowViewport } from './dispatchShared';
import { STATUS } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
// Numbered route-step marker. Kept as a small custom component rather than an
// Astryx primitive: it is a sequence marker carrying a per-step palette colour
// plus an unverified-rider warning overlay, which no Astryx component models.
// `isUnverified` is tinted from the STATUS palette rather than a raw hex.
function StepChip({ order, stepNum, isUnverified, onClick }) {
const color = isUnverified ? STATUS.error : stepColor(Number(stepNum) - 1);
const hint = isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider';
return (
<Tooltip content={`Order #${order.orderid} · ${order.deliveryaddress || order.deliverysuburb || ''} — ${hint}`}>
{/* A real <button>, not a styled Center: this is an interactive control,
so the native element gives keyboard activation and focus for free.
Astryx's Center has no `as` prop, and its own Button can't express a
36px circular sequence marker. */}
<button
type="button"
onClick={onClick}
aria-label={`Step ${stepNum}, order ${order.orderid}. ${hint}`}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
padding: 0,
borderRadius: '50%',
border: 'none',
backgroundColor: color,
color: '#fff',
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow: isUnverified
? `0 0 0 2px #fff, 0 0 0 4px ${STATUS.error}`
: '0 0 0 2px rgba(255,255,255,0.6)'
}}
>
{stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{ position: 'absolute', top: -5, right: -5, color: STATUS.error, background: '#fff', borderRadius: '50%' }}
/>
)}
</button>
</Tooltip>
);
}
StepChip.propTypes = {
order: PropTypes.object.isRequired,
stepNum: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
isUnverified: PropTypes.bool,
onClick: PropTypes.func
};
const tuningTypes = [ const tuningTypes = [
{ tuneid: 1, type: 'Balanced', value: 'balanced' }, { tuneid: 1, type: 'Balanced', value: 'balanced' },
@@ -300,8 +354,7 @@ const Preview = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const stateData = location.state || {}; const stateData = location.state || {};
const theme = useTheme(); const isMobile = useIsNarrowViewport();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes // SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
// through this state. The Dispatch tab renders from it, the Reconcile tab // through this state. The Dispatch tab renders from it, the Reconcile tab
@@ -363,6 +416,21 @@ const Preview = () => {
staleTime: 5 * 60 * 1000 staleTime: 5 * 60 * 1000
}); });
// Options for the Change Rider picker. Astryx's Selector is value-based
// (string in, string out) where MUI's Autocomplete handed back the whole
// option object, so each entry keeps a reference to its raw rider — that is
// what `confirmChangeRider` reads (`.userid`, `.label`, `.firstname`,
// `.lastname`), and that function is frozen logic for this conversion.
const riderOptions = useMemo(
() =>
(ridersList || []).map((r) => ({
value: String(r.userid),
label: r.label || `${r.firstname || ''} ${r.lastname || ''}`.trim() || `Rider ${r.userid}`,
rider: r
})),
[ridersList]
);
// Derived: rider list for the Reconcile tab. Recomputes whenever the cache // Derived: rider list for the Reconcile tab. Recomputes whenever the cache
// (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect // (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect
// here without a separate state. // here without a separate state.
@@ -668,106 +736,100 @@ const Preview = () => {
// embeds that same component below) — MainLayout's `.main-content-area` // embeds that same component below) — MainLayout's `.main-content-area`
// has 24px left/right/bottom padding and no padding-top (AppTopNav sits // has 24px left/right/bottom padding and no padding-top (AppTopNav sits
// above it in normal flow), and exposes its own measured height via the // above it in normal flow), and exposes its own measured height via the
// --appshell-header-height CSS var. A bare `height: '100vh'` here (the // --appshell-header-height CSS var. A bare `height: '100vh'` here doesn't
// previous value) doesn't subtract the top nav's height or cancel the // subtract the top nav's height or cancel the surrounding padding, so the
// surrounding padding, so the page's real content was taller than the // page's real content was taller than the visible viewport by roughly the
// visible viewport by roughly the nav height + padding — turning the // nav height + padding — turning the whole page (not just the intended
// whole page (not just the intended internal panels) into a scroll // internal panels) into a scroll container, which dragged the rider
// container, which dragged the rider sidebar along with it instead of // sidebar along with it instead of it staying in place.
// it staying in place. <VStack
<Box gap={0}
sx={{ padding={0}
display: 'flex', style={{
flexDirection: 'column',
height: 'calc(100vh - var(--appshell-header-height, 64px))', height: 'calc(100vh - var(--appshell-header-height, 64px))',
margin: '0 -24px -24px -24px', margin: '0 -24px -24px -24px',
overflow: 'hidden', overflow: 'hidden',
position: 'relative' position: 'relative'
}} }}
> >
<Backdrop {isLoading && (
sx={{ position: 'absolute', color: '#fff', zIndex: (theme) => theme.zIndex.modal + 1 }} <Center
open={isLoading} style={{
> position: 'absolute',
<CircularLoader color="inherit" /> inset: 0,
</Backdrop> zIndex: 1400,
backgroundColor: 'var(--color-overlay)'
<Box sx={{ py: 1.25, px: 2, borderBottom: '1px solid #eef2f6' }}> }}
<Stack
direction={{ xs: 'column', md: 'row' }}
alignItems={{ xs: 'stretch', md: 'center' }}
justifyContent="space-between"
spacing={1.25}
> >
<Stack direction="row" alignItems="center" spacing={1}> <CircularLoader color="inherit" />
<Tooltip title="Back to orders" placement="top"> </Center>
<IconButton )}
onClick={() => navigate('/doormile/orders')}
sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }} <VStack
> gap={0}
<HiOutlineArrowLeft size={20} /> paddingInline={2}
</IconButton> paddingBlock={1.5}
</Tooltip> style={{ borderBottom: '1px solid var(--color-border)' }}
<Typography variant="h3" fontWeight={600}> >
Assign Orders <HStack justify="between" vAlign="center" wrap="wrap" gap={1.5}>
</Typography> <HStack gap={1} vAlign="center">
</Stack> <IconButton
<Stack label="Back to orders"
direction={{ xs: 'column', sm: 'row' }} tooltip="Back to orders"
alignItems={{ xs: 'stretch', sm: 'center' }} variant="ghost"
spacing={1} icon={<HiOutlineArrowLeft size={20} />}
sx={{ width: { xs: '100%', md: 'auto' } }} onClick={() => navigate('/doormile/orders')}
>
<Autocomplete
options={tuningTypes || []}
getOptionLabel={(option) => option.type}
sx={{ minWidth: { xs: 0, sm: 250 }, maxWidth: 600, flex: 1, width: { xs: '100%', sm: 'auto' } }}
renderInput={(params) => <TextField {...params} label="Hyper Tuning" />}
onChange={(e, val, reason) => {
if (reason === 'clear') handleCreateDelivery(null);
else handleCreateDelivery(val.value);
}}
/> />
<Heading level={2}>Assign Orders</Heading>
</HStack>
<HStack gap={1} vAlign="center" wrap="wrap">
<div style={{ minWidth: 220 }}>
<Selector
label="Hyper Tuning"
isLabelHidden
placeholder="Hyper Tuning"
options={tuningTypes.map((t) => ({ value: t.value, label: t.type }))}
onChange={(value) => handleCreateDelivery(value || null)}
/>
</div>
<Button <Button
variant="contained" label="Re-Assign"
color="primary" variant="primary"
startIcon={<IoReload />} icon={<IoReload />}
fullWidth={isMobile} width={isMobile ? '100%' : undefined}
onClick={() => { onClick={() => {
setIsLoading(true); setIsLoading(true);
handleCreateDelivery('reshuffle'); handleCreateDelivery('reshuffle');
}} }}
> />
Re-Assign
</Button>
<CSVExport <CSVExport
data={csvExportData} data={csvExportData}
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`} filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
label=" CSV" label=" CSV"
style={{ m: 1 }}
/> />
</Stack> </HStack>
</Stack> </HStack>
</Box> </VStack>
<Box sx={{ px: 2, borderBottom: '1px solid #eef2f6' }}> <VStack gap={0} paddingInline={2} padding={0}>
<Tabs <TabList
value={tabValue} value={String(tabValue)}
onChange={(e, v) => setTabValue(v)} onChange={(v) => setTabValue(Number(v))}
variant={isMobile ? 'fullWidth' : 'standard'} layout={isMobile ? 'fill' : 'hug'}
sx={{ minHeight: 40 }} hasDivider
> >
<Tab label="Dispatch" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} /> <Tab value="0" label="Dispatch" />
<Tab label="Reconcile" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} /> <Tab value="1" label="Reconcile" />
</Tabs> </TabList>
</Box> </VStack>
{/* minHeight: 0 overrides the flex-item default of min-height: auto — {/* minHeight 0 overrides the flex-item default of min-height: auto —
without it, this flex:1 child can grow past its allotted space to without it, this flex:1 child can grow past its allotted space to
fit its content's natural height instead of clipping to it, which fit its content's natural height instead of clipping to it, which
is what let the embedded Dispatch view (and its internal is what let the embedded Dispatch view (and its internal
overflow-y:auto sidebar) overflow into page-level scroll. */} overflow-y:auto sidebar) overflow into page-level scroll. */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}> <VStack gap={0} padding={0} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{tabValue === 0 && dispatchPreviewData && ( {tabValue === 0 && dispatchPreviewData && (
<Dispatch <Dispatch
// The key forces a full re-mount when the cache reference changes // The key forces a full re-mount when the cache reference changes
@@ -781,232 +843,167 @@ const Preview = () => {
onChangeRider={(order, focusedRider) => openChangeRider(focusedRider, order)} onChangeRider={(order, focusedRider) => openChangeRider(focusedRider, order)}
/> />
)} )}
{tabValue === 1 && ( {tabValue === 1 && (
<Box sx={{ flex: 1, overflow: 'auto', p: 2, bgcolor: '#f8fafc' }}> <VStack gap={2} padding={2} isScrollable style={{ flex: 1 }}>
{reconcileRiders.length === 0 ? ( {reconcileRiders.length === 0 ? (
<Typography sx={{ color: '#94a3b8', textAlign: 'center', mt: 4 }}> <EmptyState
No rider data available to reconcile. title="Nothing to reconcile"
</Typography> description="No rider data available to reconcile."
icon={<MdSwapHoriz size={28} />}
/>
) : ( ) : (
<Stack spacing={1.75}> <>
<Box <Banner
sx={{ status={hasReconciled ? 'success' : 'warning'}
bgcolor: hasReconciled ? '#ecfdf5' : '#fffbeb', title={
border: `1px solid ${hasReconciled ? '#a7f3d0' : '#fde68a'}`, hasReconciled
color: hasReconciled ? '#065f46' : '#92400e', ? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
borderRadius: '10px', : 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'
px: 1.5, }
py: 1, />
fontSize: 13
}}
>
{hasReconciled
? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.'
: 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'}
</Box>
{reconcileRiders.map((r) => { {reconcileRiders.map((r) => {
const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0); const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
return ( return (
<Card key={r.rider_id} sx={{ p: { xs: 1.5, md: 2 }, borderRadius: '12px', boxShadow: '0 1px 3px rgba(15,23,42,0.06)' }}> <Card key={r.rider_id} padding={3}>
<Stack <HStack justify="between" vAlign="center" wrap="wrap" gap={1}>
direction="row" <HStack gap={1.5} vAlign="center">
justifyContent="space-between" <AccentAvatar color={STATUS.accepted} size={32}>
alignItems="center"
sx={{ mb: 1.25, flexWrap: 'wrap', gap: 1 }}
>
<Stack direction="row" alignItems="center" gap={1.25}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: '8px',
bgcolor: '#eef2ff',
color: '#4f46e5',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<MdTwoWheeler size={18} /> <MdTwoWheeler size={18} />
</Box> </AccentAvatar>
<Box> <VStack gap={0} padding={0}>
<Typography sx={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}> <Text weight="bold">{r.rider_name}</Text>
{r.rider_name} <Text size="sm" type="supporting">
</Typography>
<Typography sx={{ fontSize: 11.5, color: '#64748b' }}>
ID: {r.rider_id} ID: {r.rider_id}
</Typography> </Text>
</Box> </VStack>
</Stack> </HStack>
<Stack direction="row" gap={1}> <HStack gap={1} vAlign="center">
<Chip size="small" label={`${r.orders.length} stops`} sx={{ fontWeight: 600 }} /> <Badge variant="neutral" label={`${r.orders.length} stops`} />
<Chip size="small" label={`${totalKms.toFixed(1)} km`} variant="outlined" /> <Badge variant="neutral" label={`${totalKms.toFixed(1)} km`} />
</Stack> </HStack>
</Stack> </HStack>
<Stack direction="row" gap={1.25} sx={{ flexWrap: 'wrap', alignItems: 'center' }}> <HStack gap={1.5} wrap="wrap" vAlign="center" style={{ marginTop: 12 }}>
{r.orders.map((o, idx) => { {r.orders.map((o, idx) => (
const stepNum = o.step ?? idx + 1; <StepChip
const color = stepColor(Number(stepNum) - 1); key={`${o.orderid}-${idx}`}
const isUnverified = unverifiedOrderIds.has(String(o.orderid)); order={o}
return ( stepNum={o.step ?? idx + 1}
<Tooltip isUnverified={unverifiedOrderIds.has(String(o.orderid))}
key={`${o.orderid}-${idx}`} onClick={() => openChangeRider(r, o)}
title={ />
<Box> ))}
<div>Order #{o.orderid}</div> </HStack>
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
<div style={{ marginTop: 4, opacity: 0.8 }}>
{isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider'}
</div>
</Box>
}
>
<Box
onClick={() => openChangeRider(r, o)}
sx={{
position: 'relative',
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: isUnverified ? '#ef4444' : color,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 800,
fontSize: 14,
cursor: 'pointer',
boxShadow: isUnverified
? '0 0 0 2px #fff, 0 0 0 4px #ef4444, 0 1px 3px rgba(15,23,42,0.15)'
: '0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
transition: 'transform 0.15s',
'&:hover': { transform: 'scale(1.08)' }
}}
>
{stepNum}
{isUnverified && (
<MdWarning
size={14}
style={{
position: 'absolute',
top: -5,
right: -5,
color: '#ef4444',
background: '#fff',
borderRadius: '50%'
}}
/>
)}
</Box>
</Tooltip>
);
})}
</Stack>
</Card> </Card>
); );
})} })}
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 2 }}> <Center style={{ paddingTop: 12, paddingBottom: 16 }}>
<Button <Button
variant="contained" label={
color="primary" reconcileLoading
size="large" ? 'Reconciling…'
startIcon={<MdSwapHoriz />} : dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`
}
variant="primary"
size="lg"
icon={<MdSwapHoriz />}
isLoading={reconcileLoading}
isDisabled={reconcileLoading || dirtyRiderIds.size === 0}
width={isMobile ? '100%' : 220}
onClick={handleReconcile} onClick={handleReconcile}
disabled={reconcileLoading || dirtyRiderIds.size === 0} />
fullWidth={isMobile} </Center>
sx={{ minWidth: { xs: 0, sm: 220 }, borderRadius: '10px', textTransform: 'none', fontWeight: 700 }} </>
>
{reconcileLoading
? 'Reconciling...'
: dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`}
</Button>
</Box>
</Stack>
)} )}
</Box> </VStack>
)} )}
</Box> </VStack>
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #eef2f6' }}> <VStack
<Stack gap={0}
direction={{ xs: 'column-reverse', sm: 'row' }} paddingInline={2}
gap={{ xs: 1, sm: 2 }} paddingBlock={1.5}
alignItems={{ xs: 'stretch', sm: 'center' }} style={{ borderTop: '1px solid var(--color-border)' }}
justifyContent="end" >
> <HStack gap={2} vAlign="center" justify="end" wrap="wrap">
<Button <Button
variant="contained" label="Back"
color="secondary" variant="secondary"
startIcon={<ArrowBackIcon />} icon={<MdArrowBack size={16} />}
fullWidth={isMobile} width={isMobile ? '100%' : undefined}
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> />
Back {/* Assign stays disabled until every edited rider has been
</Button> reconciled and no order is left on an unrecognized rider. This
gating is the UI half of the reconcile-before-assign rule (see
this folder's CLAUDE.md §3) — do not relax it. Button uses
aria-disabled when a tooltip is present, so it stays focusable
and the reason remains discoverable. */}
<Tooltip <Tooltip
title={ content={
dirtyRiderIds.size > 0 dirtyRiderIds.size > 0
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
: unverifiedOrderIds.size > 0 : unverifiedOrderIds.size > 0
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first` ? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
: '' : 'Commit these assignments'
} }
> >
<span style={isMobile ? { width: '100%' } : undefined}> <Button
<Button label="Assign Orders"
variant="contained" variant="primary"
fullWidth={isMobile} width={isMobile ? '100%' : undefined}
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0} isDisabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery} onClick={handleFinalCreateDelivery}
> />
Assign Orders
</Button>
</span>
</Tooltip> </Tooltip>
</Stack> </HStack>
</Box> </VStack>
<Dialog <Dialog
open={changeDialogOpen} isOpen={changeDialogOpen}
onClose={() => setChangeDialogOpen(false)} onOpenChange={(next) => !next && setChangeDialogOpen(false)}
maxWidth="xs" width={420}
fullWidth purpose="form"
fullScreen={isMobile}
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}
> >
<DialogTitle sx={{ fontWeight: 700 }}>Change Rider</DialogTitle> <DialogHeader title="Change Rider" />
<DialogContent> <VStack gap={3} padding={4}>
<Typography sx={{ mb: 2, fontSize: 13, color: 'text.secondary' }}> <Text type="supporting">
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to: Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
</Typography> </Text>
<Autocomplete <Selector
options={ridersList || []} label="New rider"
getOptionLabel={(o) => placeholder="Pick a rider"
o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || '' options={riderOptions}
} value={selectedNewRider ? String(selectedNewRider.userid) : null}
value={selectedNewRider} onChange={(value) => setSelectedNewRider(riderOptions.find((o) => o.value === value)?.rider ?? null)}
onChange={(e, val) => setSelectedNewRider(val)}
renderInput={(params) => <TextField {...params} label="New rider" placeholder="Pick a rider" />}
/> />
</DialogContent> <HStack gap={2} justify="end" wrap="wrap">
<DialogActions sx={{ px: 3, pb: 2, flexDirection: { xs: 'column-reverse', sm: 'row' }, gap: { xs: 1, sm: 0 } }}> <Button
<Button fullWidth={isMobile} onClick={() => setChangeDialogOpen(false)}> label="Cancel"
Cancel variant="secondary"
</Button> width={isMobile ? '100%' : undefined}
<Button variant="contained" fullWidth={isMobile} disabled={!selectedNewRider} onClick={confirmChangeRider}> onClick={() => setChangeDialogOpen(false)}
Change Rider />
</Button> <Button
</DialogActions> label="Change Rider"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={!selectedNewRider}
onClick={confirmChangeRider}
/>
</HStack>
</VStack>
</Dialog> </Dialog>
</Box> </VStack>
); );
}; };
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the // Mirrors the orders.js deliveryamt recalc — applied at render-time so the
// Assign payload always reflects the current cache without a useEffect. // Assign payload always reflects the current cache without a useEffect.
function computeDeliveryAmounts(list) { function computeDeliveryAmounts(list) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,35 @@
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { import {
MdTrendingUp, MdTrendingUp,
MdTrendingDown, MdTrendingDown,
MdExpandMore,
MdReceipt, MdReceipt,
MdPayments, MdPayments,
MdRoute, MdRoute,
MdLocationOn, MdLocationOn,
MdBarChart, MdPeopleAlt,
MdPeopleAlt MdStraighten,
MdAccountBalanceWallet,
MdOutlineSavings
} from 'react-icons/md'; } from 'react-icons/md';
import './ProfitabilitySection.css';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Heading } from '@astryxdesign/core/Heading';
import { Collapsible } from '@astryxdesign/core/Collapsible';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { STATUS } from 'themes/dt/tokens';
import { AccentAvatar, TableScroll } from 'themes/dt/primitives';
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Constants // Constants
@@ -34,31 +50,26 @@ export const FIXED_COST_PER_SLOT = 500 / 3;
/** Variable fuel / wear cost per km. */ /** Variable fuel / wear cost per km. */
export const VARIABLE_RATE_KM = 2.5; export const VARIABLE_RATE_KM = 2.5;
/** Status display config keyed by normalised status string. */ // Status colours previously lived in a local STATUS_MAP here — the third rival
const STATUS_MAP = { // status table in this folder. It disagreed with both of the others (it painted
delivered: { label: 'Delivered', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' }, // `active` sky-blue and had an `assigned` state the canonical table calls
active: { label: 'Active', color: '#0ea5e9', bg: '#f0f9ff', border: '#bae6fd' }, // `accepted`), so the same order could render three different colours depending
picked: { label: 'Picked up', color: '#8b5cf6', bg: '#f5f3ff', border: '#ddd6fe' }, // on which panel drew it. Statuses now render via <StatusBadge>, which reads
assigned: { label: 'Assigned', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' }, // themes/dt/status.js. See this folder's CLAUDE.md §1b.
cancelled: { label: 'Cancelled', color: '#ef4444', bg: '#fef2f2', border: '#fca5a5' },
skipped: { label: 'Skipped', color: '#f97316', bg: '#fff7ed', border: '#ffedd5' }
};
const DEFAULT_STATUS = STATUS_MAP.assigned;
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Pure helpers // Pure helpers
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
function getStatusConfig(raw) {
return STATUS_MAP[String(raw ?? '').toLowerCase()] ?? DEFAULT_STATUS;
}
function orderRevenue(order) { function orderRevenue(order) {
const km = parseFloat(order.riderkms || 0); const km = parseFloat(order.riderkms || 0);
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM; return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
} }
// NOTE: a fourth copy of the batch windows. Dispatch.js (BATCHES_DEFAULT_RAW),
// deliveries.js and this file must agree — see the dispatch folder's CLAUDE.md
// §1. reports/profitability.js imports this one, which is why it stays exported
// here rather than moving wholesale into dispatchShared.
export const BATCHES_DEFAULT = [ export const BATCHES_DEFAULT = [
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 }, { id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 }, { id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
@@ -72,16 +83,35 @@ const getBatchForHour = (h, batches = BATCHES_DEFAULT) => {
return null; return null;
}; };
// The row field a slot is derived from. Must match Dispatch.js's
// BATCH_TIME_FIELD ('created' → `orderdate`) and deliveries.js's
// BATCH_TIME_KEYS.
//
// This used to read `assigntime || deliverydate` with a bare `dayjs()`. Both
// halves were wrong: `assigntime` is mapped to the booking's last-modified
// column (api.js), so it is re-stamped by any status change and a rider's slot
// count drifted through the day; and bare `dayjs()` mis-parses the false
// trailing Z some Doormile timestamps carry, shifting a row +5:30 into a
// different slot. Slot count feeds the FIXED cost, so both bugs moved money.
const getRowBatch = (r, batches = BATCHES_DEFAULT) => { const getRowBatch = (r, batches = BATCHES_DEFAULT) => {
const t = r?.assigntime || r?.deliverydate; const t = r?.orderdate;
if (!t) return null; if (!t) return null;
const str = String(t).trim(); const str = String(t).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = dayjs(t); const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null; if (!d.isValid()) return null;
return getBatchForHour(d.hour() + d.minute() / 60, batches); return getBatchForHour(d.hour() + d.minute() / 60, batches);
}; };
// The calendar day a row belongs to — same field as the bucketing above, and
// the same field api.js's fetchDeliveries filters its date range on.
const rowDay = (o) => {
const t = o?.orderdate;
if (!t) return null;
const d = parseDoormileTimestamp(t);
return d.isValid() ? d.format('YYYY-MM-DD') : null;
};
// Orders in these statuses never delivered, so they earn no revenue and // Orders in these statuses never delivered, so they earn no revenue and
// shouldn't be charged fuel/slot cost either — including them here made // shouldn't be charged fuel/slot cost either — including them here made
// every rider's profit/margin KPI count cancelled and skipped stops as if // every rider's profit/margin KPI count cancelled and skipped stops as if
@@ -94,15 +124,7 @@ function calcRiderMetrics(rider, selectedDate, batches) {
// context. Only the money/km math below excludes them. // context. Only the money/km math below excludes them.
const orders = (rider.orders ?? []).filter((o) => { const orders = (rider.orders ?? []).filter((o) => {
if (!selectedDate) return true; if (!selectedDate) return true;
// parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z return rowDay(o) === selectedDate;
// parse bug (utils/doormileTimestamp.js) — near a midnight boundary an
// unstripped Z could shift a row +5:30 out of selectedDate entirely.
const dateStr = o.assigntime
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: 'unknown';
return dateStr === selectedDate;
}); });
const billableOrders = orders.filter((o) => { const billableOrders = orders.filter((o) => {
const status = String(o.orderstatus ?? o.status ?? '').toLowerCase(); const status = String(o.orderstatus ?? o.status ?? '').toLowerCase();
@@ -119,11 +141,7 @@ function calcRiderMetrics(rider, selectedDate, batches) {
const slot = getRowBatch(o, batches); const slot = getRowBatch(o, batches);
if (!slot) continue; if (!slot) continue;
const dateStr = o.assigntime const dateStr = rowDay(o);
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: null;
if (!dateStr) continue; if (!dateStr) continue;
if (!slotsByDate[dateStr]) { if (!slotsByDate[dateStr]) {
@@ -150,9 +168,9 @@ function calcRiderMetrics(rider, selectedDate, batches) {
function formatDistance(km) { function formatDistance(km) {
const value = Number.isFinite(km) ? km : 0; const value = Number.isFinite(km) ? km : 0;
if (value > 0 && value < 1) { if (value > 0 && value < 1) {
return { value: String(Math.round(value * 1000)), unit: 'm' }; return `${Math.round(value * 1000)} m`;
} }
return { value: value.toFixed(1), unit: 'km' }; return `${value.toFixed(1)} km`;
} }
function rupees(v, decimals = 0) { function rupees(v, decimals = 0) {
@@ -177,21 +195,15 @@ function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
// Money is not a lifecycle status, so it must NOT go through StatusBadge —
// it reads the raw hexes from the STATUS palette instead, which is the
// sanctioned "arbitrary accent" channel (root CLAUDE.md §6.3).
const moneyColor = (isProfit) => (isProfit ? STATUS.success : STATUS.error);
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Sub-components // Sub-components
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** Coloured pill with a status dot. */
function OrderStatusPill({ status }) {
const cfg = getStatusConfig(status);
return (
<span className="order-status-pill" style={{ background: cfg.bg, color: cfg.color, borderColor: cfg.border }}>
<span className="order-status-dot" aria-hidden="true" />
{cfg.label}
</span>
);
}
/** Customer name + phone + location stacked cell. */ /** Customer name + phone + location stacked cell. */
function CustomerCell({ order }) { function CustomerCell({ order }) {
const name = order.customername ?? order.customer_name ?? order.deliverycustomer ?? order.pickupcustomer ?? 'Unknown customer'; const name = order.customername ?? order.customer_name ?? order.deliverycustomer ?? order.pickupcustomer ?? 'Unknown customer';
@@ -199,18 +211,26 @@ function CustomerCell({ order }) {
const location = order.locationname ?? order.kitchenname ?? order.kitchenName ?? ''; const location = order.locationname ?? order.kitchenname ?? order.kitchenName ?? '';
return ( return (
<div> <VStack gap={0.5} padding={0}>
<div className="customer-name" title={name}> <Text weight="bold" maxLines={1}>
{name} {name}
</div> </Text>
{phone && <div className="customer-phone">{phone}</div>} {phone && (
{location && ( <Text size="sm" type="supporting">
<div className="customer-location"> {phone}
<MdLocationOn size={12} aria-hidden="true" /> </Text>
{location}
</div>
)} )}
</div> {location && (
<HStack gap={0.5} vAlign="center">
<Center isInline width={12} height={12} style={{ color: 'var(--color-icon-secondary)' }}>
<MdLocationOn size={12} />
</Center>
<Text size="sm" type="supporting" maxLines={1}>
{location}
</Text>
</HStack>
)}
</VStack>
); );
} }
@@ -218,64 +238,79 @@ function CustomerCell({ order }) {
function OrdersBreakdownTable({ orders, getRevenue }) { function OrdersBreakdownTable({ orders, getRevenue }) {
if (!orders?.length) { if (!orders?.length) {
return ( return (
<div className="orders-empty-state" role="status"> <EmptyState
<MdReceipt size={24} aria-hidden="true" /> title="No orders yet"
<p className="orders-empty-message">No orders assigned to this rider yet.</p> description="No orders assigned to this rider yet."
</div> icon={<MdReceipt size={24} />}
/>
); );
} }
return ( return (
<div className="orders-table-container"> <TableScroll minWidth={720} maxHeight={360}>
<table className="orders-table" role="table"> <Table density="compact" dividers="rows" hasHover>
<thead> <TableHeader>
<tr> <TableRow>
<th scope="col">Customer</th> <TableHeaderCell>Customer</TableHeaderCell>
<th scope="col">Status</th> <TableHeaderCell>Status</TableHeaderCell>
<th scope="col">Planned KMs</th> <TableHeaderCell>Planned KMs</TableHeaderCell>
<th scope="col">Actual KMs</th> <TableHeaderCell>Actual KMs</TableHeaderCell>
<th scope="col">Trip KMs</th> <TableHeaderCell>Trip KMs</TableHeaderCell>
<th scope="col" style={{ textAlign: 'right' }}> <TableHeaderCell>Revenue</TableHeaderCell>
Revenue </TableRow>
</th> </TableHeader>
</tr> <TableBody>
</thead> {orders.map((order, idx) => (
<tbody> <TableRow key={`${order.orderid ?? 'order'}-${idx}`}>
{orders.map((order, idx) => { <TableCell>
const km = parseFloat(order.riderkms ?? 0); <CustomerCell order={order} />
const dist = formatDistance(km); </TableCell>
const plannedDist = formatDistance(parseFloat(order.kms ?? 0)); <TableCell>
const actualDist = formatDistance(parseFloat(order.actualkms ?? 0)); <StatusBadge status={order.orderstatus ?? order.status} />
const rev = getRevenue(order); </TableCell>
return ( <TableCell>
<tr key={`${order.orderid ?? 'order'}-${idx}`}> <Text hasTabularNumbers>{formatDistance(parseFloat(order.kms ?? 0))}</Text>
<td> </TableCell>
<CustomerCell order={order} /> <TableCell>
</td> <Text hasTabularNumbers>{formatDistance(parseFloat(order.actualkms ?? 0))}</Text>
<td> </TableCell>
<OrderStatusPill status={order.orderstatus ?? order.status} /> <TableCell>
</td> <Text hasTabularNumbers>{formatDistance(parseFloat(order.riderkms ?? 0))}</Text>
<td> </TableCell>
<span className="distance-value">{plannedDist.value}</span> <TableCell>
<span className="distance-unit">{plannedDist.unit}</span> <Text weight="bold" hasTabularNumbers style={{ textAlign: 'right' }}>
</td> {rupees(getRevenue(order))}
<td> </Text>
<span className="distance-value">{actualDist.value}</span> </TableCell>
<span className="distance-unit">{actualDist.unit}</span> </TableRow>
</td> ))}
<td> </TableBody>
<span className="distance-value">{dist.value}</span> </Table>
<span className="distance-unit">{dist.unit}</span> </TableScroll>
</td> );
<td style={{ textAlign: 'right' }}> }
<span className="revenue-amount">{rupees(rev)}</span>
</td> /** One cost tile inside the expanded panel. */
</tr> function CostTile({ icon, color, label, amount, footnote }) {
); return (
})} <Card padding={3}>
</tbody> <HStack gap={2} vAlign="center">
</table> <Center width={34} height={34} style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}>
</div> {icon}
</Center>
<VStack gap={0} padding={0}>
<Text size="sm" type="supporting">
{label}
</Text>
<Text weight="bold" hasTabularNumbers style={{ color }}>
{amount}
</Text>
<Text size="sm" type="supporting">
{footnote}
</Text>
</VStack>
</HStack>
</Card>
); );
} }
@@ -283,160 +318,128 @@ function OrdersBreakdownTable({ orders, getRevenue }) {
function RiderDetailPanel({ metrics }) { function RiderDetailPanel({ metrics }) {
const { varCost, fixedCost, kms, net, margin } = metrics; const { varCost, fixedCost, kms, net, margin } = metrics;
const isProfit = net >= 0; const isProfit = net >= 0;
const orderCount = metrics.orders?.length ?? 0;
const costTiles = [
{
icon: <MdPayments size={16} />,
iconClass: 'cost-tile-icon--fixed',
label: 'Fixed cost',
amount: rupees(fixedCost, 2),
amountClass: '',
footnote: 'Salary (per slot)'
},
{
icon: <MdRoute size={16} />,
iconClass: 'cost-tile-icon--variable',
label: 'Variable cost',
amount: rupees(varCost, 2),
amountClass: '',
footnote: `${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`
},
{
icon: isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />,
iconClass: isProfit ? 'cost-tile-icon--profit' : 'cost-tile-icon--loss',
label: isProfit ? 'Net profit' : 'Net loss',
amount: `${isProfit ? '+' : ''}${rupees(net, 2)}`,
amountClass: isProfit ? 'cost-tile-amount--profit' : 'cost-tile-amount--loss',
footnote: `${Math.abs(margin).toFixed(0)}% margin`
}
];
return ( return (
<div className="rider-detail-panel" aria-label="Rider cost breakdown"> <VStack gap={3} padding={3}>
<div className="rider-detail-inner"> <Grid columns={{ minWidth: 200 }} gap={3}>
{/* Cost breakdown tiles */} <CostTile
<div className="cost-breakdown-grid"> icon={<MdPayments size={16} />}
{costTiles.map((tile) => ( color={STATUS.info}
<div className="cost-tile" key={tile.label}> label="Fixed cost"
<div className={`cost-tile-icon ${tile.iconClass}`} aria-hidden="true"> amount={rupees(fixedCost, 2)}
{tile.icon} footnote="Salary (per slot)"
</div> />
<div className="cost-tile-content"> <CostTile
<span className="cost-tile-label">{tile.label}</span> icon={<MdRoute size={16} />}
<span className={`cost-tile-amount ${tile.amountClass}`}>{tile.amount}</span> color={STATUS.picked}
<span className="cost-tile-footnote">{tile.footnote}</span> label="Variable cost"
</div> amount={rupees(varCost, 2)}
</div> footnote={`${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`}
))} />
</div> <CostTile
icon={isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />}
color={moneyColor(isProfit)}
label={isProfit ? 'Net profit' : 'Net loss'}
amount={`${isProfit ? '+' : ''}${rupees(net, 2)}`}
footnote={`${Math.abs(margin).toFixed(0)}% margin`}
/>
</Grid>
{/* Revenue breakdown table */} <VStack gap={2} padding={0}>
<div className="revenue-breakdown"> <HStack justify="between" vAlign="center">
<div className="revenue-breakdown-header"> <HStack gap={1} vAlign="center">
<div className="revenue-breakdown-title"> <Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
<MdReceipt size={14} aria-hidden="true" /> <MdReceipt size={14} />
Revenue Breakdown </Center>
</div> <Text weight="bold">Revenue breakdown</Text>
<span className="revenue-breakdown-count"> </HStack>
{metrics.orders?.length ?? 0} order{(metrics.orders?.length ?? 0) !== 1 ? 's' : ''} <Text size="sm" type="supporting">
</span> {orderCount} order{orderCount !== 1 ? 's' : ''}
</div> </Text>
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} /> </HStack>
</div> <OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
</div> </VStack>
</div> </VStack>
); );
} }
/** Single expandable rider profitability card. */ /** Single expandable rider profitability row. */
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) { function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
const { revenue, kms, totalCost, net, margin, orders } = metrics; const { revenue, kms, totalCost, net, margin, orders } = metrics;
const isProfit = net >= 0; const isProfit = net >= 0;
const marginBarWidth = `${clamp(Math.abs(margin), 0, 100)}%`;
const orderCount = orders?.length ?? 0; const orderCount = orders?.length ?? 0;
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`; const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
const cardClasses = [ const handleOpenChange = () => {
'rider-profitability-card',
isProfit ? 'rider-profitability-card--profitable' : 'rider-profitability-card--unprofitable',
isFocused ? 'rider-profitability-card--selected' : ''
]
.filter(Boolean)
.join(' ');
function handleActivate() {
onToggle(rider.id); onToggle(rider.id);
if (!isFocused && onFocus) onFocus(rider); if (!isFocused && onFocus) onFocus(rider);
} };
return ( return (
<div className={cardClasses} aria-label={`Rider: ${name}`}> <Card padding={0} elevation="none" style={isFocused ? { outline: '2px solid var(--color-accent)' } : undefined}>
{/* Clickable header row */} <Collapsible
<div isOpen={isExpanded}
className="rider-card-header" onOpenChange={handleOpenChange}
role="button" trigger={
tabIndex={0} <HStack gap={3} vAlign="center" wrap="wrap" padding={3}>
aria-expanded={isExpanded} <AccentAvatar color={moneyColor(isProfit)} selected size={36}>
onClick={handleActivate} <Text size="sm" weight="bold" style={{ color: 'inherit' }}>
onKeyDown={(e) => { {riderInitials(name)}
if (e.key === 'Enter' || e.key === ' ') { </Text>
e.preventDefault(); </AccentAvatar>
handleActivate();
} <VStack gap={0} padding={0}>
}} <Text weight="bold" maxLines={1}>
{name}
</Text>
<Text size="sm" type="supporting">
{orderCount} order{orderCount !== 1 ? 's' : ''} · {kms.toFixed(1)} km total
</Text>
</VStack>
<HStack gap={4} vAlign="center" wrap="wrap" style={{ marginInlineStart: 'auto' }}>
<VStack gap={0} padding={0} hAlign="end">
<Text size="sm" type="supporting">
Revenue
</Text>
<Text weight="bold" hasTabularNumbers>
{rupees(revenue)}
</Text>
</VStack>
<VStack gap={0} padding={0} hAlign="end">
<Text size="sm" type="supporting">
Cost
</Text>
<Text weight="bold" hasTabularNumbers>
{rupees(totalCost)}
</Text>
</VStack>
<VStack gap={0.5} padding={0} hAlign="end" width={120}>
<Text size="sm" type="supporting">
Net
</Text>
<Text weight="bold" hasTabularNumbers style={{ color: moneyColor(isProfit) }}>
{isProfit ? '+' : ''}
{rupees(net)}
</Text>
{/* Margin bar — a plain magnitude readout, so the value is the
absolute margin clamped to 100 exactly as the old CSS bar was. */}
<ProgressBar
value={clamp(Math.abs(margin), 0, 100)}
max={100}
variant={isProfit ? 'success' : 'error'}
label={`${Math.abs(margin).toFixed(0)}% margin`}
isLabelHidden
/>
</VStack>
</HStack>
</HStack>
}
> >
{/* Rider identity */} <RiderDetailPanel metrics={metrics} />
<div className="rider-avatar" aria-hidden="true"> </Collapsible>
{riderInitials(name)} </Card>
</div>
<div className="rider-identity">
<div className="rider-name" title={name}>
{name}
</div>
<div className="rider-order-summary">
{orderCount} order{orderCount !== 1 ? 's' : ''}
<span className="rider-order-summary-separator">·</span>
{kms.toFixed(1)} km total
</div>
</div>
{/* Summary metrics — hidden on mobile via CSS */}
<div className="rider-metrics-row" aria-hidden="true">
<div className="rider-metric">
<span className="rider-metric-label">Revenue</span>
<span className="rider-metric-amount rider-metric-amount--revenue">{rupees(revenue)}</span>
</div>
<div className="rider-metric">
<span className="rider-metric-label">Cost</span>
<span className="rider-metric-amount rider-metric-amount--cost">{rupees(totalCost)}</span>
</div>
<div className="rider-net-profit-column">
<div className="rider-metric">
<span className="rider-metric-label">Net</span>
<span className={`rider-metric-amount ${isProfit ? 'rider-metric-amount--net-profit' : 'rider-metric-amount--net-loss'}`}>
{isProfit ? '+' : ''}
{rupees(net)}
</span>
</div>
<div className="rider-margin-bar">
<div
className={`rider-margin-bar-fill ${isProfit ? 'rider-margin-bar-fill--profit' : 'rider-margin-bar-fill--loss'}`}
style={{ width: marginBarWidth }}
role="presentation"
/>
</div>
</div>
</div>
{/* Expand chevron */}
<div className={`rider-expand-toggle ${isExpanded ? 'rider-expand-toggle--open' : ''}`} aria-hidden="true">
<MdExpandMore size={22} />
</div>
</div>
{/* Expanded detail panel */}
{isExpanded && <RiderDetailPanel rider={rider} metrics={metrics} />}
</div>
); );
} }
@@ -462,7 +465,6 @@ export default function ProfitabilitySection({
batches batches
}) { }) {
const [expanded, setExpanded] = useState({}); const [expanded, setExpanded] = useState({});
const sortMode = 'profit-asc'; // 'profit-asc', 'profit-desc', 'name-asc', 'orders-desc'
const toggleRider = useCallback((id) => { const toggleRider = useCallback((id) => {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
@@ -475,10 +477,7 @@ export default function ProfitabilitySection({
); );
// Filter out inactive riders (those with 0 orders in the selected date/slot) to ensure accurate KPI aggregations // Filter out inactive riders (those with 0 orders in the selected date/slot) to ensure accurate KPI aggregations
const activeEnriched = useMemo( const activeEnriched = useMemo(() => enriched.filter((r) => r._m.orders.length > 0), [enriched]);
() => enriched.filter((r) => r._m.orders.length > 0),
[enriched]
);
// Aggregate overall slot totals (pre-filtered for BI consistency) // Aggregate overall slot totals (pre-filtered for BI consistency)
const slotRevenue = activeEnriched.reduce((s, r) => s + r._m.revenue, 0); const slotRevenue = activeEnriched.reduce((s, r) => s + r._m.revenue, 0);
@@ -487,137 +486,84 @@ export default function ProfitabilitySection({
const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0); const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0);
const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length; const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length;
const lossCount = activeEnriched.length - profitCount; const lossCount = activeEnriched.length - profitCount;
const totalOrders = enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0);
const dailyIsProfit = totalDailyProfit >= 0; const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0; const slotIsProfit = slotNet >= 0;
const slotMargin = slotRevenue > 0 ? (slotNet / slotRevenue) * 100 : 0;
// Filter riders (filtering by search and status tabs removed) // Sorted loss-first so the riders needing attention are at the top.
const filtered = activeEnriched; const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
// Sort riders based on selected sortMode
const sortedAndFiltered = useMemo(() => {
const list = [...filtered];
if (sortMode === 'profit-asc') {
list.sort((a, b) => a._m.net - b._m.net);
} else if (sortMode === 'profit-desc') {
list.sort((a, b) => b._m.net - a._m.net);
} else if (sortMode === 'name-asc') {
list.sort((a, b) => {
const nameA = (a.riderName ?? a.username ?? `Rider #${a.id}`).toLowerCase();
const nameB = (b.riderName ?? b.username ?? `Rider #${b.id}`).toLowerCase();
return nameA.localeCompare(nameB);
});
} else if (sortMode === 'orders-desc') {
list.sort((a, b) => (b._m.orders?.length ?? 0) - (a._m.orders?.length ?? 0));
}
return list;
}, [filtered, sortMode]);
return ( return (
<div className="profitability-dashboard" role="region" aria-label="Profitability overview"> <VStack gap={3} padding={3}>
{/* ── Header ── */} <HStack justify="between" vAlign="center" wrap="wrap" gap={2}>
<header className="profitability-header"> <VStack gap={0.5} padding={0}>
<div className="profitability-header-left"> <Heading level={2}>Profitability overview</Heading>
<div className="profitability-header-icon" aria-hidden="true"> <Text type="supporting">
<MdBarChart size={18} /> {activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
</div> </Text>
<div> </VStack>
<h2 className="profitability-header-title">Profitability Overview</h2> </HStack>
<p className="profitability-header-subtitle">
<span>
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''}
</span>
<span className="profitability-header-dot" />
<span style={{ color: 'var(--profit-green)' }}>{profitCount} profitable</span>
<span className="profitability-header-dot" />
<span style={{ color: 'var(--loss-red)' }}>{lossCount} at loss</span>
</p>
</div>
</div>
<div className="profitability-kpi-group"> <Grid columns={{ minWidth: 200 }} gap={3}>
{/* Daily profit / loss */} <StatCard
<div className={`profitability-kpi-chip ${dailyIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}> title={`Daily ${dailyIsProfit ? 'profit' : 'loss'}`}
<div className="profitability-kpi-chip-icon" aria-hidden="true"> value={rupees(Math.abs(totalDailyProfit))}
{dailyIsProfit ? <MdTrendingUp /> : <MdTrendingDown />} icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
</div> color={moneyColor(dailyIsProfit)}
<div className="profitability-kpi-chip-content"> caption="All slots today"
<span className="profitability-kpi-chip-label">Daily {dailyIsProfit ? 'profit' : 'loss'}</span> />
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(totalDailyProfit))}</span> <StatCard
</div> title="Riders active"
</div> value={activeEnriched.length}
icon={<MdPeopleAlt size={20} />}
color={STATUS.info}
caption={`${profitCount} in profit · ${lossCount} at loss`}
/>
<StatCard
title="Total distance"
value={`${slotKms.toFixed(1)} km`}
icon={<MdStraighten size={20} />}
color={STATUS.picked}
caption="Cumulative travel distance"
/>
<StatCard
title="Slot revenue"
value={rupees(slotRevenue)}
icon={<MdAccountBalanceWallet size={20} />}
color={STATUS.success}
caption={`From ${totalOrders} order${totalOrders !== 1 ? 's' : ''}`}
/>
<StatCard
title="Slot cost"
value={rupees(slotCost)}
icon={<MdPayments size={20} />}
color={STATUS.error}
caption="Fixed + variable"
/>
<StatCard
title="Slot net"
value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`}
icon={<MdOutlineSavings size={20} />}
color={moneyColor(slotIsProfit)}
caption={`${slotMargin >= 0 ? '+' : ''}${slotMargin.toFixed(0)}% margin`}
/>
</Grid>
{/* Slot profit / loss */} <VStack gap={2} padding={0}>
<div className={`profitability-kpi-chip ${slotIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}> <Text weight="bold">Riders list</Text>
<div className="profitability-kpi-chip-icon" aria-hidden="true"> {sorted.length === 0 ? (
{slotIsProfit ? <MdTrendingUp /> : <MdTrendingDown />} <EmptyState
</div> title="No riders to show"
<div className="profitability-kpi-chip-content"> description="No riders have orders in this slot."
<span className="profitability-kpi-chip-label">Slot {slotIsProfit ? 'profit' : 'loss'}</span> icon={<MdPeopleAlt size={28} />}
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(slotNet))}</span> />
</div>
</div>
</div>
</header>
{/* ── Summary statistics row ── */}
<div className="profitability-summary-row" role="group" aria-label="Slot summary">
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Riders Active</span>
<span className="profitability-summary-value">{activeEnriched.length}</span>
<span className="profitability-summary-detail">
{profitCount} in profit · {lossCount} at loss
</span>
</div>
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Total Distance</span>
<span className="profitability-summary-value">{slotKms.toFixed(1)} km</span>
<span className="profitability-summary-detail">Cumulative travel distance</span>
</div>
<div className="profitability-summary-card profitability-summary-card--primary">
<span className="profitability-summary-label">Slot Revenue</span>
<span className="profitability-summary-value">{rupees(slotRevenue)}</span>
<span className="profitability-summary-detail">From {enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0)} orders</span>
</div>
<div className="profitability-summary-card profitability-summary-card--loss">
<span className="profitability-summary-label">Slot Cost</span>
<span className="profitability-summary-value profitability-summary-value--loss">{rupees(slotCost)}</span>
<span className="profitability-summary-detail">Fixed + variable</span>
</div>
<div className={`profitability-summary-card ${slotIsProfit ? 'card-profit' : 'card-loss'}`}>
<span className="profitability-summary-label">Slot Net</span>
<span className={`profitability-summary-value ${slotIsProfit ? 'net-positive' : 'net-negative'}`}>
{slotIsProfit ? '+' : ''}
{rupees(slotNet)}
</span>
<span
className={`profitability-summary-detail ${slotRevenue > 0 ? (slotNet / slotRevenue >= 0 ? 'margin-positive' : 'margin-negative') : ''
}`}
>
{slotRevenue > 0
? `${slotNet / slotRevenue >= 0 ? '+' : ''}${((slotNet / slotRevenue) * 100).toFixed(0)}% margin`
: '0% margin'}
</span>
</div>
</div>
{/* ── Controls (Search) (removed) ── */}
{/* ── Rider feed ── */}
<div className="rider-profitability-feed" role="list" aria-label="Rider cards">
<div className="rider-feed-heading" aria-hidden="true">
Riders List
</div>
{sortedAndFiltered.length === 0 ? (
<div className="profitability-empty-state" role="status">
<MdPeopleAlt size={32} aria-hidden="true" />
<p className="profitability-empty-message">No riders matching the filters.</p>
</div>
) : ( ) : (
sortedAndFiltered.map((rider) => ( <VStack gap={2} padding={0}>
<div key={rider.id} role="listitem"> {sorted.map((rider) => (
<RiderProfitabilityCard <RiderProfitabilityCard
key={rider.id}
rider={rider} rider={rider}
metrics={rider._m} metrics={rider._m}
isExpanded={!!expanded[rider.id]} isExpanded={!!expanded[rider.id]}
@@ -625,18 +571,14 @@ export default function ProfitabilitySection({
onToggle={toggleRider} onToggle={toggleRider}
onFocus={handleRiderFocus} onFocus={handleRiderFocus}
/> />
</div> ))}
)) </VStack>
)} )}
</div> </VStack>
</div> </VStack>
); );
} }
OrderStatusPill.propTypes = {
status: PropTypes.string
};
CustomerCell.propTypes = { CustomerCell.propTypes = {
order: PropTypes.object.isRequired order: PropTypes.object.isRequired
}; };
@@ -646,14 +588,22 @@ OrdersBreakdownTable.propTypes = {
getRevenue: PropTypes.func.isRequired getRevenue: PropTypes.func.isRequired
}; };
CostTile.propTypes = {
icon: PropTypes.node,
color: PropTypes.string,
label: PropTypes.node,
amount: PropTypes.node,
footnote: PropTypes.node
};
RiderDetailPanel.propTypes = { RiderDetailPanel.propTypes = {
rider: PropTypes.object.isRequired,
metrics: PropTypes.shape({ metrics: PropTypes.shape({
varCost: PropTypes.number.isRequired, varCost: PropTypes.number.isRequired,
fixedCost: PropTypes.number.isRequired, fixedCost: PropTypes.number.isRequired,
kms: PropTypes.number.isRequired, kms: PropTypes.number.isRequired,
net: PropTypes.number.isRequired, net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired margin: PropTypes.number.isRequired,
orders: PropTypes.array
}).isRequired }).isRequired
}; };
@@ -664,7 +614,8 @@ RiderProfitabilityCard.propTypes = {
kms: PropTypes.number.isRequired, kms: PropTypes.number.isRequired,
totalCost: PropTypes.number.isRequired, totalCost: PropTypes.number.isRequired,
net: PropTypes.number.isRequired, net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired margin: PropTypes.number.isRequired,
orders: PropTypes.array
}).isRequired, }).isRequired,
isExpanded: PropTypes.bool.isRequired, isExpanded: PropTypes.bool.isRequired,
isFocused: PropTypes.bool, isFocused: PropTypes.bool,

View File

@@ -3,26 +3,80 @@
// Dispatch.js so we don't create a circular import between the host // Dispatch.js so we don't create a circular import between the host
// component and the child views. // component and the child views.
// Status palette — single source of truth for the status pill colors import { useState, useEffect } from 'react';
// rendered on rider cards, order rows, step lists, and tooltips.
export const STATUS_STYLES = { import { getStatusMeta } from 'themes/dt/status';
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' }, // Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))`, which was the
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' }, // last MUI dependency in Preview.js and CompareDataPanel.js. MUI's `md`
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' }, // breakpoint is 900px and `down()` is exclusive, so this is the identical
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' }, // `max-width: 899.95px` query it generated — the same width the scoped media
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' }, // blocks in Dispatch.css already key off.
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' }, const NARROW_VIEWPORT_QUERY = '(max-width: 899.95px)';
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' } export function useIsNarrowViewport() {
const [isNarrow, setIsNarrow] = useState(
() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(NARROW_VIEWPORT_QUERY).matches
);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mql = window.matchMedia(NARROW_VIEWPORT_QUERY);
const onChange = (e) => setIsNarrow(e.matches);
// Re-sync on mount in case the viewport changed between the lazy initial
// state and the effect running.
setIsNarrow(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isNarrow;
}
// Enter/Space activation for div/li surfaces that carry an onClick.
//
// The dispatch UI is built from clickable cards and list rows that wrap
// headings, badges and bars, so they can't become real <button>s (interactive
// content nested in a button is invalid markup, and it would fight the card
// CSS). `role="button"` + `tabIndex={0}` + this handler is the sanctioned
// shape. Pair all three — a role without a tabIndex is not reachable, and a
// click without a key handler is mouse-only.
export const onActivate = (handler) => (e) => {
if (!handler) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handler(e);
}
}; };
export const getStatusStyle = (status) => // Status colour for the places that need a RAW HEX rather than a rendered
STATUS_STYLES[String(status || '').toLowerCase()] || { // badge — Leaflet divIcon HTML built as a template string, and the `style`
label: status || 'Unknown', // attribute on a few inline pills. Anything that renders normal JSX should use
bg: '#64748b', // <StatusBadge status={...}/> instead; this exists only because you cannot put
fg: '#fff' // a React component inside an `L.divIcon({ html: '...' })` string.
}; //
// This used to be a private STATUS_STYLES table, and it had drifted badly from
// the canonical one in themes/dt/status.js:
//
// picked #0ea5e9 → #8b5cf6 (was identical to `active`, so two
// active #0ea5e9 → #14b8a6 different states looked the same)
// accepted #8b5cf6 → #6366f1 (was identical to the new `picked`)
// arrived #ea580c → #06b6d4
// delivered #22c55e → #10b981
// skipped #94a3b8 → #f97316 (was the same grey as "unknown")
//
// So the dispatch map painted a picked-up order and an in-transit order the
// same blue, while the deliveries table painted them purple and teal. Now both
// read from one table. `getStatusMeta` also resolves the raw backend enums
// (miler_assigned, converted_to_consignment, …), which this never did — those
// previously fell through to the grey "unknown" branch.
//
// The `{ label, bg, fg }` shape is kept so the existing call sites are
// unchanged; `fg` is always white, as it was in every entry of the old table.
export const getStatusStyle = (status) => {
const meta = getStatusMeta(status);
return { label: meta.label, bg: meta.color, fg: '#fff' };
};
// Order-status sets used for completion / skipped decisions across the // Order-status sets used for completion / skipped decisions across the
// rider list, the planned-route renderer, and the compare data panel. // rider list, the planned-route renderer, and the compare data panel.

View File

@@ -1,55 +1,48 @@
import React, { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import {
Autocomplete,
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdWarningAmber, MdAdd, MdCheckCircleOutline } from 'react-icons/md'; import { MdWarningAmber, MdAdd, MdCheckCircleOutline } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput';
import { TextArea } from '@astryxdesign/core/TextArea';
import { Selector } from '@astryxdesign/core/Selector';
import { Badge } from '@astryxdesign/core/Badge';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi'; import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack'; import { DT } from 'themes/dt/tokens';
import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/tokens'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = '#000000'; const BRAND = DT.brand;
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable']; const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') }));
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical']; const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
const SEVERITY_COLOR = { Low: '#94a3b8', Medium: '#f59e0b', High: '#ef4444', Critical: '#7f1d1d' }; const SEVERITY_OPTIONS = SEVERITIES.map((s) => ({ value: s, label: s }));
const SEVERITY_BADGE = { Low: 'neutral', Medium: 'yellow', High: 'orange', Critical: 'error' };
const RESOLVE_STATUS_OPTIONS = [
{ value: 'Resolved', label: 'Resolved' },
{ value: 'Closed', label: 'Closed' }
];
const emptyForm = { consignmentid: '', tripsheetid: '', hub: null, exceptiontype: 'Damaged', severity: 'Medium', description: '' }; const emptyForm = { consignmentid: '', tripsheetid: '', hub: '', exceptiontype: 'Damaged', severity: 'Medium', description: '' };
const Exceptions = () => { const Exceptions = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
@@ -62,6 +55,7 @@ const Exceptions = () => {
const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions }); const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions });
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]); const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
const hubOptions = useMemo(() => (hubs || []).map((h) => ({ value: String(h.hubid), label: h.hubname || `Hub #${h.hubid}` })), [hubs]);
const rows = useMemo(() => { const rows = useMemo(() => {
if (!debouncedSearch) return exceptions; if (!debouncedSearch) return exceptions;
@@ -75,7 +69,8 @@ const Exceptions = () => {
const total = exceptions.length; const total = exceptions.length;
const open = exceptions.filter((e) => !['Resolved', 'Closed'].includes(e.status)).length; const open = exceptions.filter((e) => !['Resolved', 'Closed'].includes(e.status)).length;
const critical = exceptions.filter((e) => e.severity === 'Critical').length; const critical = exceptions.filter((e) => e.severity === 'Critical').length;
return { total, open, critical }; const resolved = exceptions.filter((e) => ['Resolved', 'Closed'].includes(e.status)).length;
return { total, open, critical, resolved };
}, [exceptions]); }, [exceptions]);
const opentoast = (message, variant = 'error') => const opentoast = (message, variant = 'error') =>
@@ -119,13 +114,15 @@ const Exceptions = () => {
createMutation.mutate({ createMutation.mutate({
consignmentid: form.consignmentid, consignmentid: form.consignmentid,
tripsheetid: form.tripsheetid || undefined, tripsheetid: form.tripsheetid || undefined,
hubid: form.hub?.hubid, hubid: form.hub ? Number(form.hub) : undefined,
exceptiontype: form.exceptiontype, exceptiontype: form.exceptiontype,
severity: form.severity, severity: form.severity,
description: form.description description: form.description
}); });
}; };
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
return ( return (
<> <>
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />} {(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
@@ -134,211 +131,169 @@ const Exceptions = () => {
title="Exceptions" title="Exceptions"
subtitle="Live · Lost / Damaged / Misrouted Parcels" subtitle="Live · Lost / Damaged / Misrouted Parcels"
live live
action={ action={<Button label="Log Exception" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
Log Exception
</Button>
}
/> />
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}> <Grid columns={{ minWidth: 180, max: 4, repeat: 'fit' }} gap={3}>
<Grid item xs={4}> <StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} /> <StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
</Grid> <StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
<Grid item xs={4}> <StatCard title="Resolved" value={stats.resolved} icon={<MdCheckCircleOutline size={20} />} color="#10b981" />
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
</Grid>
<Grid item xs={4}>
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
</Grid>
</Grid> </Grid>
<Paper <VStack gap={0} padding={0} style={{ marginTop: 16 }}>
elevation={0} <Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
sx={{ mt: { xs: 1.5, md: 2 }, p: { xs: 1, md: 1.5 }, borderTopLeftRadius: DT.radiusCard / 8, borderTopRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, borderBottom: 0, background: '#fff' }} <HStack justify="between" vAlign="center" gap={3} wrap="wrap">
> <Text weight="bold">
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}> {exceptions.length} total · {rows.length} shown
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}> </Text>
{exceptions.length} total · {rows.length} shown <div style={{ width: 280, maxWidth: '100%' }}>
</Typography> <DebounceSearchBar
<Box sx={{ width: { xs: '100%', sm: 280 } }}> value={searchword}
<DebounceSearchBar onChange={setSearchword}
value={searchword} onDebouncedChange={setDebouncedSearch}
onChange={setSearchword} placeholder="Search exceptions (ctrl+k)"
onDebouncedChange={setDebouncedSearch} />
placeholder="Search exceptions (ctrl+k)" </div>
/> </HStack>
</Box> </Card>
</Stack>
</Paper>
<Paper elevation={0} sx={{ borderBottomLeftRadius: DT.radiusCard / 8, borderBottomRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, overflow: 'hidden', background: '#fff' }}> <Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' }, ...tableScrollSx }}> <TableScroll minWidth={1020}>
<Table stickyHeader sx={{ minWidth: isMobile ? 960 : 1020 }}> <Table density="balanced" dividers="rows" hasHover>
<TableHead> <TableHeader>
<TableRow sx={tableHeadSx}>
<TableCell>#</TableCell>
<TableCell>Consignment</TableCell>
<TableCell>Hub</TableCell>
<TableCell>Type</TableCell>
<TableCell>Severity</TableCell>
<TableCell>Description</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <OrdersTableSkeleton col={6} />}
{rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={8} sx={{ py: 6 }}> <TableHeaderCell>#</TableHeaderCell>
<Stack alignItems="center" spacing={1.5}> <TableHeaderCell>Consignment</TableHeaderCell>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}> <TableHeaderCell>Hub</TableHeaderCell>
<MdWarningAmber size={28} /> <TableHeaderCell>Type</TableHeaderCell>
</Avatar> <TableHeaderCell>Severity</TableHeaderCell>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <TableHeaderCell>Description</TableHeaderCell>
No exceptions logged <TableHeaderCell>Status</TableHeaderCell>
</Typography> <TableHeaderCell>Actions</TableHeaderCell>
</Stack>
</TableCell>
</TableRow> </TableRow>
) : ( </TableHeader>
rows.map((row, index) => ( <TableBody>
<TableRow key={row.exceptionid || index} sx={tableRowSx}> {isLoading && <TableLoader rows={5} columns={8} />}
<TableCell>{index + 1}</TableCell> {rows.length === 0 && !isLoading ? (
<TableCell>{row.consignmentid}</TableCell> <TableRow>
<TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell> <TableCell colSpan={8}>
<TableCell>{row.exceptiontype}</TableCell> <EmptyState
<TableCell> title="No exceptions logged"
<Box description="Log lost, damaged or misrouted parcels here to track resolution."
sx={{ icon={<MdWarningAmber size={28} />}
display: 'inline-flex', />
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: soft(SEVERITY_COLOR[row.severity] || '#94a3b8'),
color: SEVERITY_COLOR[row.severity] || '#94a3b8',
fontSize: 11,
fontWeight: 800
}}
>
{row.severity}
</Box>
</TableCell>
<TableCell sx={{ maxWidth: 220 }}>
<Typography variant="body2" noWrap title={row.description}>
{row.description}
</Typography>
</TableCell>
<TableCell>{row.status || 'Open'}</TableCell>
<TableCell align="right">
{!['Resolved', 'Closed'].includes(row.status) && (
<Tooltip title="Resolve / Close">
<IconButton size="small" onClick={() => setResolveRow(row)} sx={{ color: '#10b981' }}>
<MdCheckCircleOutline size={16} />
</IconButton>
</Tooltip>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} rows.map((row, index) => (
</TableBody> <TableRow key={row.exceptionid || index}>
</Table> <TableCell>{index + 1}</TableCell>
</TableContainer> <TableCell>{row.consignmentid}</TableCell>
</Paper> <TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell>
<TableCell>{row.exceptiontype}</TableCell>
<TableCell>
<Badge variant={SEVERITY_BADGE[row.severity] || 'neutral'} label={row.severity} />
</TableCell>
<TableCell style={{ maxWidth: 220 }}>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.description}>
{row.description}
</Text>
</TableCell>
<TableCell>{row.status || 'Open'}</TableCell>
<TableCell>
{!['Resolved', 'Closed'].includes(row.status) && (
<HStack justify="end">
<IconButton
label="Resolve / Close"
tooltip="Resolve / Close"
size="sm"
variant="ghost"
icon={<MdCheckCircleOutline size={16} />}
onClick={() => setResolveRow(row)}
/>
</HStack>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> <Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>Log Exception</DialogTitle> <Layout
<DialogContent> header={
<Grid container spacing={2} sx={{ mt: 0.5 }}> <DialogHeader
<Grid item xs={12} sm={6}> title="Log Exception"
<Stack spacing={1}> subtitle="Record a lost, damaged or misrouted parcel"
<InputLabel>Consignment ID</InputLabel> onOpenChange={(next) => !next && setDialogOpen(false)}
<TextField value={form.consignmentid} onChange={(e) => setForm((f) => ({ ...f, consignmentid: e.target.value }))} /> startContent={
</Stack> <AccentAvatar color={BRAND} selected size={36}>
</Grid> <MdWarningAmber size={18} />
<Grid item xs={12} sm={6}> </AccentAvatar>
<Stack spacing={1}> }
<InputLabel>Tripsheet ID (optional)</InputLabel> />
<TextField value={form.tripsheetid} onChange={(e) => setForm((f) => ({ ...f, tripsheetid: e.target.value }))} /> }
</Stack> content={
</Grid> <LayoutContent padding={4}>
<Grid item xs={12} sm={6}> <VStack gap={3} padding={0}>
<Stack spacing={1}> <Grid columns={{ minWidth: 220 }} gap={3}>
<InputLabel>Hub (optional)</InputLabel> <TextInput label="Consignment ID" value={form.consignmentid} onChange={set('consignmentid')} isRequired />
<Autocomplete <TextInput label="Tripsheet ID" value={form.tripsheetid} onChange={set('tripsheetid')} isOptional />
options={hubs || []} <Selector label="Hub" options={hubOptions} value={form.hub} onChange={set('hub')} placeholder="Choose hub" hasSearch isOptional />
getOptionLabel={(option) => option.hubname || ''} <Selector label="Type" options={EXCEPTION_TYPE_OPTIONS} value={form.exceptiontype} onChange={set('exceptiontype')} />
value={form.hub} <Selector label="Severity" options={SEVERITY_OPTIONS} value={form.severity} onChange={set('severity')} />
onChange={(e, value) => setForm((f) => ({ ...f, hub: value }))} </Grid>
renderInput={(params) => <TextField {...params} placeholder="Choose hub" />} <TextArea label="Description" value={form.description} onChange={set('description')} isRequired />
/> </VStack>
</Stack> </LayoutContent>
</Grid> }
<Grid item xs={12} sm={6}> footer={
<Stack spacing={1}> <LayoutFooter hasDivider>
<InputLabel>Type</InputLabel> <HStack gap={2} justify="end">
<Select fullWidth value={form.exceptiontype} onChange={(e) => setForm((f) => ({ ...f, exceptiontype: e.target.value }))}> <Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={createMutation.isPending} />
{EXCEPTION_TYPES.map((t) => ( <Button label="Log Exception" variant="primary" onClick={handleCreate} isLoading={createMutation.isPending} />
<MenuItem key={t} value={t}> </HStack>
{t.replace(/_/g, ' ')} </LayoutFooter>
</MenuItem> }
))} />
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Severity</InputLabel>
<Select fullWidth value={form.severity} onChange={(e) => setForm((f) => ({ ...f, severity: e.target.value }))}>
{SEVERITIES.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Description</InputLabel>
<TextField multiline minRows={2} value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Stack>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}>
Log Exception
</Button>
</DialogActions>
</Dialog> </Dialog>
<Dialog open={!!resolveRow} onClose={() => setResolveRow(null)} maxWidth="xs" fullWidth> <Dialog isOpen={!!resolveRow} onOpenChange={(next) => !next && setResolveRow(null)} width={480} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>Resolve Exception</DialogTitle> <Layout
<DialogContent> header={
<Stack spacing={2} sx={{ mt: 1 }}> <DialogHeader
<Stack spacing={1}> title="Resolve Exception"
<InputLabel>Status</InputLabel> subtitle={resolveRow ? `Consignment ${resolveRow.consignmentid}` : ''}
<Select fullWidth value={resolveStatus} onChange={(e) => setResolveStatus(e.target.value)}> onOpenChange={(next) => !next && setResolveRow(null)}
<MenuItem value="Resolved">Resolved</MenuItem> startContent={
<MenuItem value="Closed">Closed</MenuItem> <AccentAvatar color="#10b981" selected size={36}>
</Select> <MdCheckCircleOutline size={18} />
</Stack> </AccentAvatar>
<Stack spacing={1}> }
<InputLabel>Resolution Notes</InputLabel> />
<TextField multiline minRows={2} value={resolution} onChange={(e) => setResolution(e.target.value)} /> }
</Stack> content={
</Stack> <LayoutContent padding={4}>
</DialogContent> <VStack gap={3} padding={0}>
<DialogActions sx={{ px: 3, pb: 2 }}> <Selector label="Status" options={RESOLVE_STATUS_OPTIONS} value={resolveStatus} onChange={setResolveStatus} />
<Button onClick={() => setResolveRow(null)}>Cancel</Button> <TextArea label="Resolution Notes" value={resolution} onChange={setResolution} isOptional />
<Button variant="contained" onClick={() => resolveMutation.mutate()} sx={{ bgcolor: BRAND }}> </VStack>
Save </LayoutContent>
</Button> }
</DialogActions> footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setResolveRow(null)} isDisabled={resolveMutation.isPending} />
<Button label="Save" variant="primary" onClick={() => resolveMutation.mutate()} isLoading={resolveMutation.isPending} />
</HStack>
</LayoutFooter>
}
/>
</Dialog> </Dialog>
</> </>
); );

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace } from 'react-icons/md'; import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace, MdOutlineCheckCircle } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
@@ -11,6 +11,8 @@ import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Divider } from '@astryxdesign/core/Divider';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
@@ -25,7 +27,7 @@ import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge'; import StatusBadge from 'components/nearle_components/StatusBadge';
import { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi'; import { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi';
import { DT, STATUS } from 'themes/dt/tokens'; import { DT, STATUS } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand; const BRAND = DT.brand;
@@ -76,7 +78,8 @@ const Hubs = () => {
const total = hubs.length; const total = hubs.length;
const sorting = hubs.filter((h) => h.hubtype === 'sorting_center').length; const sorting = hubs.filter((h) => h.hubtype === 'sorting_center').length;
const delivery = hubs.filter((h) => h.hubtype === 'delivery_hub').length; const delivery = hubs.filter((h) => h.hubtype === 'delivery_hub').length;
return { total, sorting, delivery }; const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length;
return { total, sorting, delivery, active };
}, [hubs]); }, [hubs]);
const opentoast = (message, variant = 'error') => const opentoast = (message, variant = 'error') =>
@@ -155,7 +158,7 @@ const Hubs = () => {
return ( return (
<> <>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />} {(isLoading || deleteMutation.isPending) && <Loader />}
<PageHeader <PageHeader
title="Hubs" title="Hubs"
@@ -164,10 +167,11 @@ const Hubs = () => {
action={<Button label="New Hub" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />} action={<Button label="New Hub" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/> />
<Grid columns={{ minWidth: 220 }} gap={3}> <Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} /> <StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} /> <StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} />
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} /> <StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} />
<StatCard title="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} />
</Grid> </Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}> <VStack gap={0} padding={0} style={{ marginTop: 16 }}>
@@ -255,49 +259,82 @@ const Hubs = () => {
</Card> </Card>
</VStack> </VStack>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form"> <Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={680} purpose="form">
<DialogHeader title={form.hubid ? 'Edit Hub' : 'New Hub'} /> <Layout
<VStack gap={4} padding={4}> header={
<Grid columns={{ minWidth: 240 }} gap={3}> <DialogHeader
<TextInput label="Hub Name" value={form.hubname} onChange={set('hubname')} isRequired /> title={form.hubid ? 'Edit Hub' : 'New Hub'}
<Selector label="Type" options={HUB_TYPE_OPTIONS} value={form.hubtype} onChange={set('hubtype')} /> subtitle={form.hubid ? `Updating "${form.hubname || 'hub'}"` : 'Add a sorting center or delivery hub'}
<NumberInput onOpenChange={(next) => !next && setDialogOpen(false)}
label="City (applocationid)" startContent={
value={form.applocationid === '' ? undefined : Number(form.applocationid)} <AccentAvatar color={BRAND} selected size={36}>
onChange={(v) => set('applocationid')(v ?? '')} <MdOutlineWarehouse size={18} />
description="Read the correct id from GET /admin/hubs rather than guessing" </AccentAvatar>
isRequired }
/> />
<TextInput label="Contact No" value={form.contactno} onChange={set('contactno')} isOptional /> }
</Grid> content={
<LayoutContent padding={4}>
<VStack gap={4} padding={0}>
<VStack gap={3} padding={0}>
<Text type="label" weight="semibold" color="secondary">
Hub Details
</Text>
<TextInput label="Hub Name" value={form.hubname} onChange={set('hubname')} isRequired />
<Grid columns={{ minWidth: 180 }} gap={3}>
<Selector label="Type" options={HUB_TYPE_OPTIONS} value={form.hubtype} onChange={set('hubtype')} />
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
<TextInput label="Contact No" value={form.contactno} onChange={set('contactno')} isOptional />
</Grid>
</VStack>
<TextInput label="Address" value={form.address} onChange={set('address')} isOptional /> <Divider />
<Grid columns={{ minWidth: 220 }} gap={3}> <VStack gap={3} padding={0}>
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} isOptional /> <Text type="label" weight="semibold" color="secondary">
<NumberInput Location
label="Latitude" </Text>
value={form.latitude === '' ? undefined : Number(form.latitude)} <TextInput label="Address" value={form.address} onChange={set('address')} isOptional />
onChange={(v) => set('latitude')(v ?? '')} <NumberInput
isOptional label="Location ID (applocationid)"
/> value={form.applocationid === '' ? undefined : Number(form.applocationid)}
<NumberInput onChange={(v) => set('applocationid')(v ?? '')}
label="Longitude" description="Read the correct id from GET /admin/hubs rather than guessing"
value={form.longitude === '' ? undefined : Number(form.longitude)} isRequired
onChange={(v) => set('longitude')(v ?? '')} />
isOptional <Grid columns={{ minWidth: 160 }} gap={3}>
/> <TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} isOptional />
</Grid> <NumberInput
label="Latitude"
<Grid columns={{ minWidth: 240 }} gap={3}> value={form.latitude === '' ? undefined : Number(form.latitude)}
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} /> onChange={(v) => set('latitude')(v ?? '')}
</Grid> isOptional
/>
<HStack gap={2} justify="end"> <NumberInput
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} /> label="Longitude"
<Button label={form.hubid ? 'Save Changes' : 'Create'} variant="primary" onClick={handleSave} /> value={form.longitude === '' ? undefined : Number(form.longitude)}
</HStack> onChange={(v) => set('longitude')(v ?? '')}
</VStack> isOptional
/>
</Grid>
</VStack>
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={saveMutation.isPending} />
<Button
label={form.hubid ? 'Save Changes' : 'Create Hub'}
variant="primary"
onClick={handleSave}
isLoading={saveMutation.isPending}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog> </Dialog>
</> </>
); );

View File

@@ -75,4 +75,3 @@ The summary endpoint (`fetchorderscount`) uses a slightly different key — `cur
- **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 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 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 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.

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +1,8 @@
/* OrdersRedesign.css — premium aesthetics + micro-animations /* OrdersRedesign.css — the one bespoke visual treatment multipleOrders.js
Ported from xpressconsole; trimmed to only the classes the keeps after its Astryx conversion: the gradient "Total Charge" badge in
redesigned multipleOrders.js (and its peers) consume here. */ the Bulk Summary card. Everything else on the page now comes from Astryx
components/tokens. */
/* ---------- Page background ---------- */
.orders-workspace-bg {
background: linear-gradient(135deg, #f8fafc 0%, #edf2f7 100%) !important;
}
/* ---------- Cards ---------- */
.orders-card {
background: #ffffff !important;
border: 1px solid #eef2f6 !important;
border-radius: 12px !important;
box-shadow: 0 10px 25px -5px rgba(62, 73, 84, 0.04),
0 4px 12px -2px rgba(62, 73, 84, 0.02) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
overflow: hidden;
}
.orders-card:hover {
box-shadow: 0 16px 35px -8px rgba(62, 73, 84, 0.08),
0 6px 16px -3px rgba(62, 73, 84, 0.03) !important;
}
.orders-card .MuiOutlinedInput-root {
font-size: 13px !important;
border-radius: 10px !important;
}
.orders-card .MuiOutlinedInput-input {
font-size: 13px !important;
padding-top: 9px !important;
padding-bottom: 9px !important;
}
.orders-card .MuiInputLabel-root {
font-size: 13px !important;
}
.orders-card .MuiInputLabel-root.MuiInputLabel-shrink {
font-size: 11.5px !important;
}
/* ---------- Section title bar (gradient strip + h-title) ---------- */
.section-title-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.section-title-bar::before {
content: '';
width: 4px;
height: 22px;
border-radius: 4px;
background: linear-gradient(180deg, #1890ff, #096dd9);
flex-shrink: 0;
}
.section-title-bar--accent::before {
background: linear-gradient(180deg, #a855f7, #65387a);
}
.section-title-bar .MuiTypography-root {
margin-bottom: 0 !important;
line-height: 1.2;
}
/* ---------- Delivery preferences card ---------- */
.delivery-prefs-card {
background: linear-gradient(135deg, #ffffff 0%, #fbfcff 100%) !important;
border: 1px solid #eef2f6 !important;
border-radius: 14px !important;
}
.delivery-prefs-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.delivery-prefs-title {
font-size: 14px !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
line-height: 1.2;
}
.delivery-prefs-sub {
font-size: 10.5px !important;
font-weight: 500 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.55px;
text-align: right;
line-height: 1.2;
}
.delivery-prefs-row {
display: flex;
flex-direction: column;
gap: 10px;
}
.delivery-prefs-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.delivery-prefs-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.55px;
text-transform: uppercase;
color: #64748b;
}
/* ---------- Pricing summary card ---------- */
.pricing-summary-card {
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%) !important;
border: 1px solid #eef2f6 !important;
border-radius: 14px !important;
padding: 14px 16px !important;
}
.pricing-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pricing-title {
font-size: 14px !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
}
.pricing-subtitle {
font-size: 11px !important;
font-weight: 500 !important;
color: #94a3b8 !important;
text-transform: uppercase;
letter-spacing: 0.6px;
}
/* ---------- Total charge badge ---------- */
.total-charge-badge { .total-charge-badge {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(101, 56, 122, 0.10) 100%); background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(101, 56, 122, 0.10) 100%);
border: 1px solid rgba(101, 56, 122, 0.18); border: 1px solid rgba(101, 56, 122, 0.18);
@@ -181,108 +40,3 @@
letter-spacing: -0.01em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
} }
/* ---------- Gradient action button ---------- */
.gradient-btn-create {
background: linear-gradient(135deg, #1890ff 0%, #65387a 100%) !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 13px !important;
letter-spacing: 0.01em !important;
text-transform: none !important;
border-radius: 10px !important;
padding: 8px 18px !important;
min-height: 38px !important;
box-shadow: 0 4px 12px -3px rgba(24, 144, 255, 0.30),
0 2px 4px rgba(101, 56, 122, 0.10) !important;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1) !important;
border: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.gradient-btn-create .MuiButton-startIcon,
.gradient-btn-create .MuiButton-endIcon {
margin: 0 !important;
}
.gradient-btn-create:hover {
filter: brightness(1.04);
box-shadow: 0 8px 18px -4px rgba(24, 144, 255, 0.40),
0 3px 8px rgba(101, 56, 122, 0.18) !important;
}
.gradient-btn-create:active { filter: brightness(0.98); }
.gradient-btn-create.Mui-disabled,
.gradient-btn-create:disabled {
background: #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
cursor: not-allowed !important;
}
/* ---------- Compact header inputs (Location/Client/Business) ---------- */
.header-compact-tf .MuiOutlinedInput-root {
border-radius: 10px !important;
height: 40px !important;
padding-left: 10px !important;
font-size: 12.5px !important;
background: #ffffff;
}
.header-compact-tf .MuiOutlinedInput-input {
padding-top: 6px !important;
padding-bottom: 6px !important;
font-size: 12.5px !important;
}
.header-compact-tf .MuiInputLabel-root {
font-size: 11.5px !important;
letter-spacing: 0.02em;
font-weight: 600;
color: #64748b !important;
}
.header-compact-tf .MuiInputLabel-shrink {
transform: translate(12px, -7px) scale(0.82) !important;
background: #ffffff;
padding: 0 4px;
}
.header-compact-tf .MuiOutlinedInput-notchedOutline { border-color: #e2e8f0; }
.header-compact-tf:hover .MuiOutlinedInput-notchedOutline { border-color: #cbd5e1; }
.header-compact-tf .Mui-focused .MuiOutlinedInput-notchedOutline { border-width: 1.5px !important; }
.header-compact-input .MuiAutocomplete-endAdornment {
top: 50%;
transform: translateY(-50%);
right: 8px;
display: inline-flex;
align-items: center;
height: auto;
gap: 2px;
}
.header-compact-input .MuiAutocomplete-endAdornment .MuiSvgIcon-root {
font-size: 16px;
display: block;
}
.header-compact-input .MuiAutocomplete-clearIndicator,
.header-compact-input .MuiAutocomplete-popupIndicator {
padding: 3px !important;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8 !important;
}
.header-compact-input .MuiAutocomplete-clearIndicator:hover,
.header-compact-input .MuiAutocomplete-popupIndicator:hover {
background: rgba(148, 163, 184, 0.12) !important;
color: #475569 !important;
}
.header-compact-input .MuiAutocomplete-popupIndicator { margin-right: 0; }
.header-compact-input .MuiOutlinedInput-root {
padding-top: 0 !important;
padding-bottom: 0 !important;
padding-right: 60px !important;
}
.header-compact-input .MuiAutocomplete-input {
padding: 4px 4px 4px 0 !important;
height: auto !important;
}

View File

@@ -1,50 +0,0 @@
/* eslint-disable no-unused-vars */
import { TableRow, TableCell, Skeleton, Stack } from '@mui/material';
export const OrdersTableSkeleton = ({ rowsPerPage = 5, col = 1 }) => {
return (
<>
{Array.from(new Array(rowsPerPage)).map((_, index) => (
<TableRow key={index}>
{/* Checkbox */}
<TableCell>
<Skeleton variant="circular" width={24} height={24} />
</TableCell>
{/* Serial Number */}
<TableCell>
<Skeleton variant="text" width={30} />
</TableCell>
{/* Delivery Info */}
{Array.from({ length: col }).map((_, index) => (
<TableCell key={index}>
<Stack spacing={0.5}>
<Skeleton variant="text" width={100} />
<Skeleton variant="text" width={80} />
</Stack>
</TableCell>
))}
{/* Notes */}
<TableCell>
<Skeleton variant="text" width={150} />
</TableCell>
{/* Order Status */}
<TableCell>
<Skeleton variant="rounded" width={60} height={24} />
</TableCell>
{/* Actions */}
<TableCell align="center">
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Skeleton variant="circular" width={28} height={28} />
<Skeleton variant="circular" width={28} height={28} />
</Stack>
</TableCell>
</TableRow>
))}
</>
);
};

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

File diff suppressed because it is too large Load Diff

View File

@@ -50,10 +50,19 @@ const BRAND = '#000000';
// match exactly or the chip and the table below it would disagree. // match exactly or the chip and the table below it would disagree.
const orderRevenue = (km) => (km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM); const orderRevenue = (km) => (km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM);
// Buckets on `orderdate` (the booking's createdat) and parses with
// parseDoormileTimestamp — must match ProfitabilitySection.getRowBatch,
// Dispatch.js's BATCH_TIME_FIELD and deliveries.js's BATCH_TIME_KEYS.
//
// It used to read `assigntime`, which api.js maps to the backend's
// last-modified column, so it was re-stamped by any status change and a
// rider's slot count drifted through the day; and it parsed with a bare
// dayjs(), which mis-handles the false trailing Z some Doormile timestamps
// carry. Slot count feeds the FIXED cost, so both bugs moved money.
const getOrderBatch = (o) => { const getOrderBatch = (o) => {
const t = o.assigntime || o.deliverydate; const t = o.orderdate;
if (!t || /^\d{4}-\d{2}-\d{2}$/.test(String(t).trim())) return null; if (!t || /^\d{4}-\d{2}-\d{2}$/.test(String(t).trim())) return null;
const d = dayjs(t); const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null; if (!d.isValid()) return null;
const hour = d.hour() + d.minute() / 60; const hour = d.hour() + d.minute() / 60;
const batch = BATCHES_DEFAULT.find((b) => hour >= b.startHour && hour < b.endHour); const batch = BATCHES_DEFAULT.find((b) => hour >= b.startHour && hour < b.endHour);
@@ -111,11 +120,7 @@ const Profitability = () => {
if (SKIPPED_STATUSES.has(status)) return; if (SKIPPED_STATUSES.has(status)) return;
// parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z // parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z
// parse bug (utils/doormileTimestamp.js) — see ProfitabilitySection.js. // parse bug (utils/doormileTimestamp.js) — see ProfitabilitySection.js.
const dateStr = o.assigntime const dateStr = o.orderdate ? parseDoormileTimestamp(o.orderdate).format('YYYY-MM-DD') : null;
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: null;
if (dateStr !== selectedDate) return; if (dateStr !== selectedDate) return;
const km = parseFloat(o.riderkms || 0); const km = parseFloat(o.riderkms || 0);
revenue += orderRevenue(km); revenue += orderRevenue(km);

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,43 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import {
Paper,
Stack,
Typography,
Table,
TableCell,
TableBody,
TableHead,
TableRow,
TableContainer,
Avatar,
Box,
ToggleButtonGroup,
ToggleButton,
Autocomplete,
TextField,
useMediaQuery,
Button
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { MdCheckCircle, MdCancel, MdAccessTime, MdInventory2, MdTwoWheeler, MdArrowForward } from 'react-icons/md';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MdCheckCircle, MdCancel, MdAccessTime, MdInventory2, MdTwoWheeler, MdArrowForward } from 'react-icons/md';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { Badge } from '@astryxdesign/core/Badge';
import { Selector } from '@astryxdesign/core/Selector';
import { DateInput } from '@astryxdesign/core/DateInput'; import { DateInput } from '@astryxdesign/core/DateInput';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OpenToast } from 'components/third-party/OpenToast'; import { OpenToast } from 'components/third-party/OpenToast';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT, edge } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
const STATUS_META = { const STATUS_META = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle }, active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel }, inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
online: { label: 'Online', color: '#10b981', icon: MdCheckCircle }, online: { label: 'Online', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
offline: { label: 'Offline', color: '#ef4444', icon: MdCancel }, offline: { label: 'Offline', color: '#ef4444', icon: MdCancel, badge: 'red' },
idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime }, idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime, badge: 'yellow' },
unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2 } unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2, badge: 'neutral' }
}; };
const BATCH_OPTIONS = [
{ value: 'all', label: 'All Batches' },
{ value: 'morning', label: 'Morning' },
{ value: 'afternoon', label: 'Afternoon' },
{ value: 'evening', label: 'Evening' }
];
export default function RiderSubstitution({ export default function RiderSubstitution({
rows, rows,
allRidersList, allRidersList,
@@ -42,15 +46,9 @@ export default function RiderSubstitution({
handleAssignSubstitute, handleAssignSubstitute,
onFinalizeSuccess, onFinalizeSuccess,
selectedDate, selectedDate,
setSelectedDate, setSelectedDate
DT,
BRAND,
edge,
tint,
soft
}) { }) {
const theme = useTheme(); const isMobile = useIsNarrowViewport();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [selectedBatch, setSelectedBatch] = useState('all'); const [selectedBatch, setSelectedBatch] = useState('all');
const [isFinalizing, setIsFinalizing] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false);
@@ -151,275 +149,136 @@ export default function RiderSubstitution({
return STATUS_META[key] || STATUS_META.unknown; return STATUS_META[key] || STATUS_META.unknown;
}; };
const totalCols = 6; const substituteOptions = (substituteRidersList || []).map((r) => ({
value: String(r.userid),
label: `${r.username || r.fullname || ''} (#${r.userid})`
}));
const AccentAvatar = ({ color, selected, size = 24, children }) => ( const renderSubstitutePicker = (row, disabled) => (
<Avatar <Selector
sx={{ label="Substitute Rider"
width: size, isLabelHidden
height: size, size="sm"
bgcolor: selected ? color : soft(color), options={substituteOptions}
color: selected ? '#fff' : color, value={substituteAssignments[row.userid] ? String(substituteAssignments[row.userid].userid) : ''}
transition: 'background-color 0.15s, color 0.15s' onChange={(v) => {
const newValue = (substituteRidersList || []).find((r) => String(r.userid) === v) || null;
handleAssignSubstitute(row.userid, newValue);
}} }}
> placeholder="Assign Substitute"
{children} hasSearch
</Avatar> hasClear
isDisabled={disabled}
/>
); );
return ( return (
<> <>
{/* Filter Row inside its own Paper */} <Card padding={3} elevation="none" style={{ marginBottom: 16 }}>
<Paper <HStack justify="between" vAlign="center" gap={3} wrap="wrap">
elevation={0} <HStack gap={2} vAlign="center" wrap="wrap">
sx={{ <Text weight="bold" color="secondary">
mb: 2,
p: 2,
borderRadius: `${DT.radiusCard / 8}px`,
border: '1px solid',
borderColor: DT.borderSubtle,
background: '#fff'
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems="center" justifyContent="space-between" gap={2}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DT.textSecondary, mr: 1 }}>
Shift Batch: Shift Batch:
</Typography> </Text>
<ToggleButtonGroup <SegmentedControl label="Shift batch" value={selectedBatch} onChange={setSelectedBatch} size="sm">
value={selectedBatch} {BATCH_OPTIONS.map((b) => (
exclusive <SegmentedControlItem key={b.value} value={b.value} label={b.label} />
onChange={(e, val) => val && setSelectedBatch(val)} ))}
aria-label="batch selection" </SegmentedControl>
size="small" </HStack>
sx={{ <DateInput
'& .MuiToggleButton-root': { label="Select Date"
borderRadius: '8px', isLabelHidden
mx: 0.5, size="sm"
border: `1px solid ${DT.borderSubtle}`, value={selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : undefined}
color: DT.textSecondary, onChange={(v) => v && setSelectedDate(dayjs(v))}
fontWeight: 600, />
textTransform: 'capitalize', </HStack>
px: 2, </Card>
py: 0.5,
'&.Mui-selected': {
bgcolor: BRAND,
color: '#fff',
'&:hover': {
bgcolor: '#262626'
}
}
}
}}
>
<ToggleButton value="all">All Batches</ToggleButton>
<ToggleButton value="morning">Morning</ToggleButton>
<ToggleButton value="afternoon">Afternoon</ToggleButton>
<ToggleButton value="evening">Evening</ToggleButton>
</ToggleButtonGroup>
</Stack>
<Box>
<DateInput
label="Select Date"
isLabelHidden
size="sm"
value={selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setSelectedDate(dayjs(v))}
/>
</Box>
</Stack>
</Paper>
{/* Results Table/Cards */} <Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
<Paper {isMobile ? (
elevation={0} filteredRows?.length === 0 ? (
sx={{ <EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
borderRadius: `${DT.radiusCard / 8}px`, ) : (
border: '1px solid', <MobileCardList scroll>
borderColor: DT.borderSubtle, {filteredRows?.map((row, index) => {
overflow: 'hidden', const statusMeta = getRowStatusMeta(row);
background: '#fff' const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
}} const starttime = riderProfile?.starttime || row.starttime;
> return (
<TableContainer <MobileCard
sx={{ key={row.userid ?? index}
maxHeight: 'calc(100vh - 240px)', accent={statusMeta.color}
'&::-webkit-scrollbar': { width: 10, height: 10 }, header={
'&::-webkit-scrollbar-thumb': { <VStack gap={2} padding={0}>
backgroundColor: edge(BRAND), <HStack justify="between" vAlign="center" gap={1}>
borderRadius: 8, <HStack gap={1.5} vAlign="center" minWidth={0}>
'&:hover': { backgroundColor: BRAND } <Text type="supporting" weight="bold" color="secondary">
}, {String(index + 1).padStart(2, '0')}
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt } </Text>
}} <div
> style={{
{isMobile ? ( display: 'inline-flex',
<MobileCardList> alignItems: 'center',
{filteredRows?.length === 0 && ( padding: '3px 8px',
<Stack alignItems="center" spacing={1.5} sx={{ py: 6 }}> borderRadius: 999,
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}> background: '#ffffff',
<MdTwoWheeler size={28} /> border: `1px solid ${edge(BRAND)}`,
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No active riders to show
</Typography>
</Stack>
)}
{filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const StatusIcon = statusMeta.icon;
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime;
return (
<MobileCard
key={row.userid ?? index}
accent={statusMeta.color}
header={
<Stack spacing={1.25}>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
{String(index + 1).padStart(2, '0')}
</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11
}}
>
#{row?.userid}
</Box>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
sx={{
display: 'inline-flex',
pl: 0.5,
pr: 1,
py: 0.25,
borderRadius: 999,
bgcolor: tint(statusMeta.color),
border: `1px solid ${edge(statusMeta.color)}`,
color: statusMeta.color
}}
>
<AccentAvatar color={statusMeta.color} size={18}>
<StatusIcon size={11} />
</AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: 10.5, lineHeight: 1 }}>
{statusMeta.label}
</Typography>
</Stack>
</Stack>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<Avatar
sx={{
width: 36,
height: 36,
bgcolor: soft(BRAND),
color: BRAND, color: BRAND,
fontWeight: 800, fontWeight: 800,
fontSize: 16, fontSize: 11
border: `1px solid ${edge(BRAND)}`,
flexShrink: 0
}} }}
> >
{(row.fullname || row.username || '?').charAt(0).toUpperCase()} #{row?.userid}
</Avatar> </div>
<Box sx={{ minWidth: 0 }}> <Badge variant={statusMeta.badge} icon={<statusMeta.icon size={11} />} label={statusMeta.label} />
<Typography sx={{ fontWeight: 800, color: DT.textPrimary, fontSize: 15 }} noWrap> </HStack>
{row.username || '—'} </HStack>
</Typography> <HStack gap={2} vAlign="center">
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap> <AccentAvatar color={BRAND} selected size={36}>
{row.contactno || '—'} <Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</Typography> </AccentAvatar>
</Box> <VStack gap={0} padding={0} minWidth={0}>
</Stack> <Text weight="bold" maxLines={1}>
</Stack> {row.username || '—'}
} </Text>
> <Text type="supporting" color="secondary" maxLines={1}>
<MobileFieldGrid> {row.contactno || '—'}
<MobileField label="Substitute" full> </Text>
<Autocomplete </VStack>
size="small" </HStack>
disabled={isBatchDisabled(getBatchForTime(starttime))} </VStack>
options={substituteRidersList || []}
isOptionEqualToValue={(option, value) => option?.userid === value?.userid}
getOptionLabel={(option) => `${option.username || option.fullname || ''} (#${option.userid})`}
value={substituteAssignments[row.userid] || null}
onChange={(event, newValue) => handleAssignSubstitute(row.userid, newValue)}
renderInput={(params) => (
<TextField
{...params}
placeholder="Assign Substitute"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
bgcolor: '#f8fafc',
'& fieldset': { borderColor: DT.borderSubtle }
}
}}
/>
)}
/>
</MobileField>
</MobileFieldGrid>
</MobileCard>
);
})}
</MobileCardList>
) : (
<Table stickyHeader sx={{ minWidth: 1200 }}>
<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 }
} }
}} >
> <MobileFieldGrid columns={1}>
<TableCell>#</TableCell> <MobileField label="Substitute" full>
<TableCell>ID</TableCell> {renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
<TableCell>Rider</TableCell> </MobileField>
<TableCell align="center"></TableCell> </MobileFieldGrid>
<TableCell>Substitute Rider</TableCell> </MobileCard>
<TableCell align="center">Status</TableCell> );
})}
</MobileCardList>
)
) : (
<TableScroll minWidth={1200}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell>
<TableHeaderCell>Substitute Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
</TableRow> </TableRow>
</TableHead> </TableHeader>
<TableBody> <TableBody>
{filteredRows?.length === 0 && ( {filteredRows?.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={totalCols} sx={{ py: 6 }}> <TableCell colSpan={6}>
<Stack alignItems="center" spacing={1.5}> <EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdTwoWheeler size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No active riders to show
</Typography>
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -427,37 +286,24 @@ export default function RiderSubstitution({
{filteredRows?.length !== 0 && {filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => { filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row); const statusMeta = getRowStatusMeta(row);
const StatusIcon = statusMeta.icon;
const riderProfile = allRidersList?.find((r) => r.userid === row.userid); const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime; const starttime = riderProfile?.starttime || row.starttime;
return ( return (
<TableRow <TableRow key={row.userid ?? index}>
key={row.userid ?? 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> <TableCell>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}> <Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')} {String(index + 1).padStart(2, '0')}
</Typography> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Box <div
sx={{ style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
px: 1, padding: '3px 8px',
py: 0.375,
borderRadius: 999, borderRadius: 999,
bgcolor: '#ffffff', background: '#ffffff',
border: `1px solid ${edge(BRAND)}`, border: `1px solid ${edge(BRAND)}`,
color: BRAND, color: BRAND,
fontWeight: 800, fontWeight: 800,
@@ -466,126 +312,43 @@ export default function RiderSubstitution({
}} }}
> >
#{row?.userid} #{row?.userid}
</Box> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" alignItems="center" spacing={1}> <HStack gap={2} vAlign="center">
<Avatar <AccentAvatar color={BRAND} selected size={36}>
sx={{ <Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
width: 36,
height: 36,
bgcolor: soft(BRAND),
color: BRAND,
fontWeight: 800,
fontSize: 16,
border: `1px solid ${edge(BRAND)}`
}}
>
{(row.fullname || row.username || '?').charAt(0).toUpperCase()}
</Avatar>
<Stack sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.username || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.contactno || '—'}
</Typography>
</Stack>
</Stack>
</TableCell>
{/* Substitution Arrow & Autocomplete */}
<TableCell align="center">
<MdArrowForward size={18} style={{ color: DT.textSecondary }} />
</TableCell>
<TableCell sx={{ minWidth: 220 }}>
<Autocomplete
size="small"
disabled={isBatchDisabled(getBatchForTime(starttime))}
options={substituteRidersList || []}
isOptionEqualToValue={(option, value) => option?.userid === value?.userid}
getOptionLabel={(option) => `${option.username || option.fullname || ''} (#${option.userid})`}
value={substituteAssignments[row.userid] || null}
onChange={(event, newValue) => handleAssignSubstitute(row.userid, newValue)}
renderInput={(params) => (
<TextField
{...params}
placeholder="Assign Substitute"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
bgcolor: '#f8fafc',
'& fieldset': { borderColor: DT.borderSubtle }
}
}}
/>
)}
/>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
alignItems="center"
spacing={0.5}
sx={{
display: 'inline-flex',
pl: 0.5,
pr: 1,
py: 0.25,
borderRadius: 999,
bgcolor: tint(statusMeta.color),
border: `1px solid ${edge(statusMeta.color)}`,
color: statusMeta.color
}}
>
<AccentAvatar color={statusMeta.color} size={20}>
<StatusIcon size={12} />
</AccentAvatar> </AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: 11, lineHeight: 1 }}> <VStack gap={0} padding={0} minWidth={0}>
{statusMeta.label} <Text weight="semibold">{row.username || '—'}</Text>
</Typography> <Text type="supporting" color="secondary">
</Stack> {row.contactno || '—'}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<MdArrowForward size={18} color={DT.textSecondary} />
</TableCell>
<TableCell style={{ minWidth: 220 }}>{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
)} </TableScroll>
</TableContainer>
{hasAssignments && (
<Box
sx={{
p: 2,
borderTop: `1px solid ${DT.borderSubtle}`,
display: 'flex',
justifyContent: 'flex-end',
bgcolor: DT.surfaceAlt
}}
>
<Button
variant="contained"
disabled={isFinalizing}
onClick={handleFinalize}
sx={{
bgcolor: BRAND,
color: '#fff',
borderRadius: '8px',
px: 4,
py: 1,
fontWeight: 700,
textTransform: 'none',
'&:hover': {
bgcolor: '#262626'
}
}}
>
{isFinalizing ? 'Saving...' : 'Finalize Substitutions'}
</Button>
</Box>
)} )}
</Paper> {hasAssignments && (
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
<Button label={isFinalizing ? 'Saving...' : 'Finalize Substitutions'} variant="primary" onClick={handleFinalize} isLoading={isFinalizing} />
</HStack>
)}
</Card>
</> </>
); );
} }

View File

@@ -1,36 +1,27 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
// material-ui
import {
Autocomplete,
Avatar,
Box,
Button,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdDirectionsBike } from 'react-icons/md';
import { DT, tint } from 'themes/dt/tokens';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { MdDirectionsBike } from 'react-icons/md';
// project import import { Grid } from '@astryxdesign/core/Grid';
import MainCard from 'components/MainCard'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { createMiler, getHubs } from 'pages/api/doormileApi'; import { createMiler, getHubs } from 'pages/api/doormileApi';
import { getTenants, fetchAppLocations } from 'pages/api/api'; import { getTenants, fetchAppLocations } from 'pages/api/api';
import Loader from 'components/Loader'; import { DT } from 'themes/dt/tokens';
import { enqueueSnackbar } from 'notistack'; import { AccentAvatar } from 'themes/dt/primitives';
import { useNavigate } from 'react-router';
const BRAND = DT.brand;
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van']; const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const VEHICLE_TYPE_OPTIONS = VEHICLE_TYPES.map((v) => ({ value: v, label: v }));
const Createrider = () => { const Createrider = () => {
const [displayname, setDisplayname] = useState(''); const [displayname, setDisplayname] = useState('');
@@ -39,13 +30,11 @@ const Createrider = () => {
const [mobilenumber, setMobilenumber] = useState(''); const [mobilenumber, setMobilenumber] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [vehicletype, setVehicletype] = useState('Bike'); const [vehicletype, setVehicletype] = useState('Bike');
const [selectedTenant, setSelectedTenant] = useState(null); const [selectedTenantId, setSelectedTenantId] = useState('');
const [selectedHub, setSelectedHub] = useState(null); const [selectedHubId, setSelectedHubId] = useState('');
const [selectedLocation, setSelectedLocation] = useState(null); const [selectedLocationId, setSelectedLocationId] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// Doormile staff (tenantid 0/null in the token) manage riders across every // Doormile staff (tenantid 0/null in the token) manage riders across every
// tenant and must pick one; a client console login is scoped to its own // tenant and must pick one; a client console login is scoped to its own
@@ -69,16 +58,15 @@ const Createrider = () => {
queryFn: fetchAppLocations queryFn: fetchAppLocations
}); });
const hubOptions = useMemo(() => hubs || [], [hubs]); const tenantOptions = useMemo(() => (tenants || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` })), [tenants]);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]); const hubOptions = useMemo(() => (hubs || []).map((h) => ({ value: String(h.hubid), label: h.hubname || `Hub #${h.hubid}` })), [hubs]);
const locationOptions = useMemo(
() => (locations || []).filter((l) => l.applocationid).map((l) => ({ value: String(l.applocationid), label: l.locationname || `Location #${l.applocationid}` })),
[locations]
);
const opentoast = (message) => { const opentoast = (message) =>
enqueueSnackbar(message, { enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
const createMilerMutation = useMutation({ const createMilerMutation = useMutation({
mutationFn: (payload) => createMiler(payload), mutationFn: (payload) => createMiler(payload),
@@ -94,13 +82,11 @@ const Createrider = () => {
opentoast(res.message || 'Failed to create rider'); opentoast(res.message || 'Failed to create rider');
} }
}, },
onError: (err) => { onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create rider')
opentoast(err.response?.data?.message || err.message || 'Failed to create rider');
}
}); });
const createprofile = () => { const createprofile = () => {
const tenantid = isDoormileStaff ? selectedTenant?.tenantid : Number(loginTenantId); const tenantid = isDoormileStaff ? (selectedTenantId ? Number(selectedTenantId) : undefined) : Number(loginTenantId);
if (!displayname) { if (!displayname) {
opentoast('Fill Display Name'); opentoast('Fill Display Name');
@@ -114,7 +100,7 @@ const Createrider = () => {
opentoast('Fill Password'); opentoast('Fill Password');
} else if (isDoormileStaff && !tenantid) { } else if (isDoormileStaff && !tenantid) {
opentoast('Choose Tenant'); opentoast('Choose Tenant');
} else if (!selectedLocation?.applocationid) { } else if (!selectedLocationId) {
opentoast('Choose City'); opentoast('Choose City');
} else { } else {
// POST /admin/milers — configid defaults to 1001 server-side (the // POST /admin/milers — configid defaults to 1001 server-side (the
@@ -127,8 +113,8 @@ const Createrider = () => {
password, password,
tenantid, tenantid,
defaultvehicletype: vehicletype, defaultvehicletype: vehicletype,
applocationid: selectedLocation?.applocationid, applocationid: Number(selectedLocationId),
hubid: selectedHub?.hubid hubid: selectedHubId ? Number(selectedHubId) : undefined
}; };
createMilerMutation.mutate(obj); createMilerMutation.mutate(obj);
@@ -139,184 +125,70 @@ const Createrider = () => {
<> <>
{createMilerMutation.isPending && <Loader />} {createMilerMutation.isPending && <Loader />}
<Box sx={{ p: { xs: 1.5, md: 3 } }}> <PageHeader
<Grid item xs={12} sx={{ mb: 2 }}> title="Create Rider"
<Paper subtitle="Add a miler-app login"
sx={{ action={
p: 2.5, <AccentAvatar color={BRAND} selected size={40}>
borderRadius: DT.radiusCard + 'px', <MdDirectionsBike size={20} />
boxShadow: DT.shadowSoft, </AccentAvatar>
border: '1px solid', }
borderColor: DT.borderSubtle, />
background: `linear-gradient(135deg, ${tint('#000000')} 0%, ${tint('#3A3A3A')} 100%)`
}}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 48, height: 48, bgcolor: DT.brand }}>
<MdDirectionsBike size={22} />
</Avatar>
<Typography variant="h3">Create Rider</Typography>
</Stack>
</Paper>
</Grid>
<MainCard>
<Grid container spacing={3}>
<Grid item xs={12}>
<MainCard height="100%">
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-display-name">Display Name</InputLabel>
<TextField
fullWidth
id="rider-display-name"
placeholder="e.g. Murali S"
onChange={(e) => setDisplayname(e.target.value)}
value={displayname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-auth-name">Login Name</InputLabel>
<TextField
fullWidth
id="rider-auth-name"
placeholder="Login name used on the miler app"
onChange={(e) => setAuthname(e.target.value)}
value={authname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-phone">Phone Number</InputLabel>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Select defaultValue="+1" disabled sx={{ cursor: 'not-allowed' }}>
<MenuItem value="+1">+91</MenuItem>
</Select>
<TextField
type="number"
id="rider-phone"
fullWidth
placeholder="Phone Number"
onChange={(e) => {
if (e.target.value.toString().length <= 10) {
setMobilenumber(e.target.value);
}
}}
value={mobilenumber}
autoComplete="off"
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-email">Email Address</InputLabel>
<TextField
type="email"
fullWidth
id="rider-email"
placeholder="Email Address"
onChange={(e) => setEmailaddress(e.target.value)}
value={emailaddress}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}> <Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<Stack spacing={1.25}> <VStack gap={4} padding={0}>
<InputLabel htmlFor="rider-password">Password</InputLabel> <Grid columns={{ minWidth: 260 }} gap={3}>
<TextField <TextInput label="Display Name" placeholder="e.g. Murali S" value={displayname} onChange={setDisplayname} isRequired />
type="password" <TextInput label="Login Name" placeholder="Login name used on the miler app" value={authname} onChange={setAuthname} isRequired />
fullWidth <TextInput
id="rider-password" label="Phone Number"
placeholder="Miler-app login password" placeholder="10-digit mobile number"
onChange={(e) => setPassword(e.target.value)} value={mobilenumber}
value={password} onChange={(v) => {
autoComplete="off" const digits = v.replace(/\D/g, '');
/> if (digits.length <= 10) setMobilenumber(digits);
</Stack> }}
</Grid> isRequired
/>
<Grid item xs={12} sm={6}> <TextInput label="Email Address" type="email" placeholder="Email Address" value={emailaddress} onChange={setEmailaddress} isRequired />
<Stack spacing={1.25}> <TextInput label="Password" type="password" placeholder="Miler-app login password" value={password} onChange={setPassword} isRequired />
<InputLabel htmlFor="rider-vehicle-type">Vehicle Type</InputLabel> <Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={vehicletype} onChange={setVehicletype} />
<Select id="rider-vehicle-type" fullWidth value={vehicletype} onChange={(e) => setVehicletype(e.target.value)}> {isDoormileStaff && (
{VEHICLE_TYPES.map((v) => ( <Selector
<MenuItem key={v} value={v}> label="Tenant"
{v} options={tenantOptions}
</MenuItem> value={selectedTenantId}
))} onChange={setSelectedTenantId}
</Select> placeholder="Choose tenant"
</Stack> hasSearch
</Grid> isRequired
/>
{isDoormileStaff && ( )}
<Grid item xs={12} sm={6}> <Selector
<Stack spacing={1.25}> label="City"
<InputLabel htmlFor="rider-tenant">Tenant</InputLabel> options={locationOptions}
<Autocomplete value={selectedLocationId}
id="rider-tenant" onChange={setSelectedLocationId}
options={tenants || []} placeholder="Choose city"
getOptionLabel={(option) => option.tenantname || ''} hasSearch
value={selectedTenant} isRequired
onChange={(e, value) => setSelectedTenant(value)} />
renderInput={(params) => <TextField {...params} placeholder="Choose tenant" />} <Selector
/> label="Hub"
</Stack> options={hubOptions}
</Grid> value={selectedHubId}
)} onChange={setSelectedHubId}
placeholder="Choose hub (visibility on hub console)"
<Grid item xs={12} sm={6}> hasSearch
<Stack spacing={1.25}> isOptional
<InputLabel htmlFor="rider-location">City</InputLabel> />
<Autocomplete
id="rider-location"
options={locationOptions}
getOptionLabel={(option) => option.locationname || ''}
value={selectedLocation}
onChange={(e, value) => setSelectedLocation(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose city" />}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-hub">Hub (optional)</InputLabel>
<Autocomplete
id="rider-hub"
options={hubOptions}
getOptionLabel={(option) => option.hubname || ''}
value={selectedHub}
onChange={(e, value) => setSelectedHub(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose hub (visibility on hub console)" />}
/>
</Stack>
</Grid>
</Grid>
</MainCard>
</Grid>
<Grid item xs={12}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="flex-end"
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={2}
>
<Button variant="contained" onClick={createprofile} fullWidth={isMobile}>
Create
</Button>
</Stack>
</Grid>
</Grid> </Grid>
</MainCard>
</Box> <HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createMilerMutation.isPending} />
</HStack>
</VStack>
</Card>
</> </>
); );
}; };

View File

@@ -1,45 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
// material-ui
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { enqueueSnackbar } from 'notistack';
import { MdDirectionsBike, MdArrowBack } from 'react-icons/md';
import { import { Grid } from '@astryxdesign/core/Grid';
Avatar, import { HStack } from '@astryxdesign/core/HStack';
Box, import { VStack } from '@astryxdesign/core/VStack';
Button, import { Card } from '@astryxdesign/core/Card';
Grid, import { Text } from '@astryxdesign/core/Text';
InputLabel, import { Button } from '@astryxdesign/core/Button';
MenuItem, import { TextInput } from '@astryxdesign/core/TextInput';
Select, import { Selector } from '@astryxdesign/core/Selector';
Stack,
TextField,
Typography,
Autocomplete,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdDirectionsBike } from 'react-icons/md';
import { DT, tint } from 'themes/dt/tokens';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
// project import import Loader from 'components/Loader';
import MainCard from 'components/MainCard'; import PageHeader from 'components/nearle_components/PageHeader';
import { getMiler, updateMiler, getAdminTenant } from 'pages/api/doormileApi'; import { getMiler, updateMiler, getAdminTenant } from 'pages/api/doormileApi';
import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api'; import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api';
import Loader from 'components/Loader'; import { DT } from 'themes/dt/tokens';
import { enqueueSnackbar } from 'notistack'; import { AccentAvatar } from 'themes/dt/primitives';
import CircularLoader from 'components/CircularLoader';
const BRAND = DT.brand;
const EditRider = () => { const EditRider = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const location = useLocation(); const location = useLocation();
const [riderdata, setRiderdata] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [riderdata, setRiderdata] = useState(null);
const [partnerlist, setPartnerlist] = useState([]); const [partnerlist, setPartnerlist] = useState([]);
const [locaName, setLocoName] = useState();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchRiderData = async (id) => { const fetchRiderData = async (id) => {
@@ -74,11 +60,12 @@ const EditRider = () => {
} }
}; };
// ==============================|| fetchAppLocations ||============================== //
useEffect(() => { useEffect(() => {
fetchZoneLocations().then(setPartnerlist); fetchZoneLocations().then(setPartnerlist);
}, []); }, []);
const locationOptions = (partnerlist || []).map((l) => ({ value: String(l.applocationid), label: l.locationname }));
const updateRider = async () => { const updateRider = async () => {
setLoading(true); setLoading(true);
// PUT /admin/milers/:id has no documented fields for bank/vehicle/shift // PUT /admin/milers/:id has no documented fields for bank/vehicle/shift
@@ -126,229 +113,94 @@ const EditRider = () => {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<> <>
{loading && ( {loading && <Loader />}
<>
<Loader /> <PageHeader
<CircularLoader /> title="Edit Rider"
</> subtitle={riderdata?.displayname || 'Update miler-app login'}
)} action={
<MainCard
title={
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={{ xs: 1.5, sm: 0 }}
sx={{
width: '100%',
height: '100%',
p: 2,
background: `linear-gradient(135deg, ${tint('#000000')} 0%, ${tint('#3A3A3A')} 100%)`
}}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 40, height: 40, bgcolor: DT.brand }}>
<MdDirectionsBike size={20} />
</Avatar>
<Typography variant="h3">Edit Rider</Typography>
</Stack>
<Button
startIcon={<ArrowBackIcon />}
variant="outlined"
fullWidth={isMobile}
onClick={() => {
setRiderdata(null);
navigate('/doormile/riders');
}}
>
Back to Riders
</Button>
</Stack>
}
>
<Box
sx={{
maxHeight: 'calc(100vh - 280px)',
overflowY: 'auto',
overflowX: 'hidden',
scrollbarGutter: 'stable',
scrollBehavior: 'smooth'
}}
>
<Stack display={'flex'} spacing={{ xs: 2.5, sm: 5 }}>
{/* || =========================================== || Contact Information || =========================================== || */}
<MainCard
title={
<Typography variant="h4" sx={{ m: 2 }}>
Contact Information
</Typography>
}
>
<Grid container spacing={{ xs: 2, sm: 3 }}>
{/* ========================== || Display Name || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-display-name">Display Name</InputLabel>
<TextField
fullWidth
id="personal-display-name"
value={riderdata?.displayname || ''}
onChange={(e) => {
setRiderdata({
...riderdata,
displayname: e.target.value
});
}}
placeholder="Name"
autoComplete="off"
/>
</Stack>
</Grid>
{/* ========================== || Login Name || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-auth-name">Login Name</InputLabel>
<TextField fullWidth id="personal-auth-name" value={riderdata?.authname || ''} disabled autoComplete="off" />
</Stack>
</Grid>
{/* ========================== || Phone Number || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-phone">Phone Number</InputLabel>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
<Select defaultValue="+1" disabled sx={{ cursor: 'not-allowed' }}>
<MenuItem value="+1">+91</MenuItem>
</Select>
<TextField
type="number"
id="personal-phone"
// format="##########"
// mask="_"
fullWidth
placeholder="Phone Number"
onChange={(e) => {
setRiderdata({
...riderdata,
phone: e.target.value
});
}}
value={riderdata?.phone || ''}
autoComplete="off"
// disabled
sx={{ cursor: 'not-allowed' }}
/>
</Stack>
</Stack>
</Grid>{' '}
{/* ========================== || Email Address || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-email">Email Address</InputLabel>
<TextField
type="email"
fullWidth
// defaultValue="stebin.ben@gmail.com"
id="personal-email"
placeholder="Email Address"
onChange={(e) => {
setRiderdata({
...riderdata,
email: e.target.value
});
}}
value={riderdata?.email}
autoComplete="off"
/>
</Stack>
</Grid>
{/* Address/Suburb/City/State removed — GET /admin/milers has
no such fields at all (confirmed live), so they always
loaded blank and updateRider() never submitted them. */}
{/* ========================== || Identification No|| ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">Identification No</InputLabel>
<TextField
fullWidth
id="personal-location"
placeholder="Identification No"
onChange={(e) => {
setRiderdata({
...riderdata,
identificationno: e.target.value
});
}}
value={riderdata?.identificationno}
autoComplete="off"
/>
</Stack>
</Grid>
{/* ========================== || City / Zone || ========================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="personal-location">City / Zone</InputLabel>
<Autocomplete
disablePortal
id="combo-box-demo"
options={partnerlist}
getOptionLabel={(option) => `${option.locationname}`}
sx={{ width: { xs: '100%', sm: 300 }, height: '30px', ml: { xs: 0, sm: 3 }, zIndex: '100' }}
onChange={(event, value) => {
if (value) {
// This picker is fetchAppLocations() (hub-derived
// cities), not a Partners resource — applocationid
// is the only confirmed miler field it feeds.
setLocoName(value.locationname);
setRiderdata({
...riderdata,
applocationid: value.applocationid
});
}
}}
renderInput={(params) => <TextField {...params} label={locaName || riderdata?.city} />}
/>
</Stack>
</Grid>
</Grid>
</MainCard>
{/* Charges/Bank Details/Vehicle Details sections removed — PUT
/admin/milers/:id has no matching fields for any of them (no
shift/basefare/bank/vehicle sub-object on a miler), so nothing
typed into them ever reached the server. */}
</Stack>
{/* ================= FIXED BOTTOM ACTION ================= */}
</Box>
</MainCard>
{/* ========================== || Update || ========================== */}
<Box
sx={{
position: 'sticky',
bottom: 0,
backgroundColor: DT.surfaceAlt,
p: 2,
zIndex: 10,
border: '1px solid',
borderColor: DT.borderSubtle,
borderTop: 'none'
}}
>
<Stack direction={{ xs: 'column-reverse', sm: 'row' }} justifyContent="flex-end" spacing={2}>
<Button startIcon={<ArrowBackIcon />} variant="outlined" fullWidth={isMobile} onClick={() => navigate('/doormile/riders')}>
Back to Riders
</Button>
<Button <Button
variant="contained" label="Back to Riders"
fullWidth={isMobile} variant="secondary"
icon={<MdArrowBack size={16} />}
onClick={() => { onClick={() => {
updateRider(); setRiderdata(null);
navigate('/doormile/riders');
}} }}
> />
Update }
</Button> />
</Stack>
</Box> <Card padding={0} elevation="none" style={{ marginTop: 16 }}>
<HStack gap={2} vAlign="center" padding={3} style={{ borderBottom: `1px solid ${DT.divider}`, backgroundColor: DT.surfaceAlt }}>
<AccentAvatar color={BRAND} selected size={32}>
<MdDirectionsBike size={16} />
</AccentAvatar>
<Text weight="bold">Contact Information</Text>
</HStack>
<VStack gap={0} padding={4}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<TextInput
label="Display Name"
placeholder="Name"
value={riderdata?.displayname || ''}
onChange={(v) => setRiderdata({ ...riderdata, displayname: v })}
/>
<TextInput label="Login Name" value={riderdata?.authname || ''} isDisabled />
<TextInput
label="Phone Number"
placeholder="Phone Number"
value={riderdata?.phone || ''}
onChange={(v) => setRiderdata({ ...riderdata, phone: v.replace(/\D/g, '') })}
/>
<TextInput
label="Email Address"
type="email"
placeholder="Email Address"
value={riderdata?.email || ''}
onChange={(v) => setRiderdata({ ...riderdata, email: v })}
/>
<TextInput
label="Identification No"
placeholder="Identification No"
value={riderdata?.identificationno || ''}
onChange={(v) => setRiderdata({ ...riderdata, identificationno: v })}
/>
<Selector
label="City / Zone"
options={locationOptions}
value={riderdata?.applocationid ? String(riderdata.applocationid) : ''}
onChange={(v) => {
const loc = (partnerlist || []).find((l) => String(l.applocationid) === v);
setRiderdata({ ...riderdata, applocationid: loc?.applocationid });
}}
placeholder={riderdata?.city || 'Choose city / zone'}
hasSearch
/>
</Grid>
{/* Address/Suburb/City/State removed — GET /admin/milers has no such
fields at all (confirmed live), so they always loaded blank and
updateRider() never submitted them. Charges/Bank Details/Vehicle
Details sections removed — PUT /admin/milers/:id has no matching
fields for any of them (no shift/basefare/bank/vehicle sub-object on
a miler), so nothing typed into them ever reached the server. */}
</VStack>
</Card>
<Card
padding={3}
elevation="none"
style={{ position: 'sticky', bottom: 0, marginTop: 16, backgroundColor: DT.surfaceAlt, zIndex: 10 }}
>
<HStack gap={2} justify="end">
<Button label="Back to Riders" variant="secondary" icon={<MdArrowBack size={16} />} onClick={() => navigate('/doormile/riders')} />
<Button label="Update" variant="primary" onClick={updateRider} isLoading={loading} />
</HStack>
</Card>
</> </>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,28 @@
import React, { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import {
Autocomplete,
Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
InputLabel,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md'; import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard'; import StatCard from 'components/nearle_components/StatCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import { getTripsheets, createTripsheet, addTripsheetItem, dispatchTripsheet, arriveTripsheet, getHubs, getVehicles, getMilers } from 'pages/api/doormileApi';
import { import { DT } from 'themes/dt/tokens';
getTripsheets, import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
createTripsheet,
addTripsheetItem,
dispatchTripsheet,
arriveTripsheet,
getHubs,
getVehicles,
getMilers
} from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/tokens';
// ============================================================================ // ============================================================================
// Hub-to-hub transport. GET /admin/tripsheets response shape isn't documented // Hub-to-hub transport. GET /admin/tripsheets response shape isn't documented
@@ -51,13 +31,11 @@ import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/toke
// { sourcehubid, destinationhubid, vehicleid, driveruserid }. // { sourcehubid, destinationhubid, vehicleid, driveruserid }.
// ============================================================================ // ============================================================================
const BRAND = '#000000'; const BRAND = DT.brand;
const emptyForm = { sourceHub: null, destinationHub: null, vehicle: null, driver: null }; const emptyForm = { sourceHub: '', destinationHub: '', vehicle: '', driver: '' };
const Tripsheets = () => { const Tripsheets = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
@@ -70,6 +48,15 @@ const Tripsheets = () => {
const { data: milers = [] } = useQuery({ queryKey: ['admin-milers-for-trips'], queryFn: getMilers }); const { data: milers = [] } = useQuery({ queryKey: ['admin-milers-for-trips'], queryFn: getMilers });
const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]); const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
const hubOptions = useMemo(() => (hubs || []).map((h) => ({ value: String(h.hubid), label: h.hubname || `Hub #${h.hubid}` })), [hubs]);
const vehicleOptions = useMemo(
() => (vehicles || []).map((v) => ({ value: String(v.vehicleid), label: v.vehicleno || `Vehicle #${v.vehicleid}` })),
[vehicles]
);
const driverOptions = useMemo(
() => (milers || []).map((m) => ({ value: String(m.userid), label: m.displayname || m.authname || `Driver #${m.userid}` })),
[milers]
);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = tripsheets.length; const total = tripsheets.length;
@@ -128,13 +115,15 @@ const Tripsheets = () => {
return; return;
} }
createMutation.mutate({ createMutation.mutate({
sourcehubid: form.sourceHub.hubid, sourcehubid: Number(form.sourceHub),
destinationhubid: form.destinationHub.hubid, destinationhubid: Number(form.destinationHub),
vehicleid: form.vehicle.vehicleid, vehicleid: Number(form.vehicle),
driveruserid: form.driver?.userid driveruserid: form.driver ? Number(form.driver) : undefined
}); });
}; };
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
return ( return (
<> <>
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />} {(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
@@ -143,172 +132,190 @@ const Tripsheets = () => {
title="Tripsheets" title="Tripsheets"
subtitle="Live · Hub-to-Hub Transport" subtitle="Live · Hub-to-Hub Transport"
live live
action={ action={<Button label="New Tripsheet" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
New Tripsheet
</Button>
}
/> />
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}> <Grid columns={{ minWidth: 220, max: 2, repeat: 'fit' }} gap={3}>
<Grid item xs={6}> <StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} />
<StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} /> <StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
</Grid>
<Grid item xs={6}>
<StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
</Grid>
</Grid> </Grid>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: DT.borderSubtle, borderRadius: DT.radiusCard / 8 + 'px', overflow: 'hidden', background: '#fff' }}> <Card padding={0} elevation="none" style={{ marginTop: 16, overflow: 'hidden' }}>
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' }, ...tableScrollSx }}> <TableScroll minWidth={920}>
<Table stickyHeader sx={{ minWidth: isMobile ? 860 : 920 }}> <Table density="balanced" dividers="rows" hasHover>
<TableHead> <TableHeader>
<TableRow sx={tableHeadSx}> <TableRow>
<TableCell>#</TableCell> <TableHeaderCell>#</TableHeaderCell>
<TableCell>Source Hub</TableCell> <TableHeaderCell>Source Hub</TableHeaderCell>
<TableCell>Destination Hub</TableCell> <TableHeaderCell>Destination Hub</TableHeaderCell>
<TableCell>Status</TableCell> <TableHeaderCell>Status</TableHeaderCell>
<TableCell align="right">Actions</TableCell> <TableHeaderCell>Actions</TableHeaderCell>
</TableRow> </TableRow>
</TableHead> </TableHeader>
<TableBody> <TableBody>
{isLoading && <OrdersTableSkeleton col={3} />} {isLoading && <TableLoader rows={5} columns={5} />}
{tripsheets.length === 0 && !isLoading ? ( {tripsheets.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} sx={{ py: 6 }}> <TableCell colSpan={5}>
<Stack alignItems="center" spacing={1.5}> <EmptyState
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}> title="No tripsheets to show"
<MdOutlineLocalShipping size={28} /> description="Create a tripsheet to move consignments between hubs."
</Avatar> icon={<MdOutlineLocalShipping size={28} />}
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> />
No tripsheets to show
</Typography>
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
tripsheets.map((row, index) => ( tripsheets.map((row, index) => (
<TableRow key={row.tripsheetid || index} sx={tableRowSx}> <TableRow key={row.tripsheetid || index}>
<TableCell>{index + 1}</TableCell> <TableCell>{index + 1}</TableCell>
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell> <TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
<TableCell>{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}</TableCell> <TableCell>
{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}
</TableCell>
<TableCell>{row.status || '—'}</TableCell> <TableCell>{row.status || '—'}</TableCell>
<TableCell align="right"> <TableCell>
<Stack direction="row" spacing={0.5} justifyContent="flex-end"> <HStack gap={1} justify="end">
<Tooltip title="Add item"> <IconButton
<IconButton size="small" onClick={() => setItemDialogTrip(row)} sx={{ color: BRAND }}> label="Add item"
<MdPlaylistAdd size={16} /> tooltip="Add item"
</IconButton> size="sm"
</Tooltip> variant="ghost"
<Tooltip title="Dispatch"> icon={<MdPlaylistAdd size={16} />}
<IconButton onClick={() => setItemDialogTrip(row)}
size="small" />
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })} <IconButton
sx={{ color: '#14b8a6' }} label="Dispatch"
> tooltip="Dispatch"
<MdOutlineFlightTakeoff size={16} /> size="sm"
</IconButton> variant="ghost"
</Tooltip> icon={<MdOutlineFlightTakeoff size={16} />}
<Tooltip title="Arrive"> onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
<IconButton />
size="small" <IconButton
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })} label="Arrive"
sx={{ color: '#10b981' }} tooltip="Arrive"
> size="sm"
<MdOutlineFlightLand size={16} /> variant="ghost"
</IconButton> icon={<MdOutlineFlightLand size={16} />}
</Tooltip> onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
</Stack> />
</HStack>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableScroll>
</Paper> </Card>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth> <Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={480} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>New Tripsheet</DialogTitle> <Layout
<DialogContent> header={
<Grid container spacing={2} sx={{ mt: 0.5 }}> <DialogHeader
<Grid item xs={12}> title="New Tripsheet"
<Stack spacing={1}> subtitle="Move consignments between two hubs"
<InputLabel>Source Hub</InputLabel> onOpenChange={(next) => !next && setDialogOpen(false)}
<Autocomplete startContent={
options={hubs || []} <AccentAvatar color={BRAND} selected size={36}>
getOptionLabel={(option) => option.hubname || ''} <MdOutlineLocalShipping size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Selector
label="Source Hub"
options={hubOptions}
value={form.sourceHub} value={form.sourceHub}
onChange={(e, value) => setForm((f) => ({ ...f, sourceHub: value }))} onChange={set('sourceHub')}
renderInput={(params) => <TextField {...params} placeholder="Choose source hub" />} placeholder="Choose source hub"
hasSearch
isRequired
/> />
</Stack> <Selector
</Grid> label="Destination Hub"
<Grid item xs={12}> options={hubOptions}
<Stack spacing={1}>
<InputLabel>Destination Hub</InputLabel>
<Autocomplete
options={hubs || []}
getOptionLabel={(option) => option.hubname || ''}
value={form.destinationHub} value={form.destinationHub}
onChange={(e, value) => setForm((f) => ({ ...f, destinationHub: value }))} onChange={set('destinationHub')}
renderInput={(params) => <TextField {...params} placeholder="Choose destination hub" />} placeholder="Choose destination hub"
hasSearch
isRequired
/> />
</Stack> <Selector
</Grid> label="Vehicle"
<Grid item xs={12}> options={vehicleOptions}
<Stack spacing={1}>
<InputLabel>Vehicle</InputLabel>
<Autocomplete
options={vehicles || []}
getOptionLabel={(option) => option.vehicleno || ''}
value={form.vehicle} value={form.vehicle}
onChange={(e, value) => setForm((f) => ({ ...f, vehicle: value }))} onChange={set('vehicle')}
renderInput={(params) => <TextField {...params} placeholder="Choose vehicle" />} placeholder="Choose vehicle"
hasSearch
isRequired
/> />
</Stack> <Selector
</Grid> label="Driver"
<Grid item xs={12}> options={driverOptions}
<Stack spacing={1}>
<InputLabel>Driver (optional)</InputLabel>
<Autocomplete
options={milers || []}
getOptionLabel={(option) => option.displayname || option.authname || ''}
value={form.driver} value={form.driver}
onChange={(e, value) => setForm((f) => ({ ...f, driver: value }))} onChange={set('driver')}
renderInput={(params) => <TextField {...params} placeholder="Choose driver" />} placeholder="Choose driver"
hasSearch
isOptional
/> />
</Stack> </VStack>
</Grid> </LayoutContent>
</Grid> }
</DialogContent> footer={
<DialogActions sx={{ px: 3, pb: 2 }}> <LayoutFooter hasDivider>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button> <HStack gap={2} justify="end">
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}> <Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={createMutation.isPending} />
Create <Button label="Create" variant="primary" onClick={handleCreate} isLoading={createMutation.isPending} />
</Button> </HStack>
</DialogActions> </LayoutFooter>
}
/>
</Dialog> </Dialog>
<Dialog open={!!itemDialogTrip} onClose={() => setItemDialogTrip(null)} maxWidth="xs" fullWidth> <Dialog isOpen={!!itemDialogTrip} onOpenChange={(next) => !next && setItemDialogTrip(null)} width={420} purpose="form">
<DialogTitle sx={{ fontWeight: 700 }}>Add Item to Tripsheet</DialogTitle> <Layout
<DialogContent> header={
<Stack spacing={1} sx={{ mt: 1 }}> <DialogHeader
<InputLabel>Consignment ID</InputLabel> title="Add Item to Tripsheet"
<TextField value={itemConsignmentId} onChange={(e) => setItemConsignmentId(e.target.value)} autoFocus /> subtitle={itemDialogTrip ? `Tripsheet #${itemDialogTrip.tripsheetid}` : ''}
</Stack> onOpenChange={(next) => !next && setItemDialogTrip(null)}
</DialogContent> startContent={
<DialogActions sx={{ px: 3, pb: 2 }}> <AccentAvatar color={BRAND} selected size={36}>
<Button onClick={() => setItemDialogTrip(null)}>Cancel</Button> <MdPlaylistAdd size={18} />
<Button </AccentAvatar>
variant="contained" }
disabled={!itemConsignmentId} />
onClick={() => addItemMutation.mutate({ id: itemDialogTrip.tripsheetid, consignmentid: itemConsignmentId })} }
sx={{ bgcolor: BRAND }} content={
> <LayoutContent padding={4}>
Add Item <TextInput
</Button> label="Consignment ID"
</DialogActions> value={itemConsignmentId}
onChange={setItemConsignmentId}
hasAutoFocus
isRequired
/>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setItemDialogTrip(null)} isDisabled={addItemMutation.isPending} />
<Button
label="Add Item"
variant="primary"
isDisabled={!itemConsignmentId}
isLoading={addItemMutation.isPending}
onClick={() => addItemMutation.mutate({ id: itemDialogTrip.tripsheetid, consignmentid: itemConsignmentId })}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog> </Dialog>
</> </>
); );

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd } from 'react-icons/md'; import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd, MdBatteryFull } from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
@@ -69,7 +69,8 @@ const Vehicles = () => {
// of 'active', so counting against that string always read 0. // of 'active', so counting against that string always read 0.
const active = vehicles.filter((v) => String(v.status).toLowerCase() === 'available').length; const active = vehicles.filter((v) => String(v.status).toLowerCase() === 'available').length;
const lowBattery = vehicles.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20).length; const lowBattery = vehicles.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20).length;
return { total, active, lowBattery }; const fullBattery = vehicles.filter((v) => Number(v.batterypercentage) >= 80).length;
return { total, active, lowBattery, fullBattery };
}, [vehicles]); }, [vehicles]);
const opentoast = (message, variant = 'error') => const opentoast = (message, variant = 'error') =>
@@ -156,10 +157,11 @@ const Vehicles = () => {
action={<Button label="New Vehicle" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />} action={<Button label="New Vehicle" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/> />
<Grid columns={{ minWidth: 220 }} gap={3}> <Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} /> <StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
<StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} /> <StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} />
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} /> <StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} />
<StatCard title="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} />
</Grid> </Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}> <VStack gap={0} padding={0} style={{ marginTop: 16 }}>

View File

@@ -1,78 +1,60 @@
import { useState } from 'react'; import { useState } from 'react';
import { Avatar, Box, Button, Chip, Grid, InputLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { MdBadge, MdPerson, MdLocationOn, MdMail, MdShield, MdVerifiedUser, MdLock } from 'react-icons/md'; import { MdBadge, MdPerson, MdLocationOn, MdMail, MdShield, MdVerifiedUser, MdLock } from 'react-icons/md';
import CircularLoader from 'components/CircularLoader'; import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Badge } from '@astryxdesign/core/Badge';
import { Avatar } from '@astryxdesign/core/Avatar';
import { Divider } from '@astryxdesign/core/Divider';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { getusers } from 'pages/api/api'; import { getusers } from 'pages/api/api';
import { updateProfilePassword } from 'pages/api/doormileApi'; import { updateProfilePassword } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack'; import { DT } from 'themes/dt/tokens';
import { DT, tint, soft } from 'themes/dt/tokens'; import { AccentAvatar } from 'themes/dt/primitives';
const ROLE_LABELS = { 1: 'Admin', 3: 'Manager', 4: 'Executive' }; const ROLE_LABELS = { 1: 'Admin', 3: 'Manager', 4: 'Executive' };
const BRAND = '#000000'; const BRAND = DT.brand;
const BRAND_LIGHT = '#3A3A3A';
const initialsOf = (name) =>
(name || '')
.trim()
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase() || '–';
// A single read-only field rendered as an icon + uppercase label + value. // A single read-only field rendered as an icon + uppercase label + value.
const InfoField = ({ icon: Icon, label, value, accent = BRAND }) => ( const InfoField = ({ icon: Icon, label, value, accent = BRAND }) => (
<Stack direction="row" spacing={1.5} alignItems="flex-start" sx={{ minWidth: 0 }}> <HStack gap={3} vAlign="start">
<Avatar sx={{ width: 36, height: 36, bgcolor: soft(accent), color: accent, flexShrink: 0 }}> <AccentAvatar color={accent} selected size={36}>
<Icon size={18} /> <Icon size={18} />
</Avatar> </AccentAvatar>
<Box sx={{ minWidth: 0 }}> <VStack gap={0} padding={0}>
<Typography <Text type="label" weight="semibold" color="secondary">
variant="overline"
sx={{ display: 'block', color: DT.textMuted, lineHeight: 1.6, letterSpacing: '0.06em' }}
>
{label} {label}
</Typography> </Text>
<Typography <Text weight="semibold" title={value || ''}>
variant="body1"
sx={{ fontWeight: 600, color: DT.textPrimary, wordBreak: 'break-word' }}
title={value || ''}
>
{value || '—'} {value || '—'}
</Typography> </Text>
</Box> </VStack>
</Stack> </HStack>
); );
// A titled grouping card. // A titled grouping card.
const SectionCard = ({ title, icon: Icon, children }) => ( const SectionCard = ({ title, icon: Icon, children }) => (
<Paper <Card padding={0} elevation="none">
elevation={0} <HStack gap={2} vAlign="center" padding={3} style={{ borderBottom: `1px solid ${DT.divider}`, backgroundColor: DT.surfaceAlt }}>
sx={{ <AccentAvatar color={BRAND} selected size={28}>
borderRadius: DT.radiusCard + 'px',
border: `1px solid ${DT.borderSubtle}`,
boxShadow: DT.shadowSoft,
overflow: 'hidden',
height: '100%'
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1.25}
sx={{ px: 2.5, py: 1.75, borderBottom: `1px solid ${DT.divider}`, bgcolor: DT.surfaceAlt }}
>
<Avatar sx={{ width: 28, height: 28, bgcolor: soft(BRAND), color: BRAND }}>
<Icon size={16} /> <Icon size={16} />
</Avatar> </AccentAvatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}> <Text weight="bold">{title}</Text>
{title} </HStack>
</Typography> <VStack gap={0} padding={3}>
</Stack> {children}
<Box sx={{ p: 2.5 }}>{children}</Box> </VStack>
</Paper> </Card>
); );
const ViewProfile = () => { const ViewProfile = () => {
@@ -85,50 +67,31 @@ const ViewProfile = () => {
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const opentoast = (message, variant = 'error') =>
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const changePasswordMutation = useMutation({ const changePasswordMutation = useMutation({
mutationFn: () => updateProfilePassword(currentPassword, newPassword), mutationFn: () => updateProfilePassword(currentPassword, newPassword),
onSuccess: (res) => { onSuccess: (res) => {
if (res.success) { if (res.success) {
enqueueSnackbar('Password updated successfully', { opentoast('Password updated successfully', 'success');
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
setCurrentPassword(''); setCurrentPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
} else { } else {
enqueueSnackbar(res.message || 'Failed to update password', { opentoast(res.message || 'Failed to update password');
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
} }
}, },
onError: (err) => { onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update password')
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to update password', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
}); });
const handleChangePassword = () => { const handleChangePassword = () => {
if (!currentPassword || !newPassword) { if (!currentPassword || !newPassword) {
enqueueSnackbar('Fill current and new password', { opentoast('Fill current and new password');
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return; return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
enqueueSnackbar('New password and confirmation do not match', { opentoast('New password and confirmation do not match');
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return; return;
} }
changePasswordMutation.mutate(); changePasswordMutation.mutate();
@@ -145,146 +108,75 @@ const ViewProfile = () => {
return ( return (
<> <>
{isLoading && ( {isLoading && <Loader />}
<>
<Loader />
<CircularLoader />
</>
)}
<Box sx={{ p: { xs: 1.5, md: 0 } }}> <PageHeader title="My Profile" subtitle="Account & Security" />
{/* ---- Gradient brand header ---- */}
<Paper
elevation={0}
sx={{
borderRadius: DT.radiusCard + 'px',
border: `1px solid ${DT.borderSubtle}`,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`,
boxShadow: DT.shadowSoft,
p: { xs: 2.5, md: 3.5 },
mb: { xs: 2.5, md: 3 }
}}
>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={{ xs: 2, sm: 3 }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
>
<Avatar
sx={{
width: { xs: 64, md: 76 },
height: { xs: 64, md: 76 },
bgcolor: BRAND,
color: '#fff',
fontSize: { xs: 24, md: 28 },
fontWeight: 700,
boxShadow: '0 10px 24px rgba(0, 0, 0, 0.35)'
}}
>
{initialsOf(fullname)}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h3" sx={{ color: DT.textPrimary, fontWeight: 700, mb: 0.5 }}>
{fullname}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
{roleLabel && (
<Chip
size="small"
icon={<MdVerifiedUser size={14} style={{ color: BRAND }} />}
label={roleLabel}
sx={{
bgcolor: soft(BRAND),
color: BRAND,
fontWeight: 700,
border: `1px solid ${BRAND}40`,
'& .MuiChip-icon': { ml: '6px' }
}}
/>
)}
{(userData?.id != null || userData?.userid != null) && (
<Typography variant="body2" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
ID&nbsp;#{userData.id ?? userData.userid}
</Typography>
)}
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: DT.textSecondary }}>
<MdLocationOn size={15} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{tenantLabel}
</Typography>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
{/* ---- Grouped detail cards ---- */} <Card padding={4} elevation="none" style={{ marginTop: 16, marginBottom: 20 }}>
<Grid container spacing={{ xs: 2.5, md: 3 }}> <HStack gap={3} vAlign="center" wrap="wrap">
<Grid item xs={12} md={6}> <Avatar name={fullname} size={72} tooltip={false} />
<SectionCard title="Account" icon={MdBadge}> <VStack gap={2} padding={0}>
<Stack spacing={2.5}> <Text type="heading" weight="bold">
<InfoField icon={MdPerson} label="User Name" value={userData?.name || userData?.fullname} /> {fullname}
<InfoField icon={MdBadge} label="User ID" value={userData?.id ?? userData?.userid} /> </Text>
<InfoField icon={MdMail} label="E-Mail" value={userData?.email} accent="#0ea5e9" /> <HStack gap={2} vAlign="center" wrap="wrap">
<InfoField icon={MdVerifiedUser} label="Role" value={roleLabel} /> {roleLabel !== '—' && <Badge variant="neutral" icon={<MdVerifiedUser size={14} />} label={roleLabel} />}
<InfoField icon={MdLocationOn} label="Tenant" value={tenantLabel} accent="#14b8a6" /> {(userData?.id != null || userData?.userid != null) && (
</Stack> <Text color="secondary" weight="semibold">
</SectionCard> ID #{userData.id ?? userData.userid}
</Grid> </Text>
)}
<HStack gap={1} vAlign="center">
<MdLocationOn size={15} color={DT.textSecondary} />
<Text color="secondary" weight="semibold">
{tenantLabel}
</Text>
</HStack>
</HStack>
</VStack>
</HStack>
</Card>
<Grid item xs={12} md={6}> <Grid columns={{ minWidth: 320 }} gap={3}>
<SectionCard title="Security" icon={MdShield}> <SectionCard title="Account" icon={MdBadge}>
<Stack spacing={2}> <VStack gap={4} padding={0}>
<Stack spacing={1}> <InfoField icon={MdPerson} label="User Name" value={userData?.name || userData?.fullname} />
<InputLabel htmlFor="current-password">Current Password</InputLabel> <InfoField icon={MdBadge} label="User ID" value={userData?.id ?? userData?.userid} />
<TextField <InfoField icon={MdMail} label="E-Mail" value={userData?.email} accent="#0ea5e9" />
id="current-password" <InfoField icon={MdVerifiedUser} label="Role" value={roleLabel} />
type="password" <InfoField icon={MdLocationOn} label="Tenant" value={tenantLabel} accent="#14b8a6" />
fullWidth </VStack>
placeholder="Current password" </SectionCard>
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} <SectionCard title="Security" icon={MdShield}>
autoComplete="off" <VStack gap={3} padding={0}>
/> <TextInput
</Stack> label="Current Password"
<Stack spacing={1}> type="password"
<InputLabel htmlFor="new-password">New Password</InputLabel> value={currentPassword}
<TextField onChange={setCurrentPassword}
id="new-password" placeholder="Current password"
type="password" />
fullWidth <TextInput label="New Password" type="password" value={newPassword} onChange={setNewPassword} placeholder="New password" />
placeholder="New password" <TextInput
value={newPassword} label="Confirm New Password"
onChange={(e) => setNewPassword(e.target.value)} type="password"
autoComplete="off" value={confirmPassword}
/> onChange={setConfirmPassword}
</Stack> placeholder="Confirm new password"
<Stack spacing={1}> />
<InputLabel htmlFor="confirm-password">Confirm New Password</InputLabel> <Divider />
<TextField <Button
id="confirm-password" label="Update Password"
type="password" variant="primary"
fullWidth icon={<MdLock size={16} />}
placeholder="Confirm new password" onClick={handleChangePassword}
value={confirmPassword} isLoading={changePasswordMutation.isPending}
onChange={(e) => setConfirmPassword(e.target.value)} style={{ alignSelf: 'flex-start' }}
autoComplete="off" />
/> </VStack>
</Stack> </SectionCard>
<Button </Grid>
variant="contained"
startIcon={<MdLock size={16} />}
onClick={handleChangePassword}
disabled={changePasswordMutation.isPending}
sx={{ alignSelf: 'flex-start' }}
>
Update Password
</Button>
</Stack>
</SectionCard>
</Grid>
</Grid>
</Box>
</> </>
); );
}; };

View File

@@ -67,6 +67,11 @@ export const STATUS_ALIASES = {
miler_assigned: 'accepted', miler_assigned: 'accepted',
pickup_scheduled: 'accepted', pickup_scheduled: 'accepted',
converted_to_consignment: 'accepted', converted_to_consignment: 'accepted',
// `Out_for_Delivery` is a confirmed live booking status (named in
// express-console-api.md's CityGate note, and mapped to 'active' by
// api.js's BOOKING_STATUS_TO_DELIVERY_STATUS). Without this entry it fell
// through to the neutral "unknown" badge showing the raw enum string.
out_for_delivery: 'active',
in_transit: 'active', in_transit: 'active',
intransit: 'active', intransit: 'active',
completed: 'delivered', completed: 'delivered',

View File

@@ -62,95 +62,3 @@ export const tint = (c) => a(c, '08'); // very subtle surface tint
export const soft = (c) => a(c, '18'); // soft chip / avatar bg export const soft = (c) => a(c, '18'); // soft chip / avatar bg
export const ring = (c) => a(c, '26'); // focus ring color export const ring = (c) => a(c, '26'); // focus ring color
export const edge = (c) => a(c, '55'); // resting border export const edge = (c) => a(c, '55'); // resting border
// ---------------------------------------------------------------------------
// ⛔ LEGACY — MUI-ONLY. Everything from here to the end of this file emits MUI
// `sx` objects and cannot be used by an Astryx page.
//
// They stay only because the pages still awaiting conversion import them
// (`pillFieldSx` ×3 files, `tableScrollSx` ×4, `tableHeadSx` ×3,
// `tableRowSx` ×3). They are deleted along with the MUI dependency once the
// last page converts — do NOT add a new call site.
//
// Astryx equivalents:
// pillFieldSx → nothing. Field chrome comes from the theme
// (`themes/astryx.js` → components['text-input']), so every
// input matches without a per-call-site helper.
// tableScrollSx → <TableScroll> from themes/dt/primitives (also supplies
// the sticky header MUI's TableContainer used to provide).
// tableHeadSx → <Table density dividers hasHover> — header casing and
// row rules are the component's own chrome.
// tableRowSx → same; `hasHover` covers the hover tint.
// ---------------------------------------------------------------------------
// Pill input sx — used by every filter Autocomplete/TextField on a page.
// Neutral, corporate filter field: white surface, hairline border, brand
// focus ring. Width is driven by parent flex/grid so this helper stays
// width-agnostic.
export const pillFieldSx = () => ({
cursor: 'pointer',
'& .MuiOutlinedInput-root': {
borderRadius: DT.radiusField + 'px',
bgcolor: DT.surface,
fontWeight: 600,
color: DT.textPrimary,
paddingRight: '8px',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
'& fieldset': { borderColor: DT.borderSubtle, borderWidth: 1 },
'&:hover fieldset': { borderColor: DT.borderHover },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(DT.brand)}` },
'&.Mui-focused fieldset': { borderColor: DT.brand, borderWidth: 1.5 }
},
'& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: DT.textMuted }
});
// ---------------------------------------------------------------------------
// Table chrome — lifted verbatim from deliveries.js, the design reference for
// every operator list page (root CLAUDE.md §6). These three used to be
// copy-pasted into each page's <TableContainer>/<TableHead>/<TableRow>, which
// is how pages drifted apart (grey scrollbars on some, brand-red on others).
// Spread them instead of restating the rules:
//
// <TableContainer sx={{ maxHeight: ..., ...tableScrollSx }}>
// <Table stickyHeader>
// <TableHead><TableRow sx={tableHeadSx}>...
// <TableBody><TableRow sx={tableRowSx}>...
// ---------------------------------------------------------------------------
// Brand-red scrollbar. Track is the page's alt surface so the gutter reads as
// part of the table rather than a hole in it.
export const tableScrollSx = {
overflow: 'auto',
'&::-webkit-scrollbar': { width: '10px', height: '10px', cursor: 'pointer' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge(DT.brand),
borderRadius: '8px',
cursor: 'pointer',
'&:hover': { backgroundColor: DT.brand }
},
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
};
// Uppercase muted header row on the alt surface.
export const tableHeadSx = {
'& 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 }
}
};
// Body row: hairline divider, tinted hover. No shadow on hover — see the
// design-system "don'ts" in root CLAUDE.md §6.
export const tableRowSx = {
'& td': { borderBottom: `1px solid ${DT.divider}` },
'&:hover': { backgroundColor: DT.surfaceAlt }
};

View File

@@ -1,156 +0,0 @@
import PropTypes from 'prop-types';
import { useMemo } from 'react';
// material-ui
import { CssBaseline, GlobalStyles, StyledEngineProvider } from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
// project import
import useConfig from 'hooks/useConfig';
import Palette from './palette';
import Typography from './typography';
import CustomShadows from './shadows';
import componentsOverride from './overrides';
// Refined enterprise elevation ramp — soft, layered, low-contrast shadows
// (Stripe/Linear feel) instead of MUI's default hard grey drop-shadows.
// MUI requires exactly 25 entries (index 0 = 'none').
const softShadows = [
'none',
'0 1px 2px rgba(15, 23, 42, 0.04)',
'0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04)',
'0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04)',
'0 4px 10px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.05)',
'0 6px 14px rgba(15, 23, 42, 0.07), 0 2px 4px rgba(15, 23, 42, 0.05)',
'0 8px 18px rgba(15, 23, 42, 0.08), 0 2px 5px rgba(15, 23, 42, 0.05)',
'0 10px 22px rgba(15, 23, 42, 0.08), 0 3px 6px rgba(15, 23, 42, 0.05)',
'0 12px 26px rgba(15, 23, 42, 0.09), 0 4px 8px rgba(15, 23, 42, 0.06)',
'0 14px 30px rgba(15, 23, 42, 0.10), 0 4px 9px rgba(15, 23, 42, 0.06)',
'0 16px 34px rgba(15, 23, 42, 0.10), 0 5px 10px rgba(15, 23, 42, 0.06)',
'0 18px 38px rgba(15, 23, 42, 0.11), 0 6px 11px rgba(15, 23, 42, 0.07)',
'0 20px 42px rgba(15, 23, 42, 0.11), 0 6px 12px rgba(15, 23, 42, 0.07)',
'0 22px 46px rgba(15, 23, 42, 0.12), 0 7px 13px rgba(15, 23, 42, 0.07)',
'0 24px 50px rgba(15, 23, 42, 0.12), 0 8px 14px rgba(15, 23, 42, 0.08)',
'0 26px 54px rgba(15, 23, 42, 0.13), 0 8px 15px rgba(15, 23, 42, 0.08)',
'0 28px 58px rgba(15, 23, 42, 0.13), 0 9px 16px rgba(15, 23, 42, 0.08)',
'0 30px 62px rgba(15, 23, 42, 0.14), 0 10px 17px rgba(15, 23, 42, 0.09)',
'0 32px 66px rgba(15, 23, 42, 0.14), 0 10px 18px rgba(15, 23, 42, 0.09)',
'0 34px 70px rgba(15, 23, 42, 0.15), 0 11px 19px rgba(15, 23, 42, 0.09)',
'0 36px 74px rgba(15, 23, 42, 0.15), 0 12px 20px rgba(15, 23, 42, 0.10)',
'0 38px 78px rgba(15, 23, 42, 0.16), 0 12px 21px rgba(15, 23, 42, 0.10)',
'0 40px 82px rgba(15, 23, 42, 0.16), 0 13px 22px rgba(15, 23, 42, 0.10)',
'0 42px 86px rgba(15, 23, 42, 0.17), 0 14px 23px rgba(15, 23, 42, 0.11)',
'0 44px 90px rgba(15, 23, 42, 0.18), 0 14px 24px rgba(15, 23, 42, 0.11)'
];
// ==============================|| DEFAULT THEME - MAIN ||============================== //
export default function ThemeCustomization({ children }) {
const { themeDirection, mode, presetColor, fontFamily } = useConfig();
const theme = useMemo(() => Palette(mode, presetColor), [mode, presetColor]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const themeTypography = useMemo(() => Typography(fontFamily), [fontFamily]);
const themeCustomShadows = useMemo(() => CustomShadows(theme), [theme]);
const themeOptions = useMemo(
() => ({
breakpoints: {
values: {
xs: 0, // Extra-small: 0px and up
custom300: 300, // above 350
custom350: 350, // above 350
custom400: 400, // above 400
custom450: 450, // above 450
custom500: 500, // above 450
custom550: 550, // above 450
custom600: 600, // above 450
custom650: 650, // above 450
custom700: 700, // above 450
custom750: 750, // above 450
sm: 768, // Small: 768px and up
custom800: 800, // above 450,
custom850: 850, // above 450,
custom900: 900, // above 450
custom950: 950, // above 450
custom1000: 1000, // above 450
md: 1024, // Medium: 1024px and up
lg: 1266, // Large: 1266px and up
custom1300: 1300, // Large: 1266px and up
custom1350: 1350, // Large: 1266px and up
xl: 1440 // Extra-large: 1440px and up
}
},
direction: themeDirection,
shape: {
borderRadius: 8
},
mixins: {
toolbar: {
minHeight: 60,
paddingTop: 8,
paddingBottom: 8
}
},
palette: theme.palette,
customShadows: themeCustomShadows,
typography: themeTypography,
shadows: softShadows
}),
[themeDirection, theme, themeTypography, themeCustomShadows]
);
const themes = createTheme(themeOptions);
themes.components = componentsOverride(themes);
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={themes}>
<CssBaseline />
<GlobalStyles
styles={{
// Crisp font rendering across the app (enterprise polish).
body: {
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility'
},
// Thin, unobtrusive scrollbars (Stripe/Linear style) instead of the
// chunky 14px solid bar. Track is transparent; the thumb is a soft
// translucent black that deepens toward the brand black on hover.
'*': {
scrollbarWidth: 'thin', // Firefox
scrollbarColor: 'rgba(0, 0, 0, 0.28) transparent'
},
'*::-webkit-scrollbar': {
width: '10px',
height: '10px'
},
'*::-webkit-scrollbar-track': {
backgroundColor: 'transparent'
},
'*::-webkit-scrollbar-thumb': {
backgroundColor: 'rgba(0, 0, 0, 0.26)',
borderRadius: '8px',
border: '2px solid transparent',
backgroundClip: 'padding-box'
},
'*::-webkit-scrollbar-thumb:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.45)'
},
'*::-webkit-scrollbar-corner': {
backgroundColor: 'transparent'
}
}}
/>
{children}
</ThemeProvider>
</StyledEngineProvider>
);
}
ThemeCustomization.propTypes = {
children: PropTypes.node
};

View File

@@ -1,27 +0,0 @@
// ==============================|| OVERRIDES - ALERT TITLE ||============================== //
export default function Accordion(theme) {
return {
MuiAccordion: {
defaultProps: {
disableGutters: true,
square: true,
elevation: 0
},
styleOverrides: {
root: {
border: `1px solid ${theme.palette.secondary.light}`,
'&:not(:last-child)': {
borderBottom: 0
},
'&:before': {
display: 'none'
},
'&.Mui-disabled': {
backgroundColor: theme.palette.secondary.lighter
}
}
}
}
};
}

View File

@@ -1,14 +0,0 @@
// ==============================|| OVERRIDES - ALERT TITLE ||============================== //
export default function AccordionDetails(theme) {
return {
MuiAccordionDetails: {
styleOverrides: {
root: {
padding: theme.spacing(2),
borderTop: `1px solid ${theme.palette.secondary.light}`
}
}
}
};
}

View File

@@ -1,33 +0,0 @@
// assets
import { RightOutlined } from '@ant-design/icons';
// ==============================|| OVERRIDES - ALERT TITLE ||============================== //
export default function AccordionSummary(theme) {
const { palette, spacing } = theme;
return {
MuiAccordionSummary: {
defaultProps: {
expandIcon: <RightOutlined style={{ fontSize: '0.75rem' }} />
},
styleOverrides: {
root: {
backgroundColor: palette.secondary.lighter,
flexDirection: 'row-reverse',
minHeight: 46
},
expandIconWrapper: {
'&.Mui-expanded': {
transform: 'rotate(90deg)'
}
},
content: {
marginTop: spacing(1.25),
marginBottom: spacing(1.25),
marginLeft: spacing(1)
}
}
}
};
}

View File

@@ -1,75 +0,0 @@
// material-ui
import { alpha } from '@mui/material/styles';
// project import
import getColors from 'utils/getColors';
// ==============================|| ALERT - COLORS ||============================== //
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { lighter, light, main } = colors;
return {
borderColor: alpha(light, 0.5),
backgroundColor: lighter,
'& .MuiAlert-icon': {
color: main
}
};
}
// ==============================|| OVERRIDES - ALERT ||============================== //
export default function Alert(theme) {
const primaryDashed = getColorStyle({ color: 'primary', theme });
return {
MuiAlert: {
styleOverrides: {
root: {
color: theme.palette.text.primary,
fontSize: '0.875rem'
},
icon: {
fontSize: '1rem'
},
message: {
padding: 0,
marginTop: 3
},
filled: {
color: theme.palette.grey[0]
},
border: {
padding: '10px 16px',
border: '1px solid',
...primaryDashed,
'&.MuiAlert-borderPrimary': getColorStyle({ color: 'primary', theme }),
'&.MuiAlert-borderSecondary': getColorStyle({ color: 'secondary', theme }),
'&.MuiAlert-borderError': getColorStyle({ color: 'error', theme }),
'&.MuiAlert-borderSuccess': getColorStyle({ color: 'success', theme }),
'&.MuiAlert-borderInfo': getColorStyle({ color: 'info', theme }),
'&.MuiAlert-borderWarning': getColorStyle({ color: 'warning', theme })
},
action: {
'& .MuiButton-root': {
padding: 2,
height: 'auto',
fontSize: '0.75rem',
marginTop: -2
},
'& .MuiIconButton-root': {
width: 'auto',
height: 'auto',
padding: 2,
marginRight: 6,
'& .MuiSvgIcon-root': {
fontSize: '1rem'
}
}
}
}
}
};
}

View File

@@ -1,15 +0,0 @@
// ==============================|| OVERRIDES - ALERT TITLE ||============================== //
export default function AlertTitle() {
return {
MuiAlertTitle: {
styleOverrides: {
root: {
marginBottom: 4,
marginTop: 0,
fontWeight: 400
}
}
}
};
}

View File

@@ -1,44 +0,0 @@
// ==============================|| OVERRIDES - AUTOCOMPLETE ||============================== //
export default function Autocomplete() {
return {
MuiAutocomplete: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
padding: '3px 9px'
}
},
// Soft, rounded popup with comfortable option rows — matches the
// SoftPaper used by the redesigned pages.
paper: {
borderRadius: 12,
boxShadow: '0 18px 50px rgba(15, 23, 42, 0.18)',
border: '1px solid #e2e8f0',
overflow: 'hidden'
},
listbox: {
padding: 6,
'& .MuiAutocomplete-option': {
borderRadius: 8,
margin: '1px 0',
'&[aria-selected="true"]': {
backgroundColor: 'rgba(0, 0, 0, 0.08)'
},
'&.Mui-focused': {
backgroundColor: 'rgba(0, 0, 0, 0.06)'
}
}
},
popupIndicator: {
width: 'auto',
height: 'auto'
},
clearIndicator: {
width: 'auto',
height: 'auto'
}
}
}
};
}

View File

@@ -1,41 +0,0 @@
// project import
import getColors from 'utils/getColors';
// ==============================|| BADGE - COLORS ||============================== //
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { lighter, main } = colors;
return {
color: main,
backgroundColor: lighter
};
}
// ==============================|| OVERRIDES - BADGE ||============================== //
export default function Badge(theme) {
const defaultLightBadge = getColorStyle({ color: 'primary', theme });
return {
MuiBadge: {
styleOverrides: {
standard: {
minWidth: theme.spacing(2),
height: theme.spacing(2),
padding: theme.spacing(0.5)
},
light: {
...defaultLightBadge,
'&.MuiBadge-colorPrimary': getColorStyle({ color: 'primary', theme }),
'&.MuiBadge-colorSecondary': getColorStyle({ color: 'secondary', theme }),
'&.MuiBadge-colorError': getColorStyle({ color: 'error', theme }),
'&.MuiBadge-colorInfo': getColorStyle({ color: 'info', theme }),
'&.MuiBadge-colorSuccess': getColorStyle({ color: 'success', theme }),
'&.MuiBadge-colorWarning': getColorStyle({ color: 'warning', theme })
}
}
}
};
}

View File

@@ -1,181 +0,0 @@
// material-ui
import { alpha } from '@mui/material/styles';
// project import
import getColors from 'utils/getColors';
import getShadow from 'utils/getShadow';
// ==============================|| BUTTON - COLORS ||============================== //
function getColorStyle({ variant, color, theme }) {
const colors = getColors(theme, color);
const { lighter, main, dark, contrastText } = colors;
const buttonShadow = `${color}Button`;
const shadows = getShadow(theme, buttonShadow);
// Clean keyboard-focus ring (soft brand-tinted halo) — replaces the old
// flashy expanding ::after glow that read as "template / AI default".
const commonShadow = {
'&:focus-visible': {
boxShadow: `0 0 0 3px ${alpha(main, 0.32)}`
}
};
switch (variant) {
case 'contained':
return {
boxShadow: 'none',
'&:hover': {
backgroundColor: dark,
boxShadow: 'none'
},
...commonShadow
};
case 'shadow':
return {
color: contrastText,
backgroundColor: main,
boxShadow: shadows,
'&:hover': {
boxShadow: 'none',
backgroundColor: dark
},
...commonShadow
};
case 'outlined':
return {
borderColor: main,
'&:hover': {
color: dark,
backgroundColor: 'transparent',
borderColor: dark
},
...commonShadow
};
case 'dashed':
return {
color: main,
borderColor: main,
backgroundColor: lighter,
'&:hover': {
color: dark,
borderColor: dark
},
...commonShadow
};
case 'text':
default:
return {
'&:hover': {
color: dark,
backgroundColor: lighter
},
...commonShadow
};
}
}
// ==============================|| OVERRIDES - BUTTON ||============================== //
export default function Button(theme) {
const primaryDashed = getColorStyle({ variant: 'dashed', color: 'primary', theme });
const primaryShadow = getColorStyle({ variant: 'shadow', color: 'primary', theme });
const disabledStyle = {
'&.Mui-disabled': {
backgroundColor: theme.palette.grey[200]
}
};
const iconStyle = {
'&>*:nth-of-type(1)': {
fontSize: 'inherit'
}
};
return {
MuiButton: {
defaultProps: {
disableElevation: true
},
styleOverrides: {
root: {
fontWeight: 600,
borderRadius: 8,
textTransform: 'none',
transition: 'background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease'
},
contained: {
...disabledStyle
},
outlined: {
...disabledStyle
},
text: {
boxShadow: 'none',
'&:hover': {
boxShadow: 'none'
}
},
endIcon: {
...iconStyle
},
startIcon: {
...iconStyle
},
dashed: {
border: '1px dashed',
...primaryDashed,
'&.MuiButton-dashedPrimary': getColorStyle({ variant: 'dashed', color: 'primary', theme }),
'&.MuiButton-dashedSecondary': getColorStyle({ variant: 'dashed', color: 'secondary', theme }),
'&.MuiButton-dashedError': getColorStyle({ variant: 'dashed', color: 'error', theme }),
'&.MuiButton-dashedSuccess': getColorStyle({ variant: 'dashed', color: 'success', theme }),
'&.MuiButton-dashedInfo': getColorStyle({ variant: 'dashed', color: 'info', theme }),
'&.MuiButton-dashedWarning': getColorStyle({ variant: 'dashed', color: 'warning', theme }),
'&.Mui-disabled': {
color: `${theme.palette.grey[300]} !important`,
borderColor: `${theme.palette.grey[400]} !important`,
backgroundColor: `${theme.palette.grey[200]} !important`
}
},
shadow: {
...primaryShadow,
'&.MuiButton-shadowPrimary': getColorStyle({ variant: 'shadow', color: 'primary', theme }),
'&.MuiButton-shadowSecondary': getColorStyle({ variant: 'shadow', color: 'secondary', theme }),
'&.MuiButton-shadowError': getColorStyle({ variant: 'shadow', color: 'error', theme }),
'&.MuiButton-shadowSuccess': getColorStyle({ variant: 'shadow', color: 'success', theme }),
'&.MuiButton-shadowInfo': getColorStyle({ variant: 'shadow', color: 'info', theme }),
'&.MuiButton-shadowWarning': getColorStyle({ variant: 'shadow', color: 'warning', theme }),
'&.Mui-disabled': {
color: `${theme.palette.grey[300]} !important`,
borderColor: `${theme.palette.grey[400]} !important`,
backgroundColor: `${theme.palette.grey[200]} !important`
}
},
containedPrimary: getColorStyle({ variant: 'contained', color: 'primary', theme }),
containedSecondary: getColorStyle({ variant: 'contained', color: 'secondary', theme }),
containedError: getColorStyle({ variant: 'contained', color: 'error', theme }),
containedSuccess: getColorStyle({ variant: 'contained', color: 'success', theme }),
containedInfo: getColorStyle({ variant: 'contained', color: 'info', theme }),
containedWarning: getColorStyle({ variant: 'contained', color: 'warning', theme }),
outlinedPrimary: getColorStyle({ variant: 'outlined', color: 'primary', theme }),
outlinedSecondary: getColorStyle({ variant: 'outlined', color: 'secondary', theme }),
outlinedError: getColorStyle({ variant: 'outlined', color: 'error', theme }),
outlinedSuccess: getColorStyle({ variant: 'outlined', color: 'success', theme }),
outlinedInfo: getColorStyle({ variant: 'outlined', color: 'info', theme }),
outlinedWarning: getColorStyle({ variant: 'outlined', color: 'warning', theme }),
textPrimary: getColorStyle({ variant: 'text', color: 'primary', theme }),
textSecondary: getColorStyle({ variant: 'text', color: 'secondary', theme }),
textError: getColorStyle({ variant: 'text', color: 'error', theme }),
textSuccess: getColorStyle({ variant: 'text', color: 'success', theme }),
textInfo: getColorStyle({ variant: 'text', color: 'info', theme }),
textWarning: getColorStyle({ variant: 'text', color: 'warning', theme }),
sizeExtraSmall: {
minWidth: 56,
fontSize: '0.625rem',
padding: '2px 8px'
}
}
}
};
}

View File

@@ -1,11 +0,0 @@
// ==============================|| OVERRIDES - BUTTON ||============================== //
export default function ButtonBase() {
return {
MuiButtonBase: {
defaultProps: {
disableRipple: true
}
}
};
}

View File

@@ -1,11 +0,0 @@
// ==============================|| OVERRIDES - BUTTON GROUP ||============================== //
export default function ButtonGroup() {
return {
MuiButtonGroup: {
defaultProps: {
disableRipple: true
}
}
};
}

View File

@@ -1,16 +0,0 @@
// ==============================|| OVERRIDES - CARD CONTENT ||============================== //
export default function CardContent() {
return {
MuiCardContent: {
styleOverrides: {
root: {
padding: 20,
'&:last-child': {
paddingBottom: 20
}
}
}
}
};
}

View File

@@ -1,126 +0,0 @@
// material-ui
import { Box } from '@mui/material';
// project import
import getColors from 'utils/getColors';
// assets
import { CheckSquareFilled, MinusSquareFilled } from '@ant-design/icons';
// ==============================|| RADIO - COLORS ||============================== //
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { lighter, main, dark } = colors;
return {
'&:hover': {
backgroundColor: lighter,
'& .icon': {
borderColor: main
}
},
'&.Mui-focusVisible': {
outline: `2px solid ${dark}`,
outlineOffset: -4
}
};
}
// ==============================|| CHECKBOX - SIZE STYLE ||============================== //
function getSizeStyle(size) {
switch (size) {
case 'small':
return { size: 16, fontSize: 1, position: 1 };
case 'large':
return { size: 24, fontSize: 1.6, position: 2 };
case 'medium':
default:
return { size: 20, fontSize: 1.35, position: 2 };
}
}
// ==============================|| CHECKBOX - STYLE ||============================== //
function checkboxStyle(size) {
const sizes = getSizeStyle(size);
return {
'& .icon': {
width: sizes.size,
height: sizes.size,
'& .filled': {
fontSize: `${sizes.fontSize}rem`,
top: -sizes.position,
left: -sizes.position
}
}
};
}
// ==============================|| OVERRIDES - CHECKBOX ||============================== //
export default function Checkbox(theme) {
const { palette } = theme;
return {
MuiCheckbox: {
defaultProps: {
className: 'size-small',
icon: <Box className="icon" sx={{ width: 16, height: 16, border: '1px solid', borderColor: 'inherit', borderRadius: 0.25 }} />,
checkedIcon: (
<Box
className="icon"
sx={{
width: 16,
height: 16,
border: '1px solid',
borderColor: 'inherit',
borderRadius: 0.25,
position: 'relative'
}}
>
<CheckSquareFilled className="filled" style={{ position: 'absolute' }} />
</Box>
),
indeterminateIcon: (
<Box
className="icon"
sx={{
width: 16,
height: 16,
border: '1px solid',
borderColor: 'inherit',
borderRadius: 0.25,
position: 'relative'
}}
>
<MinusSquareFilled className="filled" style={{ position: 'absolute' }} />
</Box>
)
},
styleOverrides: {
root: {
borderRadius: 0,
color: palette.secondary[300],
'&.size-small': {
...checkboxStyle('small')
},
'&.size-medium': {
...checkboxStyle('medium')
},
'&.size-large': {
...checkboxStyle('large')
}
},
colorPrimary: getColorStyle({ color: 'primary', theme }),
colorSecondary: getColorStyle({ color: 'secondary', theme }),
colorSuccess: getColorStyle({ color: 'success', theme }),
colorWarning: getColorStyle({ color: 'warning', theme }),
colorInfo: getColorStyle({ color: 'info', theme }),
colorError: getColorStyle({ color: 'error', theme })
}
}
};
}

View File

@@ -1,81 +0,0 @@
// project import
import getColors from 'utils/getColors';
// ==============================|| CHIP - COLORS ||============================== //
function getColor({ color, theme }) {
const colors = getColors(theme, color);
const { dark } = colors;
return {
'&.Mui-focusVisible': {
outline: `2px solid ${dark}`,
outlineOffset: 2
}
};
}
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { light, lighter, main } = colors;
return {
color: main,
backgroundColor: lighter,
borderColor: light,
'& .MuiChip-deleteIcon': {
color: main,
'&:hover': {
color: light
}
}
};
}
// ==============================|| OVERRIDES - CHIP ||============================== //
export default function Chip(theme) {
const defaultLightChip = getColorStyle({ color: 'secondary', theme });
return {
MuiChip: {
styleOverrides: {
root: {
borderRadius: 8,
fontWeight: 600,
'&:active': {
boxShadow: 'none'
},
'&.MuiChip-colorPrimary': getColor({ color: 'primary', theme }),
'&.MuiChip-colorSecondary': getColor({ color: 'secondary', theme }),
'&.MuiChip-colorError': getColor({ color: 'error', theme }),
'&.MuiChip-colorInfo': getColor({ color: 'info', theme }),
'&.MuiChip-colorSuccess': getColor({ color: 'success', theme }),
'&.MuiChip-colorWarning': getColor({ color: 'warning', theme })
},
sizeLarge: {
fontSize: '1rem',
height: 40
},
light: {
...defaultLightChip,
'&.MuiChip-lightPrimary': getColorStyle({ color: 'primary', theme }),
'&.MuiChip-lightSecondary': getColorStyle({ color: 'secondary', theme }),
'&.MuiChip-lightError': getColorStyle({ color: 'error', theme }),
'&.MuiChip-lightInfo': getColorStyle({ color: 'info', theme }),
'&.MuiChip-lightSuccess': getColorStyle({ color: 'success', theme }),
'&.MuiChip-lightWarning': getColorStyle({ color: 'warning', theme })
},
combined: {
border: '1px solid',
...defaultLightChip,
'&.MuiChip-combinedPrimary': getColorStyle({ color: 'primary', theme }),
'&.MuiChip-combinedSecondary': getColorStyle({ color: 'secondary', theme }),
'&.MuiChip-combinedError': getColorStyle({ color: 'error', theme }),
'&.MuiChip-combinedInfo': getColorStyle({ color: 'info', theme }),
'&.MuiChip-combinedSuccess': getColorStyle({ color: 'success', theme }),
'&.MuiChip-combinedWarning': getColorStyle({ color: 'warning', theme })
}
}
}
};
}

View File

@@ -1,24 +0,0 @@
// material-ui
import { alpha } from '@mui/material/styles';
// ==============================|| OVERRIDES - DIALOG ||============================== //
export default function Dialog() {
return {
MuiDialog: {
styleOverrides: {
root: {
// Lighter, slightly blurred scrim — less heavy than a near-opaque black.
'& .MuiBackdrop-root': {
backgroundColor: alpha('#0f172a', 0.45),
backdropFilter: 'blur(2px)'
}
},
paper: {
borderRadius: 16,
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.22), 0 8px 18px rgba(15, 23, 42, 0.12)'
}
}
}
};
}

View File

@@ -1,14 +0,0 @@
// ==============================|| OVERRIDES - DIALOG CONTENT TEXT ||============================== //
export default function DialogContentText(theme) {
return {
MuiDialogContentText: {
styleOverrides: {
root: {
fontSize: '0.875rem',
color: theme.palette.text.primary
}
}
}
};
}

View File

@@ -1,15 +0,0 @@
// ==============================|| OVERRIDES - DIALOG TITLE ||============================== //
export default function DialogTitle() {
return {
MuiDialogTitle: {
styleOverrides: {
root: {
fontSize: '1.0625rem',
fontWeight: 600,
letterSpacing: '-0.01em'
}
}
}
};
}

View File

@@ -1,82 +0,0 @@
// material-ui
import { alpha } from '@mui/material/styles';
// project import
import getColors from 'utils/getColors';
import getShadow from 'utils/getShadow';
// ==============================|| FAB BUTTON - COLORS ||============================== //
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { main, dark, contrastText } = colors;
const buttonShadow = `${color}Button`;
const shadows = getShadow(theme, buttonShadow);
return {
color: contrastText,
backgroundColor: main,
boxShadow: shadows,
'&:hover': {
boxShadow: 'none',
backgroundColor: dark
},
'&:focus-visible': {
outline: `2px solid ${dark}`,
outlineOffset: 2
},
'&::after': {
borderRadius: '50px',
boxShadow: `0 0 5px 5px ${alpha(main, 0.9)}`
},
'&:active::after': {
borderRadius: '50px',
boxShadow: `0 0 0 0 ${alpha(main, 0.9)}`
}
};
}
// ==============================|| OVERRIDES - FAB BUTTON ||============================== //
export default function Button(theme) {
return {
MuiFab: {
styleOverrides: {
root: {
fontWeight: 400,
'&.Mui-disabled': {
backgroundColor: theme.palette.grey[200]
},
'&.MuiFab-primary': getColorStyle({ color: 'primary', theme }),
'&.MuiFab-secondary': getColorStyle({ color: 'secondary', theme }),
'&.Mui-error': getColorStyle({ color: 'error', theme }),
'&.MuiFab-success': getColorStyle({ color: 'success', theme }),
'&.MuiFab-info': getColorStyle({ color: 'info', theme }),
'&.MuiFab-warning': getColorStyle({ color: 'warning', theme }),
'&::after': {
content: '""',
display: 'block',
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
borderRadius: 4,
opacity: 0,
transition: 'all 0.5s'
},
'&:active::after': {
position: 'absolute',
borderRadius: 4,
left: 0,
top: 0,
opacity: 1,
transition: '0s'
}
}
}
}
};
}

View File

@@ -1,28 +0,0 @@
// ==============================|| OVERRIDES - ICON BUTTON ||============================== //
export default function IconButton(theme) {
return {
MuiIconButton: {
styleOverrides: {
root: {
borderRadius: 4
},
sizeLarge: {
width: theme.spacing(5.5),
height: theme.spacing(5.5),
fontSize: '1.25rem'
},
sizeMedium: {
width: theme.spacing(4.5),
height: theme.spacing(4.5),
fontSize: '1rem'
},
sizeSmall: {
width: theme.spacing(3.75),
height: theme.spacing(3.75),
fontSize: '0.75rem'
}
}
}
};
}

View File

@@ -1,13 +0,0 @@
// ==============================|| OVERRIDES - INPUT BASE ||============================== //
export default function InputBase() {
return {
MuiInputBase: {
styleOverrides: {
sizeSmall: {
fontSize: '0.75rem'
}
}
}
};
}

View File

@@ -1,25 +0,0 @@
// ==============================|| OVERRIDES - INPUT LABEL ||============================== //
export default function InputLabel(theme) {
return {
MuiInputLabel: {
styleOverrides: {
root: {
color: theme.palette.grey[600]
},
outlined: {
lineHeight: '0.8em',
'&.MuiInputLabel-sizeSmall': {
lineHeight: '1em'
},
'&.MuiInputLabel-shrink': {
background: theme.palette.background.paper,
padding: '0 8px',
marginLeft: -6,
lineHeight: '1.4375em'
}
}
}
}
};
}

View File

@@ -1,17 +0,0 @@
// ==============================|| OVERRIDES - LINER PROGRESS ||============================== //
export default function LinearProgress() {
return {
MuiLinearProgress: {
styleOverrides: {
root: {
height: 6,
borderRadius: 100
},
bar: {
borderRadius: 100
}
}
}
};
}

View File

@@ -1,11 +0,0 @@
// ==============================|| OVERRIDES - LINK ||============================== //
export default function Link() {
return {
MuiLink: {
defaultProps: {
underline: 'hover'
}
}
};
}

View File

@@ -1,18 +0,0 @@
// ==============================|| OVERRIDES - LIST ITEM ICON ||============================== //
export default function ListItemButton(theme) {
return {
MuiListItemButton: {
styleOverrides: {
root: {
'&.Mui-selected': {
color: theme.palette.primary.main,
'& .MuiListItemIcon-root': {
color: theme.palette.primary.main
}
}
}
}
}
};
}

View File

@@ -1,14 +0,0 @@
// ==============================|| OVERRIDES - LIST ITEM ICON ||============================== //
export default function ListItemIcon(theme) {
return {
MuiListItemIcon: {
styleOverrides: {
root: {
minWidth: 24,
color: theme.palette.text.primary
}
}
}
};
}

View File

@@ -1,17 +0,0 @@
// ==============================|| OVERRIDES - LOADING BUTTON ||============================== //
export default function LoadingButton() {
return {
MuiLoadingButton: {
styleOverrides: {
root: {
padding: '6px 16px',
'&.MuiLoadingButton-loading': {
opacity: 0.6,
textShadow: 'none'
}
}
}
}
};
}

View File

@@ -1,59 +0,0 @@
// project import
import { ThemeMode } from 'config';
import getColors from 'utils/getColors';
import getShadow from 'utils/getShadow';
// ==============================|| OVERRIDES - INPUT BORDER & SHADOWS ||============================== //
function getColor({ variant, theme }) {
const colors = getColors(theme, variant);
const { light } = colors;
const shadows = getShadow(theme, `${variant}`);
return {
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: light
},
'&.Mui-focused': {
boxShadow: shadows,
'& .MuiOutlinedInput-notchedOutline': {
border: `1px solid ${light}`
}
}
};
}
// ==============================|| OVERRIDES - OUTLINED INPUT ||============================== //
export default function OutlinedInput(theme) {
return {
MuiOutlinedInput: {
styleOverrides: {
input: {
padding: '10.5px 14px 10.5px 12px'
},
notchedOutline: {
borderColor: theme.palette.mode === ThemeMode.DARK ? theme.palette.grey[200] : theme.palette.grey[300]
},
root: {
...getColor({ variant: 'primary', theme }),
'&.Mui-error': {
...getColor({ variant: 'error', theme })
}
},
inputSizeSmall: {
padding: '7.5px 8px 7.5px 12px'
},
inputMultiline: {
padding: 0
},
colorSecondary: getColor({ variant: 'secondary', theme }),
colorError: getColor({ variant: 'error', theme }),
colorWarning: getColor({ variant: 'warning', theme }),
colorInfo: getColor({ variant: 'info', theme }),
colorSuccess: getColor({ variant: 'success', theme })
}
}
};
}

View File

@@ -1,11 +0,0 @@
// ==============================|| OVERRIDES - PAGINATION ||============================== //
export default function Pagination() {
return {
MuiPagination: {
defaultProps: {
shape: 'rounded'
}
}
};
}

View File

@@ -1,118 +0,0 @@
// project import
import getColors from 'utils/getColors';
// ==============================|| PAGINATION ITEM - COLORS ||============================== //
function getColorStyle({ variant, color, theme }) {
const colors = getColors(theme, color);
const { lighter, light, dark, main, contrastText } = colors;
const focusStyle = {
'&:focus-visible': {
outline: `2px solid ${dark}`,
outlineOffset: 2,
...(variant === 'text' && {
backgroundColor: theme.palette.background.paper
})
}
};
switch (variant) {
case 'combined':
case 'contained':
return {
color: contrastText,
backgroundColor: main,
'&:hover': {
backgroundColor: light
},
...focusStyle
};
case 'outlined':
return {
borderColor: main,
'&:hover': {
backgroundColor: lighter,
borderColor: light
},
...focusStyle
};
case 'text':
default:
return {
color: main,
'&:hover': {
backgroundColor: main,
color: lighter
},
...focusStyle
};
}
}
// ==============================|| OVERRIDES - PAGINATION ITEM ||============================== //
export default function PaginationItem(theme) {
return {
MuiPaginationItem: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: `2px solid ${theme.palette.secondary.dark}`,
outlineOffset: 2
}
},
text: {
'&.Mui-selected': {
backgroundColor: 'transparent',
fontSize: '1rem',
fontWeight: 500,
'&.MuiPaginationItem-textPrimary': getColorStyle({ variant: 'text', color: 'primary', theme }),
'&.MuiPaginationItem-textSecondary': getColorStyle({ variant: 'text', color: 'secondary', theme }),
'&.MuiPaginationItem-textError': getColorStyle({ variant: 'text', color: 'error', theme }),
'&.MuiPaginationItem-textSuccess': getColorStyle({ variant: 'text', color: 'success', theme }),
'&.MuiPaginationItem-textInfo': getColorStyle({ variant: 'text', color: 'info', theme }),
'&.MuiPaginationItem-textWarning': getColorStyle({ variant: 'text', color: 'warning', theme })
}
},
contained: {
'&.Mui-selected': {
'&.MuiPaginationItem-containedPrimary': getColorStyle({ variant: 'contained', color: 'primary', theme }),
'&.MuiPaginationItem-containedSecondary': getColorStyle({ variant: 'contained', color: 'secondary', theme }),
'&.MuiPaginationItem-containedError': getColorStyle({ variant: 'contained', color: 'error', theme }),
'&.MuiPaginationItem-containedSuccess': getColorStyle({ variant: 'contained', color: 'success', theme }),
'&.MuiPaginationItem-containedInfo': getColorStyle({ variant: 'contained', color: 'info', theme }),
'&.MuiPaginationItem-containedWarning': getColorStyle({ variant: 'contained', color: 'warning', theme })
}
},
combined: {
border: '1px solid',
borderColor: theme.palette.divider,
'&.MuiPaginationItem-ellipsis': {
border: 'none'
},
'&.Mui-selected': {
'&.MuiPaginationItem-combinedPrimary': getColorStyle({ variant: 'combined', color: 'primary', theme }),
'&.MuiPaginationItem-combinedSecondary': getColorStyle({ variant: 'combined', color: 'secondary', theme }),
'&.MuiPaginationItem-combinedError': getColorStyle({ variant: 'combined', color: 'error', theme }),
'&.MuiPaginationItem-combinedSuccess': getColorStyle({ variant: 'combined', color: 'success', theme }),
'&.MuiPaginationItem-combinedInfo': getColorStyle({ variant: 'combined', color: 'info', theme }),
'&.MuiPaginationItem-combinedWarning': getColorStyle({ variant: 'combined', color: 'warning', theme })
}
},
outlined: {
borderColor: theme.palette.divider,
'&.Mui-selected': {
backgroundColor: 'transparent',
'&.MuiPaginationItem-outlinedPrimary': getColorStyle({ variant: 'outlined', color: 'primary', theme }),
'&.MuiPaginationItem-outlinedSecondary': getColorStyle({ variant: 'outlined', color: 'secondary', theme }),
'&.MuiPaginationItem-outlinedError': getColorStyle({ variant: 'outlined', color: 'error', theme }),
'&.MuiPaginationItem-outlinedSuccess': getColorStyle({ variant: 'outlined', color: 'success', theme }),
'&.MuiPaginationItem-outlinedInfo': getColorStyle({ variant: 'outlined', color: 'info', theme }),
'&.MuiPaginationItem-outlinedWarning': getColorStyle({ variant: 'outlined', color: 'warning', theme })
}
}
}
}
};
}

View File

@@ -1,13 +0,0 @@
// ==============================|| OVERRIDES - DIALOG CONTENT TEXT ||============================== //
export default function Popover(theme) {
return {
MuiPopover: {
styleOverrides: {
paper: {
boxShadow: theme.customShadows.z1
}
}
}
};
}

View File

@@ -1,119 +0,0 @@
// material-ui
import { Box } from '@mui/material';
// project import
import getColors from 'utils/getColors';
// ==============================|| RADIO - COLORS ||============================== //
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { lighter, main, dark } = colors;
return {
'& .dot': {
backgroundColor: main
},
'&:hover': {
backgroundColor: lighter
},
'&.Mui-focusVisible': {
outline: `2px solid ${dark}`,
outlineOffset: -4
}
};
}
// ==============================|| CHECKBOX - SIZE STYLE ||============================== //
function getSizeStyle(size) {
switch (size) {
case 'small':
return { size: 16, dotSize: 8, position: 3 };
case 'large':
return { size: 24, dotSize: 12, position: 5 };
case 'medium':
default:
return { size: 20, dotSize: 10, position: 4 };
}
}
// ==============================|| CHECKBOX - STYLE ||============================== //
function radioStyle(size) {
const sizes = getSizeStyle(size);
return {
'& .icon': {
width: sizes.size,
height: sizes.size,
'& .dot': {
width: sizes.dotSize,
height: sizes.dotSize,
top: sizes.position,
left: sizes.position
}
}
};
}
// ==============================|| OVERRIDES - CHECKBOX ||============================== //
export default function Radio(theme) {
const { palette } = theme;
return {
MuiRadio: {
defaultProps: {
className: 'size-small',
icon: <Box className="icon" sx={{ width: 16, height: 16, border: '1px solid', borderColor: 'inherit', borderRadius: '50%' }} />,
checkedIcon: (
<Box
className="icon"
sx={{
width: 16,
height: 16,
border: '1px solid',
borderColor: 'inherit',
borderRadius: '50%',
position: 'relative'
}}
>
<Box
className="dot"
sx={{
width: 8,
height: 8,
backgroundColor: 'inherit',
borderRadius: '50%',
position: 'absolute',
top: 3,
left: 3
}}
/>
</Box>
)
},
styleOverrides: {
root: {
color: palette.secondary[300],
'&.size-small': {
...radioStyle('small')
},
'&.size-medium': {
...radioStyle('medium')
},
'&.size-large': {
...radioStyle('large')
}
},
colorPrimary: getColorStyle({ color: 'primary', theme }),
colorSecondary: getColorStyle({ color: 'secondary', theme }),
colorSuccess: getColorStyle({ color: 'success', theme }),
colorWarning: getColorStyle({ color: 'warning', theme }),
colorInfo: getColorStyle({ color: 'info', theme }),
colorError: getColorStyle({ color: 'error', theme })
}
}
};
}

View File

@@ -1,69 +0,0 @@
// project import
import getColors from 'utils/getColors';
// ==============================|| OVERRIDES - SLIDER ||============================== //
function getColorStyle({ color, theme }) {
const colors = getColors(theme, color);
const { main } = colors;
return {
border: `2px solid ${main}`
};
}
export default function Slider(theme) {
return {
MuiSlider: {
styleOverrides: {
track: {
height: '1px'
},
thumb: {
width: 14,
height: 14,
border: `2px solid ${theme.palette.primary.main}`,
backgroundColor: theme.palette.background.paper,
'&.MuiSlider-thumbColorPrimary': getColorStyle({ color: 'primary', theme }),
'&.MuiSlider-thumbColorSecondary': getColorStyle({ color: 'secondary', theme }),
'&.MuiSlider-thumbColorSuccess': getColorStyle({ color: 'success', theme }),
'&.MuiSlider-thumbColorWarning': getColorStyle({ color: 'warning', theme }),
'&.MuiSlider-thumbColorInfo': getColorStyle({ color: 'info', theme }),
'&.MuiSlider-thumbColorError': getColorStyle({ color: 'error', theme })
},
mark: {
width: 4,
height: 4,
borderRadius: '50%',
border: `1px solid ${theme.palette.secondary.light}`,
backgroundColor: theme.palette.background.paper,
'&.MuiSlider-markActive': {
opacity: 1,
borderColor: 'inherit',
borderWidth: 2
}
},
rail: {
color: theme.palette.secondary.light
},
root: {
'&.Mui-disabled': {
'.MuiSlider-rail': {
opacity: 0.25
},
'.MuiSlider-track': {
color: theme.palette.secondary.lighter
},
'.MuiSlider-thumb': {
border: `2px solid ${theme.palette.secondary.lighter}`
}
}
},
valueLabel: {
backgroundColor: theme.palette.grey[600],
color: theme.palette.grey[0]
}
}
}
};
}

View File

@@ -1,88 +0,0 @@
// ==============================|| SWITCH - SIZE STYLE ||============================== //
function getSizeStyle(size) {
switch (size) {
case 'small':
return { width: 28, height: 16, base: 12, thumb: 10, trackRadius: 8 };
case 'large':
return { width: 60, height: 28, base: 32, thumb: 22, trackRadius: 24 };
case 'medium':
default:
return { width: 44, height: 22, base: 22, thumb: 16, trackRadius: 16 };
}
}
function switchStyle(theme, size) {
const sizes = getSizeStyle(size);
return {
width: sizes.width,
height: sizes.height,
'& .MuiSwitch-switchBase': {
padding: 3,
'&.Mui-checked': {
transform: `translateX(${sizes.base}px)`
}
},
'& .MuiSwitch-thumb': {
width: sizes.thumb,
height: sizes.thumb
},
'& .MuiSwitch-track': {
borderRadius: sizes.trackRadius
}
};
}
// ==============================|| OVERRIDES - TAB ||============================== //
export default function Switch(theme) {
return {
MuiSwitch: {
styleOverrides: {
track: {
opacity: 1,
backgroundColor: theme.palette.secondary[400],
boxSizing: 'border-box'
},
thumb: {
borderRadius: '50%',
transition: theme.transitions.create(['width'], {
duration: 200
})
},
switchBase: {
'&.Mui-checked': {
color: '#fff',
'& + .MuiSwitch-track': {
opacity: 1
},
'&.Mui-disabled': {
color: theme.palette.background.paper
}
},
'&.Mui-disabled': {
color: theme.palette.background.paper,
'+.MuiSwitch-track': {
opacity: 0.3
}
}
},
root: {
color: theme.palette.text.primary,
padding: 0,
margin: 8,
display: 'flex',
'& ~ .MuiFormControlLabel-label': {
margin: 6
},
...switchStyle(theme, 'medium')
},
sizeLarge: { ...switchStyle(theme, 'large') },
sizeSmall: {
...switchStyle(theme, 'small')
}
}
}
};
}

View File

@@ -1,24 +0,0 @@
// ==============================|| OVERRIDES - TAB ||============================== //
export default function Tab(theme) {
return {
MuiTab: {
styleOverrides: {
root: {
minHeight: 46,
color: theme.palette.text.primary,
borderRadius: 8,
'&:hover': {
backgroundColor: theme.palette.primary.lighter + 60,
color: theme.palette.primary.main
},
'&:focus-visible': {
borderRadius: 8,
outline: `2px solid ${theme.palette.secondary.dark}`,
outlineOffset: -3
}
}
}
}
};
}

View File

@@ -1,27 +0,0 @@
// ==============================|| OVERRIDES - TABLE ROW ||============================== //
export default function TableBody(theme) {
const hoverStyle = {
'&:hover': {
backgroundColor: theme.palette.secondary.lighter
}
};
return {
MuiTableBody: {
styleOverrides: {
root: {
'&.striped .MuiTableRow-root': {
'&:nth-of-type(even)': {
backgroundColor: theme.palette.grey[50]
},
...hoverStyle
},
'& .MuiTableRow-root': {
...hoverStyle
}
}
}
}
};
}

View File

@@ -1,33 +0,0 @@
// ==============================|| OVERRIDES - TABLE CELL ||============================== //
export default function TableCell(theme) {
return {
MuiTableCell: {
styleOverrides: {
root: {
fontSize: '0.875rem',
padding: '14px 12px',
borderColor: theme.palette.divider
},
sizeSmall: {
padding: '10px 8px'
},
// Clean enterprise header: small, muted, well-tracked uppercase labels.
// The old vertical divider rules between header cells were removed —
// modern data tables (Stripe/Linear) keep headers borderless and quiet.
head: {
fontSize: '0.6875rem',
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: theme.palette.text.secondary
},
footer: {
fontSize: '0.75rem',
textTransform: 'uppercase',
color: theme.palette.text.secondary
}
}
}
};
}

View File

@@ -1,15 +0,0 @@
// ==============================|| OVERRIDES - TABLE CELL ||============================== //
export default function TableFooter(theme) {
return {
MuiTableFooter: {
styleOverrides: {
root: {
backgroundColor: theme.palette.grey[50],
borderTop: `2px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`
}
}
}
};
}

View File

@@ -1,18 +0,0 @@
// ==============================|| OVERRIDES - TABLE HEAD ||============================== //
export default function TableHead(theme) {
return {
MuiTableHead: {
styleOverrides: {
root: {
backgroundColor: theme.palette.grey[50],
borderTop: 'none',
borderBottom: `1px solid ${theme.palette.divider}`,
'& .MuiTableCell-root': {
borderBottom: `1px solid ${theme.palette.divider}`
}
}
}
}
};
}

View File

@@ -1,16 +0,0 @@
// ==============================|| OVERRIDES - TABLE PAGINATION ||============================== //
export default function TablePagination() {
return {
MuiTablePagination: {
styleOverrides: {
selectLabel: {
fontSize: '0.875rem'
},
displayedRows: {
fontSize: '0.875rem'
}
}
}
};
}

View File

@@ -1,25 +0,0 @@
// ==============================|| OVERRIDES - TABLE ROW ||============================== //
export default function TableRow() {
return {
MuiTableRow: {
styleOverrides: {
root: {
'&:last-of-type': {
'& .MuiTableCell-root': {
borderBottom: 'none'
}
},
'& .MuiTableCell-root': {
'&:last-of-type': {
paddingRight: 24
},
'&:first-of-type': {
paddingLeft: 24
}
}
}
}
}
};
}

View File

@@ -1,13 +0,0 @@
// ==============================|| OVERRIDES - TABS ||============================== //
export default function Tabs() {
return {
MuiTabs: {
styleOverrides: {
vertical: {
overflow: 'visible'
}
}
}
};
}

View File

@@ -1,20 +0,0 @@
// ==============================|| OVERRIDES - TOGGLE BUTTON ||============================== //
export default function ToggleButton(theme) {
return {
MuiToggleButton: {
styleOverrides: {
root: {
'&.Mui-disabled': {
borderColor: theme.palette.divider,
color: theme.palette.text.disabled
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.secondary.dark}`,
outlineOffset: 2
}
}
}
}
};
}

View File

@@ -1,13 +0,0 @@
// ==============================|| OVERRIDES - TOOLTIP ||============================== //
export default function Tooltip(theme) {
return {
MuiTooltip: {
styleOverrides: {
tooltip: {
color: theme.palette.background.paper
}
}
}
};
}

View File

@@ -1,18 +0,0 @@
// ==============================|| OVERRIDES - TREE ITEM ||============================== //
export default function TreeItem() {
return {
MuiTreeItem: {
styleOverrides: {
content: {
padding: 8
},
iconContainer: {
'& svg': {
fontSize: '0.625rem'
}
}
}
}
};
}

View File

@@ -1,13 +0,0 @@
// ==============================|| OVERRIDES - TYPOGRAPHY ||============================== //
export default function Typography() {
return {
MuiTypography: {
styleOverrides: {
gutterBottom: {
marginBottom: 12
}
}
}
};
}

View File

@@ -1,99 +0,0 @@
// third-party
import { merge } from 'lodash';
// project import
import Accordion from './Accordion';
import AccordionDetails from './AccordionDetails';
import AccordionSummary from './AccordionSummary';
import Alert from './Alert';
import AlertTitle from './AlertTitle';
import Autocomplete from './Autocomplete';
import Badge from './Badge';
import Button from './Button';
import ButtonBase from './ButtonBase';
import ButtonGroup from './ButtonGroup';
import CardContent from './CardContent';
import Checkbox from './Checkbox';
import Chip from './Chip';
import Dialog from './Dialog';
import DialogContentText from './DialogContentText';
import DialogTitle from './DialogTitle';
import Fab from './Fab';
import IconButton from './IconButton';
import InputBase from './InputBase';
import InputLabel from './InputLabel';
import LinearProgress from './LinearProgress';
import Link from './Link';
import ListItemButton from './ListItemButton';
import ListItemIcon from './ListItemIcon';
import LoadingButton from './LoadingButton';
import OutlinedInput from './OutlinedInput';
import Pagination from './Pagination';
import PaginationItem from './PaginationItem';
import Popover from './Popover';
import Radio from './Radio';
import Slider from './Slider';
import Switch from './Switch';
import Tab from './Tab';
import TableBody from './TableBody';
import TableCell from './TableCell';
import TableFooter from './TableFooter';
import TableHead from './TableHead';
import TablePagination from './TablePagination';
import TableRow from './TableRow';
import Tabs from './Tabs';
import ToggleButton from './ToggleButton';
import Tooltip from './Tooltip';
import TreeItem from './TreeItem';
import Typography from './Typography';
// ==============================|| OVERRIDES - MAIN ||============================== //
export default function ComponentsOverrides(theme) {
return merge(
Accordion(theme),
AccordionDetails(theme),
AccordionSummary(theme),
Alert(theme),
AlertTitle(),
Autocomplete(),
Badge(theme),
Button(theme),
ButtonBase(),
ButtonGroup(),
CardContent(),
Checkbox(theme),
Chip(theme),
Dialog(),
DialogContentText(theme),
DialogTitle(),
Fab(theme),
IconButton(theme),
InputBase(),
InputLabel(theme),
LinearProgress(),
Link(),
ListItemButton(theme),
ListItemIcon(theme),
LoadingButton(),
OutlinedInput(theme),
Pagination(),
PaginationItem(theme),
Popover(theme),
Radio(theme),
Slider(theme),
Switch(theme),
Tab(theme),
TableBody(theme),
TableCell(theme),
TableFooter(theme),
TableHead(theme),
TablePagination(),
TableRow(),
Tabs(),
ToggleButton(theme),
Tooltip(theme),
TreeItem(),
Typography()
);
}

View File

@@ -1,67 +0,0 @@
// material-ui
import { alpha, createTheme } from '@mui/material/styles';
// third-party
import { presetDarkPalettes, presetPalettes } from '@ant-design/colors';
// project import
import ThemeOption from './theme';
import { ThemeMode } from 'config';
// ==============================|| DEFAULT THEME - PALETTE ||============================== //
const Palette = (mode, presetColor) => {
const colors = mode === ThemeMode.DARK ? presetDarkPalettes : presetPalettes;
let greyPrimary = [
'#ffffff',
'#fafafa',
'#f5f5f5',
'#f0f0f0',
'#d9d9d9',
'#bfbfbf',
'#8c8c8c',
'#595959',
'#262626',
'#141414',
'#000000'
];
let greyAscent = ['#fafafa', '#bfbfbf', '#434343', '#1f1f1f'];
let greyConstant = ['#fafafb', '#e6ebf1'];
if (mode === ThemeMode.DARK) {
greyPrimary = ['#000000', '#141414', '#1e1e1e', '#595959', '#8c8c8c', '#bfbfbf', '#d9d9d9', '#f0f0f0', '#f5f5f5', '#fafafa', '#ffffff'];
// greyPrimary.reverse();
greyAscent = ['#fafafa', '#bfbfbf', '#434343', '#1f1f1f'];
greyConstant = ['#121212', '#d3d8db'];
}
colors.grey = [...greyPrimary, ...greyAscent, ...greyConstant];
const paletteColor = ThemeOption(colors, presetColor, mode);
return createTheme({
palette: {
mode,
common: {
black: '#000',
white: '#fff'
},
...paletteColor,
text: {
primary: mode === ThemeMode.DARK ? alpha(paletteColor.grey[900], 0.87) : paletteColor.grey[700],
secondary: mode === ThemeMode.DARK ? alpha(paletteColor.grey[900], 0.45) : paletteColor.grey[500],
disabled: mode === ThemeMode.DARK ? alpha(paletteColor.grey[900], 0.1) : paletteColor.grey[400]
},
action: {
disabled: paletteColor.grey[300]
},
divider: mode === ThemeMode.DARK ? alpha(paletteColor.grey[900], 0.05) : paletteColor.grey[200],
background: {
paper: mode === ThemeMode.DARK ? paletteColor.grey[100] : paletteColor.grey[0],
default: paletteColor.grey.A50
}
}
});
};
export default Palette;

View File

@@ -1,33 +0,0 @@
// material-ui
import { alpha } from '@mui/material/styles';
// project-imports
import { ThemeMode } from 'config';
// ==============================|| DEFAULT THEME - CUSTOM SHADOWS ||============================== //
const CustomShadows = (theme) => ({
// z1: `0px 2px 8px rgba(0, 0, 0, 0.15)`,
button: theme.palette.mode === ThemeMode.DARK ? `0 2px 0 rgb(0 0 0 / 5%)` : `0 2px #0000000b`,
text: `0 -1px 0 rgb(0 0 0 / 12%)`,
z1:
theme.palette.mode === ThemeMode.DARK
? `0px 1px 1px rgb(0 0 0 / 14%), 0px 2px 1px rgb(0 0 0 / 12%), 0px 1px 3px rgb(0 0 0 / 20%)`
: `0 1px 3px rgba(15, 23, 42, 0.06), 0 6px 16px rgba(15, 23, 42, 0.06)`,
primary: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`,
secondary: `0 0 0 2px ${alpha(theme.palette.secondary.main, 0.2)}`,
error: `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
warning: `0 0 0 2px ${alpha(theme.palette.warning.main, 0.2)}`,
info: `0 0 0 2px ${alpha(theme.palette.info.main, 0.2)}`,
success: `0 0 0 2px ${alpha(theme.palette.success.main, 0.2)}`,
grey: `0 0 0 2px ${alpha(theme.palette.grey[500], 0.2)}`,
primaryButton: `0 14px 12px ${alpha(theme.palette.primary.main, 0.2)}`,
secondaryButton: `0 14px 12px ${alpha(theme.palette.secondary.main, 0.2)}`,
errorButton: `0 14px 12px ${alpha(theme.palette.error.main, 0.2)}`,
warningButton: `0 14px 12px ${alpha(theme.palette.warning.main, 0.2)}`,
infoButton: `0 14px 12px ${alpha(theme.palette.info.main, 0.2)}`,
successButton: `0 14px 12px ${alpha(theme.palette.success.main, 0.2)}`,
greyButton: `0 14px 12px ${alpha(theme.palette.grey[500], 0.2)}`
});
export default CustomShadows;

View File

@@ -1,124 +0,0 @@
import PropTypes from 'prop-types';
// ==============================|| PRESET THEME - DEFAULT ||============================== //
const Default = (colors) => {
const { red, gold, cyan, green, grey } = colors;
const greyColors = {
0: grey[0],
50: grey[1],
100: grey[2],
200: grey[3],
300: grey[4],
400: grey[5],
500: grey[6],
600: grey[7],
700: grey[8],
800: grey[9],
900: grey[10],
A50: grey[15],
A100: grey[11],
A200: grey[12],
A400: grey[13],
A700: grey[14],
A800: grey[16]
};
const contrastText = '#fff';
return {
// primary: {
// lighter: blue[0],
// 100: blue[1],
// 200: blue[2],
// light: blue[3],
// 400: blue[4],
// main: blue[5],
// dark: blue[6],
// 700: blue[7],
// darker: blue[8],
// 900: blue[9],
// contrastText
// },
primary: {
lighter: '#F2F2F2',
100: '#D9D9D9',
200: '#BFBFBF',
light: '#3A3A3A',
400: '#3A3A3A',
main: '#000000',
dark: '#262626',
700: '#1A1A1A',
darker: '#000000',
900: '#000000',
contrastText: '#FFFFFF'
},
secondary: {
lighter: greyColors[100],
100: greyColors[100],
200: greyColors[200],
light: greyColors[300],
400: greyColors[400],
main: greyColors[500],
600: greyColors[600],
dark: greyColors[700],
800: greyColors[800],
darker: greyColors[900],
A100: greyColors[0],
A200: greyColors.A400,
A300: greyColors.A700,
contrastText: greyColors[0]
},
error: {
lighter: red[0],
light: red[2],
main: red[4],
dark: red[7],
darker: red[9],
contrastText
},
warning: {
lighter: gold[0],
light: gold[3],
main: gold[5],
dark: gold[7],
darker: gold[9],
contrastText: greyColors[100]
},
info: {
lighter: cyan[0],
light: cyan[3],
main: cyan[5],
dark: cyan[7],
darker: cyan[9],
contrastText
},
success: {
lighter: green[0],
100: green[1],
light: green[3],
400: green[4],
main: green[5],
600: green[6],
dark: green[7],
darker: green[9],
contrastText
},
chip: {
accept: '#5d4037',
arrive: '#009688',
active: '#880e4f'
},
grey: greyColors,
bg: {
main: '#E0E0E0',
light: '#fafafb',
line: '#E6EBF1'
}
};
};
Default.propTypes = {
colors: PropTypes.object
};
export default Default;

View File

@@ -1,45 +0,0 @@
import PropTypes from 'prop-types';
// project import
import Default from './default';
import Theme1 from './theme1';
import Theme2 from './theme2';
import Theme3 from './theme3';
import Theme4 from './theme4';
import Theme5 from './theme5';
import Theme6 from './theme6';
import Theme7 from './theme7';
import Theme8 from './theme8';
// ==============================|| PRESET THEME - THEME SELECTOR ||============================== //
const Theme = (colors, presetColor, mode) => {
switch (presetColor) {
case 'theme1':
return Theme1(colors, mode);
case 'theme2':
return Theme2(colors, mode);
case 'theme3':
return Theme3(colors, mode);
case 'theme4':
return Theme4(colors, mode);
case 'theme5':
return Theme5(colors, mode);
case 'theme6':
return Theme6(colors, mode);
case 'theme7':
return Theme7(colors, mode);
case 'theme8':
return Theme8(colors, mode);
default:
return Default(colors);
// return Theme8(colors, mode);
}
};
Theme.propTypes = {
colors: PropTypes.object,
presetColor: PropTypes.any
};
export default Theme;

View File

@@ -1,109 +0,0 @@
// project-imports
import { ThemeMode } from 'config';
// ==============================|| PRESET THEME - Eva Design ||============================== //
const Theme1 = (colors, mode) => {
const { grey } = colors;
const greyColors = {
0: grey[0],
50: grey[1],
100: grey[2],
200: grey[3],
300: grey[4],
400: grey[5],
500: grey[6],
600: grey[7],
700: grey[8],
800: grey[9],
900: grey[10],
A50: grey[15],
A100: grey[11],
A200: grey[12],
A400: grey[13],
A700: grey[14],
A800: grey[16]
};
const contrastText = '#fff';
let primaryColors = ['#D6E4FF', '#D6E4FF', '#ADC8FF', '#84A9FF', '#6690FF', '#3366FF', '#254EDB', '#1939B7', '#102693', '#102693'];
let errorColors = ['#FFE7D3', '#FF805D', '#FF4528', '#DB271D', '#930C1A'];
let warningColors = ['#FFF6D0', '#FFCF4E', '#FFB814', '#DB970E', '#935B06'];
let infoColors = ['#DCF0FF', '#7EB9FF', '#549BFF', '#3D78DB', '#1A3D93'];
let successColors = ['#EAFCD4', '#8AE65B', '#58D62A', '#3DB81E', '#137C0D'];
if (mode === ThemeMode.DARK) {
primaryColors = ['#1c2134', '#1f294d', '#243462', '#273e83', '#2c4db0', '#305bdd', '#567fe9', '#80a4f4', '#a9c5f8', '#d2e2fb'];
errorColors = ['#341d1b', '#b03725', '#dd3f27', '#e9664d', '#fbd6c9'];
warningColors = ['#342a1a', '#83631a', '#dda116', '#e9ba3a', '#fbefb5'];
infoColors = ['#202734', '#416fb0', '#4c88dd', '#74a8e9', '#ecf4fb'];
successColors = ['#1f2e1c', '#449626', '#4fba28', '#74cf4d', '#e3fbd2'];
}
return {
primary: {
lighter: primaryColors[0],
100: primaryColors[1],
200: primaryColors[2],
light: primaryColors[3],
400: primaryColors[4],
main: primaryColors[5],
dark: primaryColors[6],
700: primaryColors[7],
darker: primaryColors[8],
900: primaryColors[9],
contrastText
},
secondary: {
lighter: greyColors[100],
100: greyColors[100],
200: greyColors[200],
light: greyColors[300],
400: greyColors[400],
main: greyColors[500],
600: greyColors[600],
dark: greyColors[700],
800: greyColors[800],
darker: greyColors[900],
A100: greyColors[0],
A200: greyColors.A400,
A300: greyColors.A700,
contrastText: greyColors[0]
},
error: {
lighter: errorColors[0],
light: errorColors[1],
main: errorColors[2],
dark: errorColors[3],
darker: errorColors[4],
contrastText
},
warning: {
lighter: warningColors[0],
light: warningColors[1],
main: warningColors[2],
dark: warningColors[3],
darker: warningColors[4],
contrastText: greyColors[100]
},
info: {
lighter: infoColors[0],
light: infoColors[1],
main: infoColors[2],
dark: infoColors[3],
darker: infoColors[4],
contrastText
},
success: {
lighter: successColors[0],
light: successColors[1],
main: successColors[2],
dark: successColors[3],
darker: successColors[4],
contrastText
},
grey: greyColors
};
};
export default Theme1;

View File

@@ -1,109 +0,0 @@
// project-imports
import { ThemeMode } from 'config';
// ==============================|| PRESET THEME - DEFAULT ||============================== //
const Theme2 = (colors, mode) => {
const { grey } = colors;
const greyColors = {
0: grey[0],
50: grey[1],
100: grey[2],
200: grey[3],
300: grey[4],
400: grey[5],
500: grey[6],
600: grey[7],
700: grey[8],
800: grey[9],
900: grey[10],
A50: grey[15],
A100: grey[11],
A200: grey[12],
A400: grey[13],
A700: grey[14],
A800: grey[16]
};
const contrastText = '#fff';
let primaryColors = ['#EEEDFC', '#D5D1F8', '#B9B2F3', '#9C93EE', '#877CEA', '#7265E6', '#6A5DE3', '#5F53DF', '#5549DB', '#4237D5'];
let errorColors = ['#FDE8E7', '#F25E52', '#F04134', '#EE3B2F', '#E92A21'];
let warningColors = ['#FFF7E0', '#FFC926', '#FFBF00', '#FFB900', '#FFA900'];
let infoColors = ['#E0F4F5', '#26B0BA', '#00A2AE', '#009AA7', '#008694'];
let successColors = ['#E0F5EA', '#26B56E', '#00A854', '#00A04D', '#008D3A'];
if (mode === ThemeMode.DARK) {
primaryColors = ['#222130', '#2b2946', '#37335a', '#443e78', '#554ca0', '#655ac8', '#9186dd', '#5F53DF', '#c3baf4', '#efecfb'];
errorColors = ['#321d1d', '#7d2e28', '#d13c31', '#e66859', '#f8baaf'];
warningColors = ['#342c1a', '#836611', '#dda705', '#e9bf28', '#f8e577'];
infoColors = ['#1a2628', '#11595f', '#058e98', '#1ea6aa', '#64cfcb'];
successColors = ['#1a2721', '#115c36', '#05934c', '#1da65d', '#61ca8b'];
}
return {
primary: {
lighter: primaryColors[0],
100: primaryColors[1],
200: primaryColors[2],
light: primaryColors[3],
400: primaryColors[4],
main: primaryColors[5],
dark: primaryColors[6],
700: primaryColors[7],
darker: primaryColors[8],
900: primaryColors[9],
contrastText
},
secondary: {
lighter: greyColors[100],
100: greyColors[100],
200: greyColors[200],
light: greyColors[300],
400: greyColors[400],
main: greyColors[500],
600: greyColors[600],
dark: greyColors[700],
800: greyColors[800],
darker: greyColors[900],
A100: greyColors[0],
A200: greyColors.A400,
A300: greyColors.A700,
contrastText: greyColors[0]
},
error: {
lighter: errorColors[0],
light: errorColors[1],
main: errorColors[2],
dark: errorColors[3],
darker: errorColors[4],
contrastText
},
warning: {
lighter: warningColors[0],
light: warningColors[1],
main: warningColors[2],
dark: warningColors[3],
darker: warningColors[4],
contrastText: greyColors[100]
},
info: {
lighter: infoColors[0],
light: infoColors[1],
main: infoColors[2],
dark: infoColors[3],
darker: infoColors[4],
contrastText
},
success: {
lighter: successColors[0],
light: successColors[1],
main: successColors[2],
dark: successColors[3],
darker: successColors[4],
contrastText
},
grey: greyColors
};
};
export default Theme2;

View File

@@ -1,109 +0,0 @@
// project-imports
import { ThemeMode } from 'config';
// ==============================|| PRESET THEME - Dark Green ||============================== //
const Theme4 = (colors, mode) => {
const { grey } = colors;
const greyColors = {
0: grey[0],
50: grey[1],
100: grey[2],
200: grey[3],
300: grey[4],
400: grey[5],
500: grey[6],
600: grey[7],
700: grey[8],
800: grey[9],
900: grey[10],
A50: grey[15],
A100: grey[11],
A200: grey[12],
A400: grey[13],
A700: grey[14],
A800: grey[16]
};
const contrastText = '#fff';
let primaryColors = ['#E6F3EC', '#84c297', '#5eb57d', '#3da866', '#1f9c53', '#068e44', '#006933', '#004222', '#001c0f', '#000000'];
let errorColors = ['#FDE8E7', '#F25E52', '#F04134', '#EE3B2F', '#E92A21'];
let warningColors = ['#FFF7E0', '#FFC926', '#FFBF00', '#FFB900', '#FFA900'];
let infoColors = ['#E0F4F5', '#26B0BA', '#00A2AE', '#009AA7', '#008694'];
let successColors = ['#E0F5EA', '#26B56E', '#00A854', '#00A04D', '#008D3A'];
if (mode === ThemeMode.DARK) {
primaryColors = ['#1a231f', '#173123', '#174029', '#13502f', '#0e6737', '#0a7d3e', '#1f8f4e', '#3ba162', '#5cb07a', '#82bf95'];
errorColors = ['#321d1d', '#7d2e28', '#d13c31', '#e66859', '#f8baaf'];
warningColors = ['#342c1a', '#836611', '#dda705', '#e9bf28', '#f8e577'];
infoColors = ['#1a2628', '#11595f', '#058e98', '#1ea6aa', '#64cfcb'];
successColors = ['#1a2721', '#115c36', '#05934c', '#1da65d', '#61ca8b'];
}
return {
primary: {
lighter: primaryColors[0],
100: primaryColors[1],
200: primaryColors[2],
light: primaryColors[3],
400: primaryColors[4],
main: primaryColors[5],
dark: primaryColors[6],
700: primaryColors[7],
darker: primaryColors[8],
900: primaryColors[9],
contrastText
},
secondary: {
lighter: greyColors[100],
100: greyColors[100],
200: greyColors[200],
light: greyColors[300],
400: greyColors[400],
main: greyColors[500],
600: greyColors[600],
dark: greyColors[700],
800: greyColors[800],
darker: greyColors[900],
A100: greyColors[0],
A200: greyColors.A400,
A300: greyColors.A700,
contrastText: greyColors[0]
},
error: {
lighter: errorColors[0],
light: errorColors[1],
main: errorColors[2],
dark: errorColors[3],
darker: errorColors[4],
contrastText
},
warning: {
lighter: warningColors[0],
light: warningColors[1],
main: warningColors[2],
dark: warningColors[3],
darker: warningColors[4],
contrastText: greyColors[100]
},
info: {
lighter: infoColors[0],
light: infoColors[1],
main: infoColors[2],
dark: infoColors[3],
darker: infoColors[4],
contrastText
},
success: {
lighter: successColors[0],
light: successColors[1],
main: successColors[2],
dark: successColors[3],
darker: successColors[4],
contrastText
},
grey: greyColors
};
};
export default Theme4;

Some files were not shown because too many files have changed in this diff Show More