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",
"private": true,
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons": "^5.0.1",
"@astryxdesign/core": "^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",
"@stylexjs/stylex": "^0.19.0",
"@svgr/webpack": "^7.0.0",
"@tanstack/react-query": "^5.101.4",
"antd": "^5.11.5",
"autosuggest-highlight": "^3.3.4",
"axios": "^1.3.5",
"buffer": "^6.0.3",
"chance": "^1.1.11",
@@ -60,7 +49,6 @@
"simplebar": "^6.2.5",
"simplebar-react": "^3.2.4",
"stream-browserify": "^3.0.0",
"stylis-plugin-rtl": "^2.1.1",
"use-debounce": "^10.0.5",
"util": "^0.12.5",
"web-vitals": "^3.3.1",

View File

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

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';
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== //
// Clean metric card: muted label, large value, a single soft-tinted rounded
// tile holding the icon. No coloured top-stripe, no rainbow — colour appears
// only in the small tile so a row of cards reads calm and corporate.
// Clean metric card: muted eyebrow label, large tabular value, a single
// soft-tinted rounded tile holding the icon (with a faint matching ring for
// 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} />}.
// `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 }) {
return (
<Card height="100%" padding={3}>
<HStack justify="between" vAlign="center" gap={1}>
<Card height="100%" padding={2} elevation="low">
<HStack justify="between" vAlign="center" gap={2}>
<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}
</Text>
{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}
</Text>
)}
@@ -45,9 +48,15 @@ export default function StatCard({ title, value, icon, color = '#000000', captio
</VStack>
{icon && (
<Center
width={42}
height={42}
style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}
width={36}
height={36}
style={{
flexShrink: 0,
borderRadius: 'var(--radius-element)',
backgroundColor: `${color}16`,
color,
border: `1px solid ${color}2A`
}}
>
{icon}
</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).
import '@astryxdesign/core/reset.css';
import '@astryxdesign/core/astryx.css';
import './globalPolish.css';
import { LinkProvider } from '@astryxdesign/core/Link';
// apex-chart

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import { OpenToast } from 'components/third-party/OpenToast';
import logger from 'utils/logger';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
getMilers,
getMiler,
@@ -518,7 +519,20 @@ export const notifyRider = async (
// deliveries.js itself doesn't need a full rewrite — same compatibility-shim
// approach as fetchAllRiders' `{details}` wrapper.
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
// 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
@@ -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 {
rows,
rows: rows.filter(inRange),
// Whether to fetch another page must be based on the RAW bookings page
// (bookings.length), not the post-filter `rows.length` — most bookings on
// 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
// (confirmed live: Available/Assigned/On_Pickup/Offline/Blocked).
// "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;
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) {
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 {
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 { useMemo, useState } from 'react';
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md';
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 TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/tokens';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================
// 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.
// ============================================================================
const BRAND = '#000000';
const BRAND = DT.brand;
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 emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
const AppUsers = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient();
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
@@ -139,6 +130,8 @@ const AppUsers = () => {
saveMutation.mutate({ id, data });
};
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
return (
<>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
@@ -147,162 +140,146 @@ const AppUsers = () => {
title="App Users"
subtitle="Live · Staff Logins"
live
action={
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
New User
</Button>
}
action={<Button label="New User" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
<Grid item xs={6}>
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} />
</Grid>
<Grid item xs={6}>
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
</Grid>
<Grid columns={{ minWidth: 220, max: 2, repeat: 'fit' }} gap={3}>
<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>
<Paper
elevation={0}
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' }}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
{users.length} total · {rows.length} shown
</Typography>
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search staff (ctrl+k)"
/>
</Box>
</Stack>
</Paper>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<Text weight="bold">
{users.length} total · {rows.length} shown
</Text>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search staff (ctrl+k)"
/>
</div>
</HStack>
</Card>
<Paper elevation={0} sx={{ borderBottomLeftRadius: DT.radiusCard / 8, borderBottomRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, overflow: 'hidden', background: '#fff' }}>
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' }, ...tableScrollSx }}>
<Table stickyHeader sx={{ minWidth: isMobile ? 820 : 880 }}>
<TableHead>
<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 ? (
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<TableScroll minWidth={880}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableCell colSpan={7} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdOutlineAdminPanelSettings size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No staff users to show
</Typography>
</Stack>
</TableCell>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>Phone</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow key={row.id || index} sx={tableRowSx}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{row.first_name}
</Typography>
</TableCell>
<TableCell>{row.email}</TableCell>
<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>
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={7} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7}>
<EmptyState
title="No staff users to show"
description="Create a staff login to give someone console access."
icon={<MdOutlineAdminPanelSettings size={28} />}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
) : (
rows.map((row, index) => (
<TableRow key={row.id || index}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<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>
<DialogTitle sx={{ fontWeight: 700 }}>{form.id ? 'Edit User' : 'New Staff User'}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Name</InputLabel>
<TextField value={form.first_name} onChange={(e) => setForm((f) => ({ ...f, first_name: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Email</InputLabel>
<TextField type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Phone</InputLabel>
<TextField value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>{form.id ? 'New Password (leave blank to keep unchanged)' : 'Password'}</InputLabel>
<TextField type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Role</InputLabel>
<Select fullWidth value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value }))}>
{ROLES.map((r) => (
<MenuItem key={r} value={r}>
{roleLabel(r)}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Status</InputLabel>
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
<MenuItem value="Active">Active</MenuItem>
<MenuItem value="Inactive">Inactive</MenuItem>
</Select>
</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 isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={480} purpose="form">
<Layout
header={
<DialogHeader
title={form.id ? 'Edit User' : 'New Staff User'}
subtitle={form.id ? `Updating "${form.first_name || 'user'}"` : 'Add a staff login'}
onOpenChange={(next) => !next && setDialogOpen(false)}
startContent={
<AccentAvatar color={BRAND} selected size={36}>
<MdOutlineAdminPanelSettings size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<TextInput label="Name" value={form.first_name} onChange={set('first_name')} isRequired />
<TextInput label="Email" type="email" value={form.email} onChange={set('email')} isRequired />
<TextInput label="Phone" value={form.phone} onChange={set('phone')} isOptional />
<TextInput
label={form.id ? 'New Password (leave blank to keep unchanged)' : 'Password'}
type="password"
value={form.password}
onChange={set('password')}
isRequired={!form.id}
isOptional={!!form.id}
/>
<Grid columns={{ minWidth: 160 }} gap={3}>
<Selector label="Role" options={ROLE_OPTIONS} value={form.role} onChange={set('role')} />
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
</Grid>
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={saveMutation.isPending} />
<Button
label={form.id ? 'Save Changes' : 'Create'}
variant="primary"
onClick={handleSave}
isLoading={saveMutation.isPending}
/>
</HStack>
</LayoutFooter>
}
/>
</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';
// 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 { useNavigate } from 'react-router';
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 PageHeader from 'components/nearle_components/PageHeader';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import { enqueueSnackbar } from 'notistack';
import { useNavigate } from 'react-router';
import { DT, tint } from 'themes/dt/tokens';
import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
import { DT } 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,
// 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
// end-to-end" list.
const Createcustomer = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const loggedInTenantId = localStorage.getItem('tenantid');
@@ -66,11 +59,12 @@ const Createcustomer = () => {
const [appLocaLat, setAppLocaLat] = useState();
const [appLocaLng, setAppLocaLng] = useState();
const { data: tenantList = [], isLoading: tenantListLoading } = useQuery({
const { data: tenantList = [] } = useQuery({
queryKey: ['admin-tenants-picker'],
queryFn: getAdminTenants,
enabled: isStaffLogin
});
const tenantOptions = (tenantList || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
// LocationAutocomplete only reports back applocationid/locationname — it
// caches the full hub list (with lat/lng) in localStorage under a
@@ -125,13 +119,8 @@ const Createcustomer = () => {
});
};
const opentoast = (message) => {
enqueueSnackbar(message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
const opentoast = (message) =>
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const createCustomerMutation = useMutation({
mutationFn: (payload) => createTenantCustomer(payload),
@@ -147,9 +136,7 @@ const Createcustomer = () => {
opentoast(res.message || 'Could not create customer');
}
},
onError: (err) => {
opentoast(err.response?.data?.message || err.message || 'Failed to create customer');
}
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create customer')
});
const createprofile = () => {
@@ -174,238 +161,86 @@ const Createcustomer = () => {
<>
{createCustomerMutation.isPending && <Loader />}
<Grid item xs={12} sx={{ mb: 2 }}>
<Paper
sx={{
p: 2.5,
borderRadius: DT.radiusCard + 'px',
boxShadow: DT.shadowSoft,
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 }}>
<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>
)}
<PageHeader
title="Create Customer"
subtitle="Add a customer record"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdPersonAddAlt1 size={20} />
</AccentAvatar>
}
/>
{/* ===================================================== || Name / Phone / Email || ===================================================== */}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="customer-firstname">Name</InputLabel>
<TextField
fullWidth
id="customer-firstname"
placeholder="First Name"
onChange={(e) => setFirstname(e.target.value)}
value={firstname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="customer-lastname">Last Name</InputLabel>
<TextField
fullWidth
id="customer-lastname"
placeholder="Last Name"
onChange={(e) => setLastname(e.target.value)}
value={lastname}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="customer-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="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>
<Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<VStack gap={1.25} padding={0}>
<Text type="label" weight="semibold" color="secondary">
Location
</Text>
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
</VStack>
{isStaffLogin && (
<Selector
label="Choose Client"
options={tenantOptions}
value={tid ? String(tid) : ''}
onChange={(v) => setTid(v || '')}
placeholder="Choose Client"
hasSearch
/>
)}
<TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
<TextInput
label="Phone Number"
placeholder="Phone Number"
value={phone}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setPhone(digits);
}}
isRequired
/>
<TextInput label="Email" type="email" placeholder="Email Address" value={email} onChange={setEmail} isOptional />
<TextInput label="Door No" placeholder="Door No" value={doorno} onChange={setDoorno} isOptional />
</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>
<Divider />
<Text type="supporting" color="secondary" style={{ 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.
</Text>
<AddressAutocomplete
id="customer-address"
label="Address"
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>
</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';
// 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 { 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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const [tenantname, setTenantname] = useState('');
@@ -43,13 +38,8 @@ const Createclient = () => {
const [status, setStatus] = useState('Active');
const [requiredeliveryotp, setRequiredeliveryotp] = useState(false);
const opentoast = (message) => {
enqueueSnackbar(message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
const opentoast = (message) =>
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const createTenantMutation = useMutation({
mutationFn: (payload) => createAdminTenant(payload),
@@ -65,9 +55,7 @@ const Createclient = () => {
opentoast(res.message || 'Client already exists');
}
},
onError: (err) => {
opentoast(err.response?.data?.message || err.message || 'Failed to create client');
}
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create client')
});
const createprofile = () => {
@@ -97,114 +85,45 @@ const Createclient = () => {
<>
{createTenantMutation.isPending && <Loader />}
<Grid item xs={12} sx={{ mb: 2 }}>
<Paper
sx={{
p: 2.5,
borderRadius: DT.radiusCard + 'px',
boxShadow: DT.shadowSoft,
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 }}>
<MdPersonAddAlt1 size={22} />
</Avatar>
<Typography variant="h3">Create Client</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}>
<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>
<PageHeader
title="Create Client"
subtitle="Add a new tenant"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdPersonAddAlt1 size={20} />
</AccentAvatar>
}
/>
<Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<TextInput label="Client / Business Name" placeholder="e.g. DailyGrubs" value={tenantname} onChange={setTenantname} isRequired />
<Selector label="Status" options={STATUS_OPTIONS} value={status} onChange={setStatus} />
<TextInput
label="Primary Contact Number"
placeholder="Contact Number"
value={primarycontact}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setPrimarycontact(digits);
}}
isRequired
/>
<TextInput label="Primary Email" type="email" placeholder="Primary Email Address" value={primaryemail} onChange={setPrimaryemail} isRequired />
</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>
</MainCard>
<Switch
label="Require delivery OTP for this client (off by default)"
value={requiredeliveryotp}
onChange={setRequiredeliveryotp}
/>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createTenantMutation.isPending} />
</HStack>
</VStack>
</Card>
</>
);
};

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
MdTwoWheeler,
MdLocationOn,
@@ -7,11 +8,73 @@ import {
MdAccessTime,
MdInventory2
} 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 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';
// ==============================|| 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 = ({
visibleRiders,
riders,
@@ -75,32 +138,27 @@ const ActiveSection = ({
if (isLoading) {
return (
<div className="empty-slot" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: '200px' }}>
<div style={{ marginBottom: 16 }}><LoaderWithImage size={44} imgSize={24} /></div>
<div className="empty-slot-title">Loading active deliveries...</div>
</div>
<VStack gap={2} padding={4} hAlign="center" vAlign="center" minHeight={200}>
<LoaderWithImage size={44} imgSize={24} />
<Text type="supporting">Loading active deliveries…</Text>
</VStack>
);
}
if (activeDeliveries.length === 0) {
return (
<div className="empty-slot">
<div className="empty-slot-icon">
<MdInventory2 />
</div>
<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>
<EmptyState
title="No active deliveries"
description="No deliveries are currently in progress for this slot."
icon={<MdInventory2 size={28} />}
/>
);
}
const renderActiveDeliveryCard = (o, i) => {
const renderActiveDelivery = (o) => {
const rid = o.rider_id || o.userid;
const rider = riders.find((r) => String(r.id) === String(rid));
const color = getRiderColor(rid);
const statusStyle = getStatusStyle(o.orderstatus);
const lat = parseFloat(o.droplat || o.deliverylat);
const lon = parseFloat(o.droplon || o.deliverylong);
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
@@ -108,7 +166,7 @@ const ActiveSection = ({
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
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 =
riderName
.split(/\s+/)
@@ -118,82 +176,70 @@ const ActiveSection = ({
.join('')
.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 (
<div
<ListItem
key={o.orderid}
className={`adcard${isActive ? ' is-active' : ''}`}
style={{ '--ad-accent': color }}
label={customer}
isSelected={!!isSelected}
onClick={() => {
if (rider) handleRiderFocus(rider);
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
}}
>
<div className="adcard-top">
<div className="adcard-avatar" style={{ background: color }}>{initials}</div>
<div className="adcard-titles">
<div className="adcard-rider" title={riderName}>
<MdTwoWheeler style={{ fontSize: 13, flexShrink: 0 }} />
<span className="adcard-tx">{riderName}</span>
</div>
<div className="adcard-customer" title={customer}>{customer}</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
<span
className="adcard-status"
style={{ background: `${statusStyle.bg}1a`, color: statusStyle.bg }}
title={statusStyle.label}
>
{statusStyle.label}
</span>
{o.deliverytime && (
<span className="adcard-time" style={{ fontSize: '11px', color: '#64748b' }}>
{parseDoormileTimestamp(o.deliverytime).isValid()
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss')
: String(o.deliverytime)}
</span>
startContent={
// Rider colour is an arbitrary per-rider hex assigned by the map's
// palette, which no semantic token can express — this is the same
// caller-supplied-accent escape hatch StatCard uses.
<AccentAvatar color={color} selected size={32}>
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
{initials}
</Text>
</AccentAvatar>
}
description={
<VStack gap={0.5} padding={0}>
<ContextLine icon={<MdTwoWheeler size={13} />}>{riderName}</ContextLine>
{dropArea && <ContextLine icon={<MdLocationOn size={13} />}>{dropArea}</ContextLine>}
{o.pickupcustomer && <ContextLine icon={<MdRestaurant size={13} />}>{o.pickupcustomer}</ContextLine>}
</VStack>
}
endContent={
<VStack gap={1} padding={0} hAlign="end">
<StatusBadge status={o.orderstatus} />
{deliveredAt && (
<Text size="sm" type="supporting" hasTabularNumbers>
{deliveredAt}
</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 && (
<>
<span className="adcard-m adcard-m-eta" title="Distance to drop">
<span className="adcard-ic"><MdMyLocation /></span>
<HStack gap={2} vAlign="center">
<Metric icon={<MdMyLocation size={14} />} label="Distance to drop">
{formatMeters(estMeters)}
</span>
<span className="adcard-m adcard-m-time" title="Estimated time to drop">
<span className="adcard-ic"><MdAccessTime /></span>
{(() => {
const etaMin = estMeters / 1000 / 20 * 60;
return etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`;
})()}
</span>
</>
</Metric>
<Metric icon={<MdAccessTime size={14} />} label="Estimated time to drop">
{etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`}
</Metric>
</HStack>
)}
</span>
</div>
</div>
</VStack>
}
/>
);
};
return <div className="adcard-list">{activeDeliveries.map(renderActiveDeliveryCard)}</div>;
return (
<List density="balanced" hasDividers>
{activeDeliveries.map(renderActiveDelivery)}
</List>
);
};
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.
### Time-field selection (`selectedTimeField`)
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.
### Time-field selection (`BATCH_TIME_FIELD`)
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`
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
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 { useTheme, useMediaQuery } from '@mui/material';
import {
MdPublic,
MdSwapHoriz,
@@ -21,12 +20,16 @@ import {
MdHourglassBottom
} from 'react-icons/md';
import {
onActivate,
useIsNarrowViewport,
stepColor,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
ordinal
} 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 +
// 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
// timing/clock columns vertically and let stat chips wrap). No data,
// handler, or derivation behaviour changes on any breakpoint.
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useIsNarrowViewport();
// 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
@@ -105,7 +107,13 @@ function CompareDataPanel({
(sum.onTime / onTimeForScore) * 25 +
((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';
// KPIs derived from delivery timestamps.
@@ -253,7 +261,11 @@ function CompareDataPanel({
className={`cdp-seq-diff${
focusedCompareStep === d.sequenceStep ? ' is-focused' : ''
}${focusable ? '' : ' is-nested'}`}
role="treeitem"
aria-selected={focusedCompareStep === d.sequenceStep}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
>
<span
className="cdp-seq-diff-num"
@@ -433,7 +445,9 @@ function CompareDataPanel({
<div
className="cdp-section-head cdp-section-head-clickable"
onClick={() => setSequenceOpen((v) => !v)}
onKeyDown={onActivate(() => setSequenceOpen((v) => !v))}
role="button"
tabIndex={0}
aria-expanded={sequenceOpen}
title={sequenceOpen ? 'Collapse route sequence' : 'Expand route sequence'}
>
@@ -455,7 +469,7 @@ function CompareDataPanel({
{sequenceOpen && (
<div className="cdp-seq">
{outOfOrderSteps.length > 0 ? (
<ul className="cdp-seq-diffs">
<ul className="cdp-seq-diffs" role="tree" aria-label="Route sequence differences">
{seqRuns.map((run, runIdx) => {
if (run.items.length === 1) {
return renderDiffRow(run.items[0]);
@@ -472,7 +486,11 @@ function CompareDataPanel({
<React.Fragment key={`run-${runIdx}-${first.d.sequenceStep}`}>
<li
className={`cdp-seq-diff is-group${isOpen ? ' is-expanded' : ''}${groupFocused ? ' is-focused' : ''}`}
role="treeitem"
aria-selected={groupFocused}
tabIndex={0}
onClick={() => toggleSeqGroup(runIdx)}
onKeyDown={onActivate(() => toggleSeqGroup(runIdx))}
aria-expanded={isOpen}
>
<span className="cdp-seq-group-num">
@@ -508,8 +526,8 @@ function CompareDataPanel({
</span>
</li>
{isOpen && (
<li className="cdp-seq-group-children-wrap">
<ul className="cdp-seq-group-children">
<li className="cdp-seq-group-children-wrap" role="presentation">
<ul className="cdp-seq-group-children" role="group">
{run.items.map((it) => renderDiffRow(it, false))}
</ul>
</li>
@@ -670,7 +688,9 @@ function CompareDataPanel({
<div
className="cdp-highlight is-best"
onClick={() => focusStep(bestStep.sequenceStep)}
onKeyDown={onActivate(() => focusStep(bestStep.sequenceStep))}
role="button"
tabIndex={0}
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
@@ -710,7 +730,9 @@ function CompareDataPanel({
<div
className="cdp-highlight is-worst"
onClick={() => focusStep(worstStep.sequenceStep)}
onKeyDown={onActivate(() => focusStep(worstStep.sequenceStep))}
role="button"
tabIndex={0}
title="Focus this step"
>
<span className="cdp-highlight-rail" aria-hidden="true" />
@@ -810,7 +832,7 @@ function CompareDataPanel({
? 'is-under'
: ''
: '';
const statusStyle = getStatusStyle(focused.orderstatus);
const profit = parseFloat(focused.order?.profit || 0);
return (
<section className="cdp-section">
@@ -845,14 +867,7 @@ function CompareDataPanel({
Order #{focused.orderid}
</div>
</div>
{focused.orderstatus && (
<span
className="compare-delta-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{focused.orderstatus && <StatusBadge status={focused.orderstatus} />}
</div>
<div className="compare-delta-grid">
<div className={`compare-delta-cell${focused.anomaly ? ' is-anomaly' : ''}`}>
@@ -920,7 +935,7 @@ function CompareDataPanel({
Deviations ({deviations.length})
</span>
</div>
<ul className="cdp-dev-list">
<ul className="cdp-dev-list" role="listbox" aria-label="Step deviations">
{deviations.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const kmSign = d.kmDelta >= 0 ? '+' : '';
@@ -928,7 +943,11 @@ function CompareDataPanel({
<li
key={`dev-${d.sequenceStep}`}
className={`cdp-dev-item${focusedCompareStep === d.sequenceStep ? ' is-focused' : ''}`}
role="option"
aria-selected={focusedCompareStep === d.sequenceStep}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
>
<span className="cdp-dev-num" style={{ background: color }}>
{d.sequenceStep}
@@ -970,7 +989,7 @@ function CompareDataPanel({
{delivered}/{compareDeltas.length} delivered
</span>
</div>
<ul className="cdp-step-list">
<ul className="cdp-step-list" role="listbox" aria-label="Route steps">
{compareDeltas.map((d) => {
const color = stepColor(d.sequenceStep - 1);
const statusLow = String(d.orderstatus || '').toLowerCase();
@@ -978,7 +997,7 @@ function CompareDataPanel({
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const isCorrect = isDelivered && !d.anomaly;
const isFocused = focusedCompareStep === d.sequenceStep;
const statusStyle = getStatusStyle(d.orderstatus);
const profit = parseFloat(d.order?.profit || 0);
const kmSign = d.kmDelta >= 0 ? '+' : '';
const timeCls =
@@ -1001,7 +1020,11 @@ function CompareDataPanel({
<li
key={`step-${d.sequenceStep}`}
className={stepCls}
role="option"
aria-selected={isFocused}
tabIndex={0}
onClick={() => focusStep(d.sequenceStep)}
onKeyDown={onActivate(() => focusStep(d.sequenceStep))}
>
<span className="cdp-step-num" style={{ background: color }}>
{d.sequenceStep}
@@ -1021,14 +1044,7 @@ function CompareDataPanel({
<span className="cdp-step-title">
{d.deliverycustomer || `Step ${d.sequenceStep}`}
</span>
{d.orderstatus && (
<span
className="cdp-step-status"
style={{ background: statusStyle.bg, color: statusStyle.fg }}
>
{statusStyle.label}
</span>
)}
{d.orderstatus && <StatusBadge status={d.orderstatus} />}
</div>
<div className="cdp-step-sub">
{d.pickupcustomer ? `from ${d.pickupcustomer} · ` : ''}

View File

@@ -117,7 +117,7 @@
border-radius: 999px;
background: rgba(123, 31, 162, 0.08);
border: 1px solid rgba(123, 31, 162, 0.25);
color: #7b1fa2;
color: var(--color-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
@@ -221,12 +221,12 @@
.dispatch-container .logo-city-option.active {
background: rgba(123, 31, 162, 0.1);
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .logo-city-option-icon {
font-size: 14px;
color: #7b1fa2;
color: var(--color-accent);
flex-shrink: 0;
}
@@ -238,7 +238,7 @@
}
.dispatch-container .logo-city-option-check {
color: #7b1fa2;
color: var(--color-accent);
font-weight: 800;
flex-shrink: 0;
}
@@ -975,329 +975,6 @@
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 {
display: inline-flex;
@@ -2921,7 +2598,7 @@
/* ---------- Per-order cards inside a focused zone ---------- */
/* 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
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 {
display: flex;
@@ -2948,7 +2625,7 @@
left: 0;
width: 4px;
height: 100%;
background: #7b1fa2;
background: var(--color-accent);
opacity: 0.85;
transition: width 0.18s ease, opacity 0.18s ease;
}
@@ -2969,7 +2646,7 @@
}
.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);
background: linear-gradient(180deg, #ffffff 0%, #f7eaff 100%);
}
@@ -3019,8 +2696,8 @@
font-weight: 800;
font-size: 13px;
flex-shrink: 0;
background: #7b1fa2;
border: 1px solid #7b1fa2;
background: var(--color-accent);
border: 1px solid var(--color-accent);
box-shadow: 0 2px 6px rgba(123, 31, 162, 0.22);
}
@@ -3044,7 +2721,7 @@
.dispatch-container .zone-order-rider {
font-size: 11px;
font-weight: 600;
color: #7b1fa2;
color: var(--color-accent);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
@@ -3085,7 +2762,7 @@
.dispatch-container .zone-order-time svg {
font-size: 14px;
color: #7b1fa2;
color: var(--color-accent);
flex-shrink: 0;
}
@@ -3199,7 +2876,7 @@
.dispatch-container .zone-order-chip.zone-order-trip {
background: rgba(123, 31, 162, 0.1);
border-color: rgba(123, 31, 162, 0.25);
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .zone-order-type {
@@ -4260,17 +3937,6 @@
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 {
display: grid;
@@ -5389,7 +5055,7 @@
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #7b1fa2;
color: var(--color-accent);
border-bottom: 1px solid rgba(123, 31, 162, 0.18);
padding-bottom: 4px;
}
@@ -5571,7 +5237,7 @@
/* --- Header: purple gradient with order id + status + rider --- */
.dispatch-container .dispatch-popup .pu-header {
background: linear-gradient(135deg, #7b1fa2 0%, #9c27b0 50%, #ab47bc 100%);
background: var(--color-accent);
padding: 14px 16px 12px;
color: #fff;
border-radius: 12px 12px 0 0;
@@ -5706,13 +5372,13 @@
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .dispatch-popup .pu-header-step-value {
font-size: 15px;
font-weight: 800;
color: #7b1fa2;
color: var(--color-accent);
white-space: nowrap;
}
@@ -5737,7 +5403,7 @@
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7b1fa2;
color: var(--color-accent);
border-bottom: 1px solid rgba(123, 31, 162, 0.18);
}
@@ -5781,7 +5447,7 @@
height: 26px;
border-radius: 7px;
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -5841,7 +5507,7 @@
.dispatch-container .dispatch-popup .pu-distance-icon {
display: inline-flex;
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .dispatch-popup .pu-distance-icon svg {
@@ -6326,7 +5992,7 @@
height: 64px;
border-radius: 16px;
background: rgba(123, 31, 162, 0.08);
color: #7b1fa2;
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -6384,7 +6050,7 @@
}
.dispatch-container .ri-search-input:focus {
border-color: #7b1fa2;
border-color: var(--color-accent);
background: #fff;
}
@@ -6429,13 +6095,13 @@
}
.dispatch-container .ri-rider-item.active {
border-color: #7b1fa2;
border-color: var(--color-accent);
background: linear-gradient(180deg, #fbf3ff 0%, #f0e0fa 100%);
box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18);
}
.dispatch-container .ri-rider-item.active .ri-rider-name {
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .ri-rider-item.active .ri-rider-arrow {
@@ -6476,7 +6142,7 @@
}
.dispatch-container .ri-rider-arrow {
color: #7b1fa2;
color: var(--color-accent);
font-size: 18px;
font-weight: 800;
opacity: 0.4;
@@ -6577,7 +6243,7 @@
.dispatch-container .ri-live.is-refetching {
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
color: var(--color-accent);
}
.dispatch-container .ri-live-dot {
@@ -6590,7 +6256,7 @@
}
.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);
}
@@ -6648,7 +6314,7 @@
height: 32px;
border-radius: 9px;
background: rgba(123, 31, 162, 0.12);
color: #7b1fa2;
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
@@ -6727,7 +6393,7 @@
}
.dispatch-container .ri-coords-label svg {
color: #7b1fa2;
color: var(--color-accent);
font-size: 16px;
}
@@ -6753,7 +6419,7 @@
can read the location without opening the popup. Styled to override the
default leaflet tooltip chrome (rounded chip, brand purple). */
.dispatch-container .ri-map .leaflet-tooltip.ri-area-banner {
background: #7b1fa2;
background: var(--color-accent);
color: #fff;
border: 0;
border-radius: 8px;
@@ -6766,7 +6432,7 @@
}
.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 */
@@ -7177,10 +6843,6 @@
font-size: 9px;
}
.dispatch-container .compare-delta-status {
padding: 2px 6px;
font-size: 9px;
}
.dispatch-container .compare-delta-grid {
gap: 6px;
@@ -7395,10 +7057,6 @@
font-size: 9px;
}
.dispatch-container .compare-delta-status {
padding: 2px 6px;
font-size: 9px;
}
/* Delta Grid - cells and labels */
.dispatch-container .compare-delta-grid {
@@ -8134,17 +7792,6 @@
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 {
font-size: 13px;
@@ -10791,186 +10438,6 @@
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

View File

@@ -22,7 +22,6 @@ import {
MdStraighten,
MdLocationOn,
MdMarkunreadMailbox,
MdMoveToInbox,
MdPlace,
MdTwoWheeler,
MdNotes,
@@ -61,7 +60,7 @@ import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLo
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
STATUS_STYLES,
onActivate,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
@@ -149,22 +148,9 @@ const BATCHES_DEFAULT_RAW = [
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
];
// v9: afternoon batch extended to 12:30 PM. Bumping from v8 wipes the
// cached layouts that still hold the old endHour: 12 value.
const SLOTS_STORAGE_KEY = 'dispatch.slots.v9';
// 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'
];
// The `dispatch.slots.v1`…`v9` localStorage keys are no longer read or
// written — batch windows are constants now (see BATCHES in the component).
// Any leftover entries in an operator's browser are inert.
// 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
@@ -218,12 +204,21 @@ const getBatchForHour = (h, batches) => {
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
const TIME_FIELDS = [
{ id: 'delivered', label: 'Delivered', keys: ['deliverytime'] },
// The batch-bucketing default. `expecteddeliverytime` is the booking's
// promised delivery slot (serviceoptions[0].estimateddeliveryat) — it is set
// once and not re-stamped as the order progresses, which is exactly what a
// "wave" needs. Do not point this at `assigntime`: that field is the
// booking's last-modified column (see api.js) and drifts through the day.
{ id: 'due', label: 'Due', keys: ['expecteddeliverytime'] },
// The batch-bucketing default. `orderdate` is the booking's `createdat` —
// when the order came in. It is immutable, and it matches the operator's
// mental model: an order created and assigned at 12:22 belongs to the
// Afternoon wave, full stop.
//
// 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: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
{ 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'] }
];
// 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 field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0];
for (const k of field.keys) {
@@ -416,10 +416,13 @@ const getStableRiderColor = (id) => {
return RIDER_COLORS[h % RIDER_COLORS.length];
};
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
// extracted CompareDataPanel component can import them without forcing
// a circular dependency on Dispatch.js.
// getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES, STEP_PALETTE, stepColor —
// moved to ./dispatchShared.js so the extracted CompareDataPanel component can
// import them without forcing 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 map = useMap();
@@ -911,7 +914,6 @@ const Dispatch = ({
// 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
// or vice versa without immediately triggering a close.
const activePopupMarkerRef = useRef(null);
const popupHoverTimerRef = useRef(null);
// Order shown in the centered popup overlay. Rendered outside the leaflet
// 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 locationMenuRef = useRef(null);
// Which timestamp column drives slot bucketing. Default = 'due'
// (expecteddeliverytime) so orders bucket into Morning/Afternoon/Evening by
// the delivery slot they were promised for. The status-wise time-field
// dropdown is hidden for now (see commented-out block in JSX), so this stays
// fixed at 'due'.
// Which timestamp column drives slot bucketing. Fixed at creation time —
// see TIME_FIELDS. This was a useState with an operator-facing dropdown;
// the dropdown was disabled for a correctness reason (picking "Assigned"
// bucketed on a column the backend re-stamps, so orders visibly jumped
// 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
// assign time" — but on the Doormile backend `assigntime` is mapped to the
// booking's last-modified column (api.js), so it moves whenever the row is
// touched. Today's live orders therefore migrated into whichever batch
// contained the current clock time, and the 15s poll made the counts shift
// on their own. deliveries.js buckets on the same field for the same reason
// — the two pages must agree (see this folder's CLAUDE.md §1).
const [selectedTimeField, setSelectedTimeField] = useState('due');
const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false);
const timeFieldMenuRef = useRef(null);
// 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]);
// Dropping the localStorage READ is itself a fix: an operator who edited
// their slot hours in an older build kept getting those stale custom hours
// forever, silently disagreeing with deliveries.js, which has always used
// the constants.
const BATCHES = BATCHES_DEFAULT;
// Close the location dropdown on any click outside its wrapper.
useEffect(() => {
@@ -1033,30 +974,6 @@ const Dispatch = ({
return () => document.removeEventListener('mousedown', onDocClick);
}, [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
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
// ('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
// stripping _ts on the way out.
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);
},
enabled: compareOpen && focusedRider != null,
@@ -2742,7 +2662,15 @@ const Dispatch = ({
const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null;
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-emo" style={{ background: '#f1f5f9', borderColor: '#e2e8f0', color: '#64748b' }}><MdTwoWheeler /></div>
<div className="rcard-info">
@@ -2972,7 +2900,7 @@ const Dispatch = ({
? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]))
: null;
return ordersToRender.map((o, idx) => {
return ordersToRender.map((o) => {
const rid = o.rider_id;
const active = rid ? activeRiders.has(rid) : true;
let color = getRiderColor(rid);
@@ -3650,170 +3578,6 @@ const Dispatch = ({
{shouldFetchLive && viewMode !== 'rider-info' && (
<div id="batch-row">
<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
horizontally when it overflows. */}
<div className="batch-scroll">
@@ -3919,7 +3683,7 @@ const Dispatch = ({
{riderInfoIsError && (
<div className="ri-error">
Couldn't load this rider's log. {riderInfoError?.message || ''}
Couldn’t load this rider’s log. {riderInfoError?.message || ''}
</div>
)}
@@ -4313,12 +4077,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
onKeyDown={canFocus ? onActivate(() => 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}
>
<div className="zone-order-card-head">
@@ -4487,6 +4246,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : 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-num">{o.step || idx + 1}</div>
@@ -4632,6 +4392,7 @@ const Dispatch = ({
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : 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-num">
@@ -4765,7 +4526,15 @@ const Dispatch = ({
const delivered = z.statusCounts.delivered || 0;
const profitNeg = z.totalProfit < 0;
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-emoji"><MdMap /></div>
<div className="zone-card-titles">
@@ -4844,7 +4613,15 @@ const Dispatch = ({
})
) : viewMode === 'kitchens' ? (
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-emo" style={{ background: '#f59e0b18', borderColor: '#f59e0b50', color: '#f59e0b' }}><MdRestaurant /></div>
<div className="rcard-info">
@@ -5203,15 +4980,9 @@ const Dispatch = ({
(o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid)
);
const statusStyle = getStatusStyle(t.orderstatus);
const flagSvg = t.orderstatus
? `<svg class="cmark-flag" viewBox="0 0 18 22" xmlns="http://www.w3.org/2000/svg">
<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>`
: '';
// A status flag SVG used to be built here and never rendered —
// this marker draws `dropPinHtml` below instead. Removed with
// the getStatusStyle lookup that only fed it.
const dropClasses = ['compare-step-pin'];
if (isFocusedStep) dropClasses.push('is-focused');
if (isDelivered) dropClasses.push('is-delivered');
@@ -6627,14 +6398,22 @@ const Dispatch = ({
{riderPositionModal && (
<div
className="da-pos-modal-backdrop"
role="dialog"
aria-modal="true"
aria-label={`Last known position of ${riderPositionModal.name}`}
// The dialog role belongs on the card below, not on the backdrop —
// the backdrop is a click-outside-to-dismiss scrim, which is
// 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) => {
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-title-wrap">
<div

View File

@@ -1,33 +1,28 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useLocation, useNavigate } from 'react-router-dom';
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 dayjs from 'dayjs';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { HiOutlineArrowLeft } from 'react-icons/hi';
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 {
buildMilerLookup,
@@ -43,7 +38,66 @@ import { OpenToast } from 'components/third-party/OpenToast';
import CSVExport from 'components/third-party/ReactTable';
import CircularLoader from 'components/CircularLoader';
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 = [
{ tuneid: 1, type: 'Balanced', value: 'balanced' },
@@ -300,8 +354,7 @@ const Preview = () => {
const navigate = useNavigate();
const location = useLocation();
const stateData = location.state || {};
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useIsNarrowViewport();
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
// through this state. The Dispatch tab renders from it, the Reconcile tab
@@ -363,6 +416,21 @@ const Preview = () => {
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
// (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect
// here without a separate state.
@@ -668,106 +736,100 @@ const Preview = () => {
// embeds that same component below) — MainLayout's `.main-content-area`
// 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
// --appshell-header-height CSS var. A bare `height: '100vh'` here (the
// previous value) doesn't subtract the top nav's height or cancel the
// surrounding padding, so the page's real content was taller than the
// visible viewport by roughly the nav height + padding — turning the
// whole page (not just the intended internal panels) into a scroll
// container, which dragged the rider sidebar along with it instead of
// it staying in place.
<Box
sx={{
display: 'flex',
flexDirection: 'column',
// --appshell-header-height CSS var. A bare `height: '100vh'` here doesn't
// subtract the top nav's height or cancel the surrounding padding, so the
// page's real content was taller than the visible viewport by roughly the
// nav height + padding — turning the whole page (not just the intended
// internal panels) into a scroll container, which dragged the rider
// sidebar along with it instead of it staying in place.
<VStack
gap={0}
padding={0}
style={{
height: 'calc(100vh - var(--appshell-header-height, 64px))',
margin: '0 -24px -24px -24px',
overflow: 'hidden',
position: 'relative'
}}
>
<Backdrop
sx={{ position: 'absolute', color: '#fff', zIndex: (theme) => theme.zIndex.modal + 1 }}
open={isLoading}
>
<CircularLoader color="inherit" />
</Backdrop>
<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}
{isLoading && (
<Center
style={{
position: 'absolute',
inset: 0,
zIndex: 1400,
backgroundColor: 'var(--color-overlay)'
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="Back to orders" placement="top">
<IconButton
onClick={() => navigate('/doormile/orders')}
sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }}
>
<HiOutlineArrowLeft size={20} />
</IconButton>
</Tooltip>
<Typography variant="h3" fontWeight={600}>
Assign Orders
</Typography>
</Stack>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'stretch', sm: 'center' }}
spacing={1}
sx={{ width: { xs: '100%', md: 'auto' } }}
>
<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);
}}
<CircularLoader color="inherit" />
</Center>
)}
<VStack
gap={0}
paddingInline={2}
paddingBlock={1.5}
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<HStack justify="between" vAlign="center" wrap="wrap" gap={1.5}>
<HStack gap={1} vAlign="center">
<IconButton
label="Back to orders"
tooltip="Back to orders"
variant="ghost"
icon={<HiOutlineArrowLeft size={20} />}
onClick={() => navigate('/doormile/orders')}
/>
<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
variant="contained"
color="primary"
startIcon={<IoReload />}
fullWidth={isMobile}
label="Re-Assign"
variant="primary"
icon={<IoReload />}
width={isMobile ? '100%' : undefined}
onClick={() => {
setIsLoading(true);
handleCreateDelivery('reshuffle');
}}
>
Re-Assign
</Button>
/>
<CSVExport
data={csvExportData}
filename={`Orders_Detail_${dayjs().format('YYYY-MM-DD_HHmmss')}.csv`}
label=" CSV"
style={{ m: 1 }}
/>
</Stack>
</Stack>
</Box>
</HStack>
</HStack>
</VStack>
<Box sx={{ px: 2, borderBottom: '1px solid #eef2f6' }}>
<Tabs
value={tabValue}
onChange={(e, v) => setTabValue(v)}
variant={isMobile ? 'fullWidth' : 'standard'}
sx={{ minHeight: 40 }}
<VStack gap={0} paddingInline={2} padding={0}>
<TabList
value={String(tabValue)}
onChange={(v) => setTabValue(Number(v))}
layout={isMobile ? 'fill' : 'hug'}
hasDivider
>
<Tab label="Dispatch" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
<Tab label="Reconcile" sx={{ minHeight: 40, textTransform: 'none', fontWeight: 600 }} />
</Tabs>
</Box>
<Tab value="0" label="Dispatch" />
<Tab value="1" label="Reconcile" />
</TabList>
</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
fit its content's natural height instead of clipping to it, which
is what let the embedded Dispatch view (and its internal
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 && (
<Dispatch
// The key forces a full re-mount when the cache reference changes
@@ -781,232 +843,167 @@ const Preview = () => {
onChangeRider={(order, focusedRider) => openChangeRider(focusedRider, order)}
/>
)}
{tabValue === 1 && (
<Box sx={{ flex: 1, overflow: 'auto', p: 2, bgcolor: '#f8fafc' }}>
<VStack gap={2} padding={2} isScrollable style={{ flex: 1 }}>
{reconcileRiders.length === 0 ? (
<Typography sx={{ color: '#94a3b8', textAlign: 'center', mt: 4 }}>
No rider data available to reconcile.
</Typography>
<EmptyState
title="Nothing to reconcile"
description="No rider data available to reconcile."
icon={<MdSwapHoriz size={28} />}
/>
) : (
<Stack spacing={1.75}>
<Box
sx={{
bgcolor: hasReconciled ? '#ecfdf5' : '#fffbeb',
border: `1px solid ${hasReconciled ? '#a7f3d0' : '#fde68a'}`,
color: hasReconciled ? '#065f46' : '#92400e',
borderRadius: '10px',
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>
<>
<Banner
status={hasReconciled ? 'success' : 'warning'}
title={
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.'
}
/>
{reconcileRiders.map((r) => {
const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
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)' }}>
<Stack
direction="row"
justifyContent="space-between"
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'
}}
>
<Card key={r.rider_id} padding={3}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={1}>
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={STATUS.accepted} size={32}>
<MdTwoWheeler size={18} />
</Box>
<Box>
<Typography sx={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
{r.rider_name}
</Typography>
<Typography sx={{ fontSize: 11.5, color: '#64748b' }}>
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold">{r.rider_name}</Text>
<Text size="sm" type="supporting">
ID: {r.rider_id}
</Typography>
</Box>
</Stack>
<Stack direction="row" gap={1}>
<Chip size="small" label={`${r.orders.length} stops`} sx={{ fontWeight: 600 }} />
<Chip size="small" label={`${totalKms.toFixed(1)} km`} variant="outlined" />
</Stack>
</Stack>
</Text>
</VStack>
</HStack>
<HStack gap={1} vAlign="center">
<Badge variant="neutral" label={`${r.orders.length} stops`} />
<Badge variant="neutral" label={`${totalKms.toFixed(1)} km`} />
</HStack>
</HStack>
<Stack direction="row" gap={1.25} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
{r.orders.map((o, idx) => {
const stepNum = o.step ?? idx + 1;
const color = stepColor(Number(stepNum) - 1);
const isUnverified = unverifiedOrderIds.has(String(o.orderid));
return (
<Tooltip
key={`${o.orderid}-${idx}`}
title={
<Box>
<div>Order #{o.orderid}</div>
<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>
<HStack gap={1.5} wrap="wrap" vAlign="center" style={{ marginTop: 12 }}>
{r.orders.map((o, idx) => (
<StepChip
key={`${o.orderid}-${idx}`}
order={o}
stepNum={o.step ?? idx + 1}
isUnverified={unverifiedOrderIds.has(String(o.orderid))}
onClick={() => openChangeRider(r, o)}
/>
))}
</HStack>
</Card>
);
})}
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 1.5, pb: 2 }}>
<Center style={{ paddingTop: 12, paddingBottom: 16 }}>
<Button
variant="contained"
color="primary"
size="large"
startIcon={<MdSwapHoriz />}
label={
reconcileLoading
? 'Reconciling…'
: 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}
disabled={reconcileLoading || dirtyRiderIds.size === 0}
fullWidth={isMobile}
sx={{ minWidth: { xs: 0, sm: 220 }, borderRadius: '10px', textTransform: 'none', fontWeight: 700 }}
>
{reconcileLoading
? 'Reconciling...'
: dirtyRiderIds.size === 0
? 'Reconcile'
: `Reconcile (${dirtyRiderIds.size})`}
</Button>
</Box>
</Stack>
/>
</Center>
</>
)}
</Box>
</VStack>
)}
</Box>
</VStack>
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #eef2f6' }}>
<Stack
direction={{ xs: 'column-reverse', sm: 'row' }}
gap={{ xs: 1, sm: 2 }}
alignItems={{ xs: 'stretch', sm: 'center' }}
justifyContent="end"
>
<VStack
gap={0}
paddingInline={2}
paddingBlock={1.5}
style={{ borderTop: '1px solid var(--color-border)' }}
>
<HStack gap={2} vAlign="center" justify="end" wrap="wrap">
<Button
variant="contained"
color="secondary"
startIcon={<ArrowBackIcon />}
fullWidth={isMobile}
label="Back"
variant="secondary"
icon={<MdArrowBack size={16} />}
width={isMobile ? '100%' : undefined}
onClick={() => navigate(-1)}
>
Back
</Button>
/>
{/* Assign stays disabled until every edited rider has been
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
title={
content={
dirtyRiderIds.size > 0
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
: unverifiedOrderIds.size > 0
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
: ''
: 'Commit these assignments'
}
>
<span style={isMobile ? { width: '100%' } : undefined}>
<Button
variant="contained"
fullWidth={isMobile}
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery}
>
Assign Orders
</Button>
</span>
<Button
label="Assign Orders"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
onClick={handleFinalCreateDelivery}
/>
</Tooltip>
</Stack>
</Box>
</HStack>
</VStack>
<Dialog
open={changeDialogOpen}
onClose={() => setChangeDialogOpen(false)}
maxWidth="xs"
fullWidth
fullScreen={isMobile}
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}
isOpen={changeDialogOpen}
onOpenChange={(next) => !next && setChangeDialogOpen(false)}
width={420}
purpose="form"
>
<DialogTitle sx={{ fontWeight: 700 }}>Change Rider</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 2, fontSize: 13, color: 'text.secondary' }}>
<DialogHeader title="Change Rider" />
<VStack gap={3} padding={4}>
<Text type="supporting">
Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to:
</Typography>
<Autocomplete
options={ridersList || []}
getOptionLabel={(o) =>
o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || ''
}
value={selectedNewRider}
onChange={(e, val) => setSelectedNewRider(val)}
renderInput={(params) => <TextField {...params} label="New rider" placeholder="Pick a rider" />}
</Text>
<Selector
label="New rider"
placeholder="Pick a rider"
options={riderOptions}
value={selectedNewRider ? String(selectedNewRider.userid) : null}
onChange={(value) => setSelectedNewRider(riderOptions.find((o) => o.value === value)?.rider ?? null)}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, flexDirection: { xs: 'column-reverse', sm: 'row' }, gap: { xs: 1, sm: 0 } }}>
<Button fullWidth={isMobile} onClick={() => setChangeDialogOpen(false)}>
Cancel
</Button>
<Button variant="contained" fullWidth={isMobile} disabled={!selectedNewRider} onClick={confirmChangeRider}>
Change Rider
</Button>
</DialogActions>
<HStack gap={2} justify="end" wrap="wrap">
<Button
label="Cancel"
variant="secondary"
width={isMobile ? '100%' : undefined}
onClick={() => setChangeDialogOpen(false)}
/>
<Button
label="Change Rider"
variant="primary"
width={isMobile ? '100%' : undefined}
isDisabled={!selectedNewRider}
onClick={confirmChangeRider}
/>
</HStack>
</VStack>
</Dialog>
</Box>
</VStack>
);
};
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the
// Assign payload always reflects the current cache without a useEffect.
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 PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import {
MdTrendingUp,
MdTrendingDown,
MdExpandMore,
MdReceipt,
MdPayments,
MdRoute,
MdLocationOn,
MdBarChart,
MdPeopleAlt
MdPeopleAlt,
MdStraighten,
MdAccountBalanceWallet,
MdOutlineSavings
} 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
@@ -34,31 +50,26 @@ export const FIXED_COST_PER_SLOT = 500 / 3;
/** Variable fuel / wear cost per km. */
export const VARIABLE_RATE_KM = 2.5;
/** Status display config keyed by normalised status string. */
const STATUS_MAP = {
delivered: { label: 'Delivered', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
active: { label: 'Active', color: '#0ea5e9', bg: '#f0f9ff', border: '#bae6fd' },
picked: { label: 'Picked up', color: '#8b5cf6', bg: '#f5f3ff', border: '#ddd6fe' },
assigned: { label: 'Assigned', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
cancelled: { label: 'Cancelled', color: '#ef4444', bg: '#fef2f2', border: '#fca5a5' },
skipped: { label: 'Skipped', color: '#f97316', bg: '#fff7ed', border: '#ffedd5' }
};
const DEFAULT_STATUS = STATUS_MAP.assigned;
// Status colours previously lived in a local STATUS_MAP here — the third rival
// status table in this folder. It disagreed with both of the others (it painted
// `active` sky-blue and had an `assigned` state the canonical table calls
// `accepted`), so the same order could render three different colours depending
// on which panel drew it. Statuses now render via <StatusBadge>, which reads
// themes/dt/status.js. See this folder's CLAUDE.md §1b.
// ─────────────────────────────────────────────────────────────
// Pure helpers
// ─────────────────────────────────────────────────────────────
function getStatusConfig(raw) {
return STATUS_MAP[String(raw ?? '').toLowerCase()] ?? DEFAULT_STATUS;
}
function orderRevenue(order) {
const km = parseFloat(order.riderkms || 0);
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 = [
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
@@ -72,16 +83,35 @@ const getBatchForHour = (h, batches = BATCHES_DEFAULT) => {
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 t = r?.assigntime || r?.deliverydate;
const t = r?.orderdate;
if (!t) return null;
const str = String(t).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = dayjs(t);
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
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
// 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
@@ -94,15 +124,7 @@ function calcRiderMetrics(rider, selectedDate, batches) {
// context. Only the money/km math below excludes them.
const orders = (rider.orders ?? []).filter((o) => {
if (!selectedDate) return true;
// parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z
// 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;
return rowDay(o) === selectedDate;
});
const billableOrders = orders.filter((o) => {
const status = String(o.orderstatus ?? o.status ?? '').toLowerCase();
@@ -119,11 +141,7 @@ function calcRiderMetrics(rider, selectedDate, batches) {
const slot = getRowBatch(o, batches);
if (!slot) continue;
const dateStr = o.assigntime
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: null;
const dateStr = rowDay(o);
if (!dateStr) continue;
if (!slotsByDate[dateStr]) {
@@ -150,9 +168,9 @@ function calcRiderMetrics(rider, selectedDate, batches) {
function formatDistance(km) {
const value = Number.isFinite(km) ? km : 0;
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) {
@@ -177,21 +195,15 @@ function clamp(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
// ─────────────────────────────────────────────────────────────
/** 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. */
function CustomerCell({ order }) {
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 ?? '';
return (
<div>
<div className="customer-name" title={name}>
<VStack gap={0.5} padding={0}>
<Text weight="bold" maxLines={1}>
{name}
</div>
{phone && <div className="customer-phone">{phone}</div>}
{location && (
<div className="customer-location">
<MdLocationOn size={12} aria-hidden="true" />
{location}
</div>
</Text>
{phone && (
<Text size="sm" type="supporting">
{phone}
</Text>
)}
</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 }) {
if (!orders?.length) {
return (
<div className="orders-empty-state" role="status">
<MdReceipt size={24} aria-hidden="true" />
<p className="orders-empty-message">No orders assigned to this rider yet.</p>
</div>
<EmptyState
title="No orders yet"
description="No orders assigned to this rider yet."
icon={<MdReceipt size={24} />}
/>
);
}
return (
<div className="orders-table-container">
<table className="orders-table" role="table">
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col">Planned KMs</th>
<th scope="col">Actual KMs</th>
<th scope="col">Trip KMs</th>
<th scope="col" style={{ textAlign: 'right' }}>
Revenue
</th>
</tr>
</thead>
<tbody>
{orders.map((order, idx) => {
const km = parseFloat(order.riderkms ?? 0);
const dist = formatDistance(km);
const plannedDist = formatDistance(parseFloat(order.kms ?? 0));
const actualDist = formatDistance(parseFloat(order.actualkms ?? 0));
const rev = getRevenue(order);
return (
<tr key={`${order.orderid ?? 'order'}-${idx}`}>
<td>
<CustomerCell order={order} />
</td>
<td>
<OrderStatusPill status={order.orderstatus ?? order.status} />
</td>
<td>
<span className="distance-value">{plannedDist.value}</span>
<span className="distance-unit">{plannedDist.unit}</span>
</td>
<td>
<span className="distance-value">{actualDist.value}</span>
<span className="distance-unit">{actualDist.unit}</span>
</td>
<td>
<span className="distance-value">{dist.value}</span>
<span className="distance-unit">{dist.unit}</span>
</td>
<td style={{ textAlign: 'right' }}>
<span className="revenue-amount">{rupees(rev)}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TableScroll minWidth={720} maxHeight={360}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>Customer</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Planned KMs</TableHeaderCell>
<TableHeaderCell>Actual KMs</TableHeaderCell>
<TableHeaderCell>Trip KMs</TableHeaderCell>
<TableHeaderCell>Revenue</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order, idx) => (
<TableRow key={`${order.orderid ?? 'order'}-${idx}`}>
<TableCell>
<CustomerCell order={order} />
</TableCell>
<TableCell>
<StatusBadge status={order.orderstatus ?? order.status} />
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.kms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.actualkms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text hasTabularNumbers>{formatDistance(parseFloat(order.riderkms ?? 0))}</Text>
</TableCell>
<TableCell>
<Text weight="bold" hasTabularNumbers style={{ textAlign: 'right' }}>
{rupees(getRevenue(order))}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableScroll>
);
}
/** One cost tile inside the expanded panel. */
function CostTile({ icon, color, label, amount, footnote }) {
return (
<Card padding={3}>
<HStack gap={2} vAlign="center">
<Center width={34} height={34} style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}>
{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 }) {
const { varCost, fixedCost, kms, net, margin } = metrics;
const isProfit = net >= 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`
}
];
const orderCount = metrics.orders?.length ?? 0;
return (
<div className="rider-detail-panel" aria-label="Rider cost breakdown">
<div className="rider-detail-inner">
{/* Cost breakdown tiles */}
<div className="cost-breakdown-grid">
{costTiles.map((tile) => (
<div className="cost-tile" key={tile.label}>
<div className={`cost-tile-icon ${tile.iconClass}`} aria-hidden="true">
{tile.icon}
</div>
<div className="cost-tile-content">
<span className="cost-tile-label">{tile.label}</span>
<span className={`cost-tile-amount ${tile.amountClass}`}>{tile.amount}</span>
<span className="cost-tile-footnote">{tile.footnote}</span>
</div>
</div>
))}
</div>
<VStack gap={3} padding={3}>
<Grid columns={{ minWidth: 200 }} gap={3}>
<CostTile
icon={<MdPayments size={16} />}
color={STATUS.info}
label="Fixed cost"
amount={rupees(fixedCost, 2)}
footnote="Salary (per slot)"
/>
<CostTile
icon={<MdRoute size={16} />}
color={STATUS.picked}
label="Variable cost"
amount={rupees(varCost, 2)}
footnote={`${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`}
/>
<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 */}
<div className="revenue-breakdown">
<div className="revenue-breakdown-header">
<div className="revenue-breakdown-title">
<MdReceipt size={14} aria-hidden="true" />
Revenue Breakdown
</div>
<span className="revenue-breakdown-count">
{metrics.orders?.length ?? 0} order{(metrics.orders?.length ?? 0) !== 1 ? 's' : ''}
</span>
</div>
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
</div>
</div>
</div>
<VStack gap={2} padding={0}>
<HStack justify="between" vAlign="center">
<HStack gap={1} vAlign="center">
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
<MdReceipt size={14} />
</Center>
<Text weight="bold">Revenue breakdown</Text>
</HStack>
<Text size="sm" type="supporting">
{orderCount} order{orderCount !== 1 ? 's' : ''}
</Text>
</HStack>
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
</VStack>
</VStack>
);
}
/** Single expandable rider profitability card. */
/** Single expandable rider profitability row. */
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
const { revenue, kms, totalCost, net, margin, orders } = metrics;
const isProfit = net >= 0;
const marginBarWidth = `${clamp(Math.abs(margin), 0, 100)}%`;
const orderCount = orders?.length ?? 0;
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
const cardClasses = [
'rider-profitability-card',
isProfit ? 'rider-profitability-card--profitable' : 'rider-profitability-card--unprofitable',
isFocused ? 'rider-profitability-card--selected' : ''
]
.filter(Boolean)
.join(' ');
function handleActivate() {
const handleOpenChange = () => {
onToggle(rider.id);
if (!isFocused && onFocus) onFocus(rider);
}
};
return (
<div className={cardClasses} aria-label={`Rider: ${name}`}>
{/* Clickable header row */}
<div
className="rider-card-header"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleActivate();
}
}}
<Card padding={0} elevation="none" style={isFocused ? { outline: '2px solid var(--color-accent)' } : undefined}>
<Collapsible
isOpen={isExpanded}
onOpenChange={handleOpenChange}
trigger={
<HStack gap={3} vAlign="center" wrap="wrap" padding={3}>
<AccentAvatar color={moneyColor(isProfit)} selected size={36}>
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
{riderInitials(name)}
</Text>
</AccentAvatar>
<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 */}
<div className="rider-avatar" aria-hidden="true">
{riderInitials(name)}
</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>
<RiderDetailPanel metrics={metrics} />
</Collapsible>
</Card>
);
}
@@ -462,7 +465,6 @@ export default function ProfitabilitySection({
batches
}) {
const [expanded, setExpanded] = useState({});
const sortMode = 'profit-asc'; // 'profit-asc', 'profit-desc', 'name-asc', 'orders-desc'
const toggleRider = useCallback((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
const activeEnriched = useMemo(
() => enriched.filter((r) => r._m.orders.length > 0),
[enriched]
);
const activeEnriched = useMemo(() => enriched.filter((r) => r._m.orders.length > 0), [enriched]);
// Aggregate overall slot totals (pre-filtered for BI consistency)
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 profitCount = activeEnriched.filter((r) => r._m.net >= 0).length;
const lossCount = activeEnriched.length - profitCount;
const totalOrders = enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0);
const dailyIsProfit = totalDailyProfit >= 0;
const slotIsProfit = slotNet >= 0;
const slotMargin = slotRevenue > 0 ? (slotNet / slotRevenue) * 100 : 0;
// Filter riders (filtering by search and status tabs removed)
const filtered = 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]);
// Sorted loss-first so the riders needing attention are at the top.
const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
return (
<div className="profitability-dashboard" role="region" aria-label="Profitability overview">
{/* ── Header ── */}
<header className="profitability-header">
<div className="profitability-header-left">
<div className="profitability-header-icon" aria-hidden="true">
<MdBarChart size={18} />
</div>
<div>
<h2 className="profitability-header-title">Profitability Overview</h2>
<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>
<VStack gap={3} padding={3}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={2}>
<VStack gap={0.5} padding={0}>
<Heading level={2}>Profitability overview</Heading>
<Text type="supporting">
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
</Text>
</VStack>
</HStack>
<div className="profitability-kpi-group">
{/* Daily profit / loss */}
<div className={`profitability-kpi-chip ${dailyIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
<div className="profitability-kpi-chip-icon" aria-hidden="true">
{dailyIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
</div>
<div className="profitability-kpi-chip-content">
<span className="profitability-kpi-chip-label">Daily {dailyIsProfit ? 'profit' : 'loss'}</span>
<span className="profitability-kpi-chip-amount">{rupees(Math.abs(totalDailyProfit))}</span>
</div>
</div>
<Grid columns={{ minWidth: 200 }} gap={3}>
<StatCard
title={`Daily ${dailyIsProfit ? 'profit' : 'loss'}`}
value={rupees(Math.abs(totalDailyProfit))}
icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
color={moneyColor(dailyIsProfit)}
caption="All slots today"
/>
<StatCard
title="Riders active"
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 */}
<div className={`profitability-kpi-chip ${slotIsProfit ? 'profitability-kpi-chip--profit' : 'profitability-kpi-chip--loss'}`}>
<div className="profitability-kpi-chip-icon" aria-hidden="true">
{slotIsProfit ? <MdTrendingUp /> : <MdTrendingDown />}
</div>
<div className="profitability-kpi-chip-content">
<span className="profitability-kpi-chip-label">Slot {slotIsProfit ? 'profit' : 'loss'}</span>
<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>
<VStack gap={2} padding={0}>
<Text weight="bold">Riders list</Text>
{sorted.length === 0 ? (
<EmptyState
title="No riders to show"
description="No riders have orders in this slot."
icon={<MdPeopleAlt size={28} />}
/>
) : (
sortedAndFiltered.map((rider) => (
<div key={rider.id} role="listitem">
<VStack gap={2} padding={0}>
{sorted.map((rider) => (
<RiderProfitabilityCard
key={rider.id}
rider={rider}
metrics={rider._m}
isExpanded={!!expanded[rider.id]}
@@ -625,18 +571,14 @@ export default function ProfitabilitySection({
onToggle={toggleRider}
onFocus={handleRiderFocus}
/>
</div>
))
))}
</VStack>
)}
</div>
</div>
</VStack>
</VStack>
);
}
OrderStatusPill.propTypes = {
status: PropTypes.string
};
CustomerCell.propTypes = {
order: PropTypes.object.isRequired
};
@@ -646,14 +588,22 @@ OrdersBreakdownTable.propTypes = {
getRevenue: PropTypes.func.isRequired
};
CostTile.propTypes = {
icon: PropTypes.node,
color: PropTypes.string,
label: PropTypes.node,
amount: PropTypes.node,
footnote: PropTypes.node
};
RiderDetailPanel.propTypes = {
rider: PropTypes.object.isRequired,
metrics: PropTypes.shape({
varCost: PropTypes.number.isRequired,
fixedCost: PropTypes.number.isRequired,
kms: PropTypes.number.isRequired,
net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired
margin: PropTypes.number.isRequired,
orders: PropTypes.array
}).isRequired
};
@@ -664,7 +614,8 @@ RiderProfitabilityCard.propTypes = {
kms: PropTypes.number.isRequired,
totalCost: PropTypes.number.isRequired,
net: PropTypes.number.isRequired,
margin: PropTypes.number.isRequired
margin: PropTypes.number.isRequired,
orders: PropTypes.array
}).isRequired,
isExpanded: PropTypes.bool.isRequired,
isFocused: PropTypes.bool,

View File

@@ -3,26 +3,80 @@
// Dispatch.js so we don't create a circular import between the host
// component and the child views.
// Status palette — single source of truth for the status pill colors
// rendered on rider cards, order rows, step lists, and tooltips.
export const STATUS_STYLES = {
created: { label: 'Created', bg: '#3b82f6', fg: '#fff' },
pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' },
accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' },
arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' },
picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' },
active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' },
delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' },
skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' },
cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }
import { useState, useEffect } from 'react';
import { getStatusMeta } from 'themes/dt/status';
// Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))`, which was the
// last MUI dependency in Preview.js and CompareDataPanel.js. MUI's `md`
// breakpoint is 900px and `down()` is exclusive, so this is the identical
// `max-width: 899.95px` query it generated — the same width the scoped media
// blocks in Dispatch.css already key off.
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);
// 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_STYLES[String(status || '').toLowerCase()] || {
label: status || 'Unknown',
bg: '#64748b',
fg: '#fff'
};
// Status colour for the places that need a RAW HEX rather than a rendered
// badge — Leaflet divIcon HTML built as a template string, and the `style`
// attribute on a few inline pills. Anything that renders normal JSX should use
// <StatusBadge status={...}/> instead; this exists only because you cannot put
// 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
// rider list, the planned-route renderer, and the compare data panel.

View File

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

View File

@@ -1,5 +1,5 @@
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 { enqueueSnackbar } from 'notistack';
@@ -11,6 +11,8 @@ 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 { Divider } from '@astryxdesign/core/Divider';
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
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 { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi';
import { DT, STATUS } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
@@ -76,7 +78,8 @@ const Hubs = () => {
const total = hubs.length;
const sorting = hubs.filter((h) => h.hubtype === 'sorting_center').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]);
const opentoast = (message, variant = 'error') =>
@@ -155,7 +158,7 @@ const Hubs = () => {
return (
<>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
{(isLoading || deleteMutation.isPending) && <Loader />}
<PageHeader
title="Hubs"
@@ -164,10 +167,11 @@ const Hubs = () => {
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="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="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} />
</Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
@@ -255,49 +259,82 @@ const Hubs = () => {
</Card>
</VStack>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form">
<DialogHeader title={form.hubid ? 'Edit Hub' : 'New Hub'} />
<VStack gap={4} padding={4}>
<Grid columns={{ minWidth: 240 }} gap={3}>
<TextInput label="Hub Name" value={form.hubname} onChange={set('hubname')} isRequired />
<Selector label="Type" options={HUB_TYPE_OPTIONS} value={form.hubtype} onChange={set('hubtype')} />
<NumberInput
label="City (applocationid)"
value={form.applocationid === '' ? undefined : Number(form.applocationid)}
onChange={(v) => set('applocationid')(v ?? '')}
description="Read the correct id from GET /admin/hubs rather than guessing"
isRequired
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={680} purpose="form">
<Layout
header={
<DialogHeader
title={form.hubid ? 'Edit Hub' : 'New Hub'}
subtitle={form.hubid ? `Updating "${form.hubname || 'hub'}"` : 'Add a sorting center or delivery hub'}
onOpenChange={(next) => !next && setDialogOpen(false)}
startContent={
<AccentAvatar color={BRAND} selected size={36}>
<MdOutlineWarehouse size={18} />
</AccentAvatar>
}
/>
<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}>
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} isOptional />
<NumberInput
label="Latitude"
value={form.latitude === '' ? undefined : Number(form.latitude)}
onChange={(v) => set('latitude')(v ?? '')}
isOptional
/>
<NumberInput
label="Longitude"
value={form.longitude === '' ? undefined : Number(form.longitude)}
onChange={(v) => set('longitude')(v ?? '')}
isOptional
/>
</Grid>
<Grid columns={{ minWidth: 240 }} gap={3}>
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
</Grid>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} />
<Button label={form.hubid ? 'Save Changes' : 'Create'} variant="primary" onClick={handleSave} />
</HStack>
</VStack>
<VStack gap={3} padding={0}>
<Text type="label" weight="semibold" color="secondary">
Location
</Text>
<TextInput label="Address" value={form.address} onChange={set('address')} isOptional />
<NumberInput
label="Location ID (applocationid)"
value={form.applocationid === '' ? undefined : Number(form.applocationid)}
onChange={(v) => set('applocationid')(v ?? '')}
description="Read the correct id from GET /admin/hubs rather than guessing"
isRequired
/>
<Grid columns={{ minWidth: 160 }} gap={3}>
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} isOptional />
<NumberInput
label="Latitude"
value={form.latitude === '' ? undefined : Number(form.latitude)}
onChange={(v) => set('latitude')(v ?? '')}
isOptional
/>
<NumberInput
label="Longitude"
value={form.longitude === '' ? undefined : Number(form.longitude)}
onChange={(v) => set('longitude')(v ?? '')}
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>
</>
);

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 move solver URLs into env vars.** They are deliberately hardcoded because the optimiser is a separate, versioned service — pinning the URL is the version pin.
- **Don't fold `createorder1.js`, `newcreateOrder.js`, and `multipleOrders.js` into one component.** They serve different operator flows (existing customer vs new customer vs CSV bulk import).
- **Don't reorder `OrdersTableSkeleton`'s column count** without checking every page that imports it. It's used as a shared skeleton across orders, deliveries, tenants, and customers.

File diff suppressed because it is too large Load Diff

View File

@@ -1,149 +1,8 @@
/* OrdersRedesign.css — premium aesthetics + micro-animations
Ported from xpressconsole; trimmed to only the classes the
redesigned multipleOrders.js (and its peers) consume here. */
/* OrdersRedesign.css — the one bespoke visual treatment multipleOrders.js
keeps after its Astryx conversion: the gradient "Total Charge" badge in
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 {
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);
@@ -181,108 +40,3 @@
letter-spacing: -0.01em;
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.
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 t = o.assigntime || o.deliverydate;
const t = o.orderdate;
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;
const hour = d.hour() + d.minute() / 60;
const batch = BATCHES_DEFAULT.find((b) => hour >= b.startHour && hour < b.endHour);
@@ -111,11 +120,7 @@ const Profitability = () => {
if (SKIPPED_STATUSES.has(status)) return;
// parseDoormileTimestamp (not bare dayjs()) avoids the false-trailing-Z
// parse bug (utils/doormileTimestamp.js) — see ProfitabilitySection.js.
const dateStr = o.assigntime
? parseDoormileTimestamp(o.assigntime).format('YYYY-MM-DD')
: o.deliverydate
? parseDoormileTimestamp(o.deliverydate).format('YYYY-MM-DD')
: null;
const dateStr = o.orderdate ? parseDoormileTimestamp(o.orderdate).format('YYYY-MM-DD') : null;
if (dateStr !== selectedDate) return;
const km = parseFloat(o.riderkms || 0);
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 {
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 { 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 { 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 { 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 = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel },
online: { label: 'Online', color: '#10b981', icon: MdCheckCircle },
offline: { label: 'Offline', color: '#ef4444', icon: MdCancel },
idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime },
unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2 }
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
online: { label: 'Online', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
offline: { label: 'Offline', color: '#ef4444', icon: MdCancel, badge: 'red' },
idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime, badge: 'yellow' },
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({
rows,
allRidersList,
@@ -42,15 +46,9 @@ export default function RiderSubstitution({
handleAssignSubstitute,
onFinalizeSuccess,
selectedDate,
setSelectedDate,
DT,
BRAND,
edge,
tint,
soft
setSelectedDate
}) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isMobile = useIsNarrowViewport();
const [selectedBatch, setSelectedBatch] = useState('all');
const [isFinalizing, setIsFinalizing] = useState(false);
@@ -151,275 +149,136 @@ export default function RiderSubstitution({
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 }) => (
<Avatar
sx={{
width: size,
height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
const renderSubstitutePicker = (row, disabled) => (
<Selector
label="Substitute Rider"
isLabelHidden
size="sm"
options={substituteOptions}
value={substituteAssignments[row.userid] ? String(substituteAssignments[row.userid].userid) : ''}
onChange={(v) => {
const newValue = (substituteRidersList || []).find((r) => String(r.userid) === v) || null;
handleAssignSubstitute(row.userid, newValue);
}}
>
{children}
</Avatar>
placeholder="Assign Substitute"
hasSearch
hasClear
isDisabled={disabled}
/>
);
return (
<>
{/* Filter Row inside its own Paper */}
<Paper
elevation={0}
sx={{
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 }}>
<Card padding={3} elevation="none" style={{ marginBottom: 16 }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center" wrap="wrap">
<Text weight="bold" color="secondary">
Shift Batch:
</Typography>
<ToggleButtonGroup
value={selectedBatch}
exclusive
onChange={(e, val) => val && setSelectedBatch(val)}
aria-label="batch selection"
size="small"
sx={{
'& .MuiToggleButton-root': {
borderRadius: '8px',
mx: 0.5,
border: `1px solid ${DT.borderSubtle}`,
color: DT.textSecondary,
fontWeight: 600,
textTransform: 'capitalize',
px: 2,
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>
</Text>
<SegmentedControl label="Shift batch" value={selectedBatch} onChange={setSelectedBatch} size="sm">
{BATCH_OPTIONS.map((b) => (
<SegmentedControlItem key={b.value} value={b.value} label={b.label} />
))}
</SegmentedControl>
</HStack>
<DateInput
label="Select Date"
isLabelHidden
size="sm"
value={selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setSelectedDate(dayjs(v))}
/>
</HStack>
</Card>
{/* Results Table/Cards */}
<Paper
elevation={0}
sx={{
borderRadius: `${DT.radiusCard / 8}px`,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
<TableContainer
sx={{
maxHeight: 'calc(100vh - 240px)',
'&::-webkit-scrollbar': { width: 10, height: 10 },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge(BRAND),
borderRadius: 8,
'&:hover': { backgroundColor: BRAND }
},
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
}}
>
{isMobile ? (
<MobileCardList>
{filteredRows?.length === 0 && (
<Stack alignItems="center" spacing={1.5} sx={{ py: 6 }}>
<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>
)}
{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),
<Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
{isMobile ? (
filteredRows?.length === 0 ? (
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
) : (
<MobileCardList scroll>
{filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
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={
<VStack gap={2} padding={0}>
<HStack justify="between" vAlign="center" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Text>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 16,
border: `1px solid ${edge(BRAND)}`,
flexShrink: 0
fontSize: 11
}}
>
{(row.fullname || row.username || '?').charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, color: DT.textPrimary, fontSize: 15 }} noWrap>
{row.username || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{row.contactno || '—'}
</Typography>
</Box>
</Stack>
</Stack>
}
>
<MobileFieldGrid>
<MobileField label="Substitute" full>
<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 }
}
}}
/>
)}
/>
</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 }
#{row?.userid}
</div>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={11} />} label={statusMeta.label} />
</HStack>
</HStack>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{row.username || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.contactno || '—'}
</Text>
</VStack>
</HStack>
</VStack>
}
}}
>
<TableCell>#</TableCell>
<TableCell>ID</TableCell>
<TableCell>Rider</TableCell>
<TableCell align="center"></TableCell>
<TableCell>Substitute Rider</TableCell>
<TableCell align="center">Status</TableCell>
>
<MobileFieldGrid columns={1}>
<MobileField label="Substitute" full>
{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
</MobileField>
</MobileFieldGrid>
</MobileCard>
);
})}
</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>
</TableHead>
</TableHeader>
<TableBody>
{filteredRows?.length === 0 && (
<TableRow>
<TableCell colSpan={totalCols} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<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 colSpan={6}>
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
</TableCell>
</TableRow>
)}
@@ -427,37 +286,24 @@ export default function RiderSubstitution({
{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 (
<TableRow
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 }
}}
>
<TableRow key={row.userid ?? index}>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Typography>
</Text>
</TableCell>
<TableCell>
<Box
sx={{
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 1,
py: 0.375,
padding: '3px 8px',
borderRadius: 999,
bgcolor: '#ffffff',
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
@@ -466,126 +312,43 @@ export default function RiderSubstitution({
}}
>
#{row?.userid}
</Box>
</div>
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<Avatar
sx={{
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} />
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<Typography variant="caption" sx={{ fontWeight: 800, fontSize: 11, lineHeight: 1 }}>
{statusMeta.label}
</Typography>
</Stack>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold">{row.username || '—'}</Text>
<Text type="supporting" color="secondary">
{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>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</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>
</TableScroll>
)}
</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';
// 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 { useNavigate } from 'react-router';
import { useMutation, useQuery } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { MdDirectionsBike } from 'react-icons/md';
// project import
import MainCard from 'components/MainCard';
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 Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { createMiler, getHubs } from 'pages/api/doormileApi';
import { getTenants, fetchAppLocations } from 'pages/api/api';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
import { useNavigate } from 'react-router';
import { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
const VEHICLE_TYPE_OPTIONS = VEHICLE_TYPES.map((v) => ({ value: v, label: v }));
const Createrider = () => {
const [displayname, setDisplayname] = useState('');
@@ -39,13 +30,11 @@ const Createrider = () => {
const [mobilenumber, setMobilenumber] = useState('');
const [password, setPassword] = useState('');
const [vehicletype, setVehicletype] = useState('Bike');
const [selectedTenant, setSelectedTenant] = useState(null);
const [selectedHub, setSelectedHub] = useState(null);
const [selectedLocation, setSelectedLocation] = useState(null);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [selectedHubId, setSelectedHubId] = useState('');
const [selectedLocationId, setSelectedLocationId] = useState('');
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
// tenant and must pick one; a client console login is scoped to its own
@@ -69,16 +58,15 @@ const Createrider = () => {
queryFn: fetchAppLocations
});
const hubOptions = useMemo(() => hubs || [], [hubs]);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]);
const tenantOptions = useMemo(() => (tenants || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` })), [tenants]);
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) => {
enqueueSnackbar(message, {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
};
const opentoast = (message) =>
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const createMilerMutation = useMutation({
mutationFn: (payload) => createMiler(payload),
@@ -94,13 +82,11 @@ const Createrider = () => {
opentoast(res.message || 'Failed to create rider');
}
},
onError: (err) => {
opentoast(err.response?.data?.message || err.message || 'Failed to create rider');
}
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create rider')
});
const createprofile = () => {
const tenantid = isDoormileStaff ? selectedTenant?.tenantid : Number(loginTenantId);
const tenantid = isDoormileStaff ? (selectedTenantId ? Number(selectedTenantId) : undefined) : Number(loginTenantId);
if (!displayname) {
opentoast('Fill Display Name');
@@ -114,7 +100,7 @@ const Createrider = () => {
opentoast('Fill Password');
} else if (isDoormileStaff && !tenantid) {
opentoast('Choose Tenant');
} else if (!selectedLocation?.applocationid) {
} else if (!selectedLocationId) {
opentoast('Choose City');
} else {
// POST /admin/milers — configid defaults to 1001 server-side (the
@@ -127,8 +113,8 @@ const Createrider = () => {
password,
tenantid,
defaultvehicletype: vehicletype,
applocationid: selectedLocation?.applocationid,
hubid: selectedHub?.hubid
applocationid: Number(selectedLocationId),
hubid: selectedHubId ? Number(selectedHubId) : undefined
};
createMilerMutation.mutate(obj);
@@ -139,184 +125,70 @@ const Createrider = () => {
<>
{createMilerMutation.isPending && <Loader />}
<Box sx={{ p: { xs: 1.5, md: 3 } }}>
<Grid item xs={12} sx={{ mb: 2 }}>
<Paper
sx={{
p: 2.5,
borderRadius: DT.radiusCard + 'px',
boxShadow: DT.shadowSoft,
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>
<PageHeader
title="Create Rider"
subtitle="Add a miler-app login"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdDirectionsBike size={20} />
</AccentAvatar>
}
/>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-password">Password</InputLabel>
<TextField
type="password"
fullWidth
id="rider-password"
placeholder="Miler-app login password"
onChange={(e) => setPassword(e.target.value)}
value={password}
autoComplete="off"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-vehicle-type">Vehicle Type</InputLabel>
<Select id="rider-vehicle-type" fullWidth value={vehicletype} onChange={(e) => setVehicletype(e.target.value)}>
{VEHICLE_TYPES.map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
{isDoormileStaff && (
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<InputLabel htmlFor="rider-tenant">Tenant</InputLabel>
<Autocomplete
id="rider-tenant"
options={tenants || []}
getOptionLabel={(option) => option.tenantname || ''}
value={selectedTenant}
onChange={(e, value) => setSelectedTenant(value)}
renderInput={(params) => <TextField {...params} placeholder="Choose tenant" />}
/>
</Stack>
</Grid>
)}
<Grid item xs={12} sm={6}>
<Stack spacing={1.25}>
<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>
<Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<TextInput label="Display Name" placeholder="e.g. Murali S" value={displayname} onChange={setDisplayname} isRequired />
<TextInput label="Login Name" placeholder="Login name used on the miler app" value={authname} onChange={setAuthname} isRequired />
<TextInput
label="Phone Number"
placeholder="10-digit mobile number"
value={mobilenumber}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setMobilenumber(digits);
}}
isRequired
/>
<TextInput label="Email Address" type="email" placeholder="Email Address" value={emailaddress} onChange={setEmailaddress} isRequired />
<TextInput label="Password" type="password" placeholder="Miler-app login password" value={password} onChange={setPassword} isRequired />
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={vehicletype} onChange={setVehicletype} />
{isDoormileStaff && (
<Selector
label="Tenant"
options={tenantOptions}
value={selectedTenantId}
onChange={setSelectedTenantId}
placeholder="Choose tenant"
hasSearch
isRequired
/>
)}
<Selector
label="City"
options={locationOptions}
value={selectedLocationId}
onChange={setSelectedLocationId}
placeholder="Choose city"
hasSearch
isRequired
/>
<Selector
label="Hub"
options={hubOptions}
value={selectedHubId}
onChange={setSelectedHubId}
placeholder="Choose hub (visibility on hub console)"
hasSearch
isOptional
/>
</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';
// material-ui
import { useLocation, useNavigate } from 'react-router-dom';
import { enqueueSnackbar } from 'notistack';
import { MdDirectionsBike, MdArrowBack } from 'react-icons/md';
import {
Avatar,
Box,
Button,
Grid,
InputLabel,
MenuItem,
Select,
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';
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';
// project import
import MainCard from 'components/MainCard';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { getMiler, updateMiler, getAdminTenant } from 'pages/api/doormileApi';
import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api';
import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack';
import CircularLoader from 'components/CircularLoader';
import { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
const EditRider = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const location = useLocation();
const [riderdata, setRiderdata] = useState(null);
const navigate = useNavigate();
const [riderdata, setRiderdata] = useState(null);
const [partnerlist, setPartnerlist] = useState([]);
const [locaName, setLocoName] = useState();
const [loading, setLoading] = useState(false);
const fetchRiderData = async (id) => {
@@ -74,11 +60,12 @@ const EditRider = () => {
}
};
// ==============================|| fetchAppLocations ||============================== //
useEffect(() => {
fetchZoneLocations().then(setPartnerlist);
}, []);
const locationOptions = (partnerlist || []).map((l) => ({ value: String(l.applocationid), label: l.locationname }));
const updateRider = async () => {
setLoading(true);
// PUT /admin/milers/:id has no documented fields for bank/vehicle/shift
@@ -126,229 +113,94 @@ const EditRider = () => {
setLoading(false);
}
};
return (
<>
{loading && (
<>
<Loader />
<CircularLoader />
</>
)}
<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>
{loading && <Loader />}
<PageHeader
title="Edit Rider"
subtitle={riderdata?.displayname || 'Update miler-app login'}
action={
<Button
variant="contained"
fullWidth={isMobile}
label="Back to Riders"
variant="secondary"
icon={<MdArrowBack size={16} />}
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 {
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 { useMemo, useState } from 'react';
import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md';
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 TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader';
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 { enqueueSnackbar } from 'notistack';
import { DT, soft, tableScrollSx, tableHeadSx, tableRowSx } from 'themes/dt/tokens';
import { getTripsheets, createTripsheet, addTripsheetItem, dispatchTripsheet, arriveTripsheet, getHubs, getVehicles, getMilers } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================
// 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 }.
// ============================================================================
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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
@@ -70,6 +48,15 @@ const Tripsheets = () => {
const { data: milers = [] } = useQuery({ queryKey: ['admin-milers-for-trips'], queryFn: getMilers });
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 total = tripsheets.length;
@@ -128,13 +115,15 @@ const Tripsheets = () => {
return;
}
createMutation.mutate({
sourcehubid: form.sourceHub.hubid,
destinationhubid: form.destinationHub.hubid,
vehicleid: form.vehicle.vehicleid,
driveruserid: form.driver?.userid
sourcehubid: Number(form.sourceHub),
destinationhubid: Number(form.destinationHub),
vehicleid: Number(form.vehicle),
driveruserid: form.driver ? Number(form.driver) : undefined
});
};
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
return (
<>
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
@@ -143,172 +132,190 @@ const Tripsheets = () => {
title="Tripsheets"
subtitle="Live · Hub-to-Hub Transport"
live
action={
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
New Tripsheet
</Button>
}
action={<Button label="New Tripsheet" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
/>
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
<Grid item xs={6}>
<StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} />
</Grid>
<Grid item xs={6}>
<StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
</Grid>
<Grid columns={{ minWidth: 220, max: 2, repeat: 'fit' }} gap={3}>
<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>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: DT.borderSubtle, borderRadius: DT.radiusCard / 8 + 'px', overflow: 'hidden', background: '#fff' }}>
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' }, ...tableScrollSx }}>
<Table stickyHeader sx={{ minWidth: isMobile ? 860 : 920 }}>
<TableHead>
<TableRow sx={tableHeadSx}>
<TableCell>#</TableCell>
<TableCell>Source Hub</TableCell>
<TableCell>Destination Hub</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
<Card padding={0} elevation="none" style={{ marginTop: 16, overflow: 'hidden' }}>
<TableScroll minWidth={920}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Source Hub</TableHeaderCell>
<TableHeaderCell>Destination Hub</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{isLoading && <OrdersTableSkeleton col={3} />}
{isLoading && <TableLoader rows={5} columns={5} />}
{tripsheets.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 }}>
<MdOutlineLocalShipping size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No tripsheets to show
</Typography>
</Stack>
<TableCell colSpan={5}>
<EmptyState
title="No tripsheets to show"
description="Create a tripsheet to move consignments between hubs."
icon={<MdOutlineLocalShipping size={28} />}
/>
</TableCell>
</TableRow>
) : (
tripsheets.map((row, index) => (
<TableRow key={row.tripsheetid || index} sx={tableRowSx}>
<TableRow key={row.tripsheetid || index}>
<TableCell>{index + 1}</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 align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Add item">
<IconButton size="small" onClick={() => setItemDialogTrip(row)} sx={{ color: BRAND }}>
<MdPlaylistAdd size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Dispatch">
<IconButton
size="small"
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
sx={{ color: '#14b8a6' }}
>
<MdOutlineFlightTakeoff size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Arrive">
<IconButton
size="small"
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
sx={{ color: '#10b981' }}
>
<MdOutlineFlightLand size={16} />
</IconButton>
</Tooltip>
</Stack>
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Add item"
tooltip="Add item"
size="sm"
variant="ghost"
icon={<MdPlaylistAdd size={16} />}
onClick={() => setItemDialogTrip(row)}
/>
<IconButton
label="Dispatch"
tooltip="Dispatch"
size="sm"
variant="ghost"
icon={<MdOutlineFlightTakeoff size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
/>
<IconButton
label="Arrive"
tooltip="Arrive"
size="sm"
variant="ghost"
icon={<MdOutlineFlightLand size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
/>
</HStack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TableScroll>
</Card>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>New Tripsheet</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Source Hub</InputLabel>
<Autocomplete
options={hubs || []}
getOptionLabel={(option) => option.hubname || ''}
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={480} purpose="form">
<Layout
header={
<DialogHeader
title="New Tripsheet"
subtitle="Move consignments between two hubs"
onOpenChange={(next) => !next && setDialogOpen(false)}
startContent={
<AccentAvatar color={BRAND} selected size={36}>
<MdOutlineLocalShipping size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Selector
label="Source Hub"
options={hubOptions}
value={form.sourceHub}
onChange={(e, value) => setForm((f) => ({ ...f, sourceHub: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose source hub" />}
onChange={set('sourceHub')}
placeholder="Choose source hub"
hasSearch
isRequired
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Destination Hub</InputLabel>
<Autocomplete
options={hubs || []}
getOptionLabel={(option) => option.hubname || ''}
<Selector
label="Destination Hub"
options={hubOptions}
value={form.destinationHub}
onChange={(e, value) => setForm((f) => ({ ...f, destinationHub: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose destination hub" />}
onChange={set('destinationHub')}
placeholder="Choose destination hub"
hasSearch
isRequired
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Vehicle</InputLabel>
<Autocomplete
options={vehicles || []}
getOptionLabel={(option) => option.vehicleno || ''}
<Selector
label="Vehicle"
options={vehicleOptions}
value={form.vehicle}
onChange={(e, value) => setForm((f) => ({ ...f, vehicle: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose vehicle" />}
onChange={set('vehicle')}
placeholder="Choose vehicle"
hasSearch
isRequired
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Driver (optional)</InputLabel>
<Autocomplete
options={milers || []}
getOptionLabel={(option) => option.displayname || option.authname || ''}
<Selector
label="Driver"
options={driverOptions}
value={form.driver}
onChange={(e, value) => setForm((f) => ({ ...f, driver: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose driver" />}
onChange={set('driver')}
placeholder="Choose driver"
hasSearch
isOptional
/>
</Stack>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}>
Create
</Button>
</DialogActions>
</VStack>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={createMutation.isPending} />
<Button label="Create" variant="primary" onClick={handleCreate} isLoading={createMutation.isPending} />
</HStack>
</LayoutFooter>
}
/>
</Dialog>
<Dialog open={!!itemDialogTrip} onClose={() => setItemDialogTrip(null)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Add Item to Tripsheet</DialogTitle>
<DialogContent>
<Stack spacing={1} sx={{ mt: 1 }}>
<InputLabel>Consignment ID</InputLabel>
<TextField value={itemConsignmentId} onChange={(e) => setItemConsignmentId(e.target.value)} autoFocus />
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setItemDialogTrip(null)}>Cancel</Button>
<Button
variant="contained"
disabled={!itemConsignmentId}
onClick={() => addItemMutation.mutate({ id: itemDialogTrip.tripsheetid, consignmentid: itemConsignmentId })}
sx={{ bgcolor: BRAND }}
>
Add Item
</Button>
</DialogActions>
<Dialog isOpen={!!itemDialogTrip} onOpenChange={(next) => !next && setItemDialogTrip(null)} width={420} purpose="form">
<Layout
header={
<DialogHeader
title="Add Item to Tripsheet"
subtitle={itemDialogTrip ? `Tripsheet #${itemDialogTrip.tripsheetid}` : ''}
onOpenChange={(next) => !next && setItemDialogTrip(null)}
startContent={
<AccentAvatar color={BRAND} selected size={36}>
<MdPlaylistAdd size={18} />
</AccentAvatar>
}
/>
}
content={
<LayoutContent padding={4}>
<TextInput
label="Consignment ID"
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>
</>
);

View File

@@ -1,5 +1,5 @@
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 { HStack } from '@astryxdesign/core/HStack';
@@ -69,7 +69,8 @@ const Vehicles = () => {
// of 'active', so counting against that string always read 0.
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;
return { total, active, lowBattery };
const fullBattery = vehicles.filter((v) => Number(v.batterypercentage) >= 80).length;
return { total, active, lowBattery, fullBattery };
}, [vehicles]);
const opentoast = (message, variant = 'error') =>
@@ -156,10 +157,11 @@ const Vehicles = () => {
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="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="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} />
</Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>

View File

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

View File

@@ -67,6 +67,11 @@ export const STATUS_ALIASES = {
miler_assigned: 'accepted',
pickup_scheduled: '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',
intransit: 'active',
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 ring = (c) => a(c, '26'); // focus ring color
export const edge = (c) => a(c, '55'); // resting border
// ---------------------------------------------------------------------------
// ⛔ LEGACY — MUI-ONLY. Everything from here to the end of this file emits MUI
// `sx` objects and cannot be used by an Astryx page.
//
// They stay only because the pages still awaiting conversion import them
// (`pillFieldSx` ×3 files, `tableScrollSx` ×4, `tableHeadSx` ×3,
// `tableRowSx` ×3). They are deleted along with the MUI dependency once the
// last page converts — do NOT add a new call site.
//
// Astryx equivalents:
// pillFieldSx → nothing. Field chrome comes from the theme
// (`themes/astryx.js` → components['text-input']), so every
// input matches without a per-call-site helper.
// tableScrollSx → <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