updates on the astryx desing
This commit is contained in:
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
34
src/App.js
34
src/App.js
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
41
src/globalPolish.css
Normal 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;
|
||||
}
|
||||
27
src/hooks/useIsNarrowViewport.js
Normal file
27
src/hooks/useIsNarrowViewport.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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't store an address against a customer yet — these fields aren't required and won'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't store an address against a customer yet — these fields aren't required and won'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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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't store an address against a customer yet — these fields aren't required and won'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't store an address against a customer yet — these fields aren't required and won'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
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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} · ` : ''}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 < 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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 #{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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// ==============================|| OVERRIDES - ALERT TITLE ||============================== //
|
||||
|
||||
export default function AlertTitle() {
|
||||
return {
|
||||
MuiAlertTitle: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
marginBottom: 4,
|
||||
marginTop: 0,
|
||||
fontWeight: 400
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// ==============================|| OVERRIDES - BUTTON ||============================== //
|
||||
|
||||
export default function ButtonBase() {
|
||||
return {
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableRipple: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// ==============================|| OVERRIDES - BUTTON GROUP ||============================== //
|
||||
|
||||
export default function ButtonGroup() {
|
||||
return {
|
||||
MuiButtonGroup: {
|
||||
defaultProps: {
|
||||
disableRipple: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// ==============================|| OVERRIDES - CARD CONTENT ||============================== //
|
||||
|
||||
export default function CardContent() {
|
||||
return {
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 20,
|
||||
'&:last-child': {
|
||||
paddingBottom: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// ==============================|| OVERRIDES - DIALOG TITLE ||============================== //
|
||||
|
||||
export default function DialogTitle() {
|
||||
return {
|
||||
MuiDialogTitle: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: '1.0625rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// ==============================|| OVERRIDES - INPUT BASE ||============================== //
|
||||
|
||||
export default function InputBase() {
|
||||
return {
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
sizeSmall: {
|
||||
fontSize: '0.75rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// ==============================|| OVERRIDES - LINER PROGRESS ||============================== //
|
||||
|
||||
export default function LinearProgress() {
|
||||
return {
|
||||
MuiLinearProgress: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 6,
|
||||
borderRadius: 100
|
||||
},
|
||||
bar: {
|
||||
borderRadius: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// ==============================|| OVERRIDES - LINK ||============================== //
|
||||
|
||||
export default function Link() {
|
||||
return {
|
||||
MuiLink: {
|
||||
defaultProps: {
|
||||
underline: 'hover'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// ==============================|| OVERRIDES - LIST ITEM ICON ||============================== //
|
||||
|
||||
export default function ListItemIcon(theme) {
|
||||
return {
|
||||
MuiListItemIcon: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 24,
|
||||
color: theme.palette.text.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// ==============================|| OVERRIDES - PAGINATION ||============================== //
|
||||
|
||||
export default function Pagination() {
|
||||
return {
|
||||
MuiPagination: {
|
||||
defaultProps: {
|
||||
shape: 'rounded'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// ==============================|| OVERRIDES - DIALOG CONTENT TEXT ||============================== //
|
||||
|
||||
export default function Popover(theme) {
|
||||
return {
|
||||
MuiPopover: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
boxShadow: theme.customShadows.z1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// ==============================|| OVERRIDES - TABLE PAGINATION ||============================== //
|
||||
|
||||
export default function TablePagination() {
|
||||
return {
|
||||
MuiTablePagination: {
|
||||
styleOverrides: {
|
||||
selectLabel: {
|
||||
fontSize: '0.875rem'
|
||||
},
|
||||
displayedRows: {
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// ==============================|| OVERRIDES - TABS ||============================== //
|
||||
|
||||
export default function Tabs() {
|
||||
return {
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
vertical: {
|
||||
overflow: 'visible'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// ==============================|| OVERRIDES - TOOLTIP ||============================== //
|
||||
|
||||
export default function Tooltip(theme) {
|
||||
return {
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
color: theme.palette.background.paper
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// ==============================|| OVERRIDES - TREE ITEM ||============================== //
|
||||
|
||||
export default function TreeItem() {
|
||||
return {
|
||||
MuiTreeItem: {
|
||||
styleOverrides: {
|
||||
content: {
|
||||
padding: 8
|
||||
},
|
||||
iconContainer: {
|
||||
'& svg': {
|
||||
fontSize: '0.625rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// ==============================|| OVERRIDES - TYPOGRAPHY ||============================== //
|
||||
|
||||
export default function Typography() {
|
||||
return {
|
||||
MuiTypography: {
|
||||
styleOverrides: {
|
||||
gutterBottom: {
|
||||
marginBottom: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user