changes regarding the mui to astryx tempalate based design
This commit is contained in:
1508
package-lock.json
generated
1508
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,14 +10,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@astryxdesign/cli": "^0.1.8",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@astryxdesign/core": "^0.1.8",
|
||||||
"@mui/icons-material": "^9.1.1",
|
"@astryxdesign/theme-neutral": "^0.1.8",
|
||||||
"@mui/material": "^9.1.1",
|
"@stylexjs/stylex": "^0.19.0",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.3.1",
|
"lucide-react": "^1.25.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4",
|
"react-router-dom": "^6.30.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
|
|||||||
BIN
public/navbarLogo.png
Normal file
BIN
public/navbarLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
11
src/App.jsx
11
src/App.jsx
@@ -1,7 +1,5 @@
|
|||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Box, CircularProgress } from '@mui/material';
|
|
||||||
|
|
||||||
import MainLayout from '@/layout/MainLayout';
|
import MainLayout from '@/layout/MainLayout';
|
||||||
import MinimalLayout from '@/layout/MinimalLayout';
|
import MinimalLayout from '@/layout/MinimalLayout';
|
||||||
import ProtectedRoute from '@/auth/ProtectedRoute';
|
import ProtectedRoute from '@/auth/ProtectedRoute';
|
||||||
@@ -11,9 +9,10 @@ const load = (factory) => {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||||
<CircularProgress color="primary" />
|
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||||
</Box>
|
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<C />
|
<C />
|
||||||
@@ -34,6 +33,8 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
||||||
<Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} />
|
<Route path="/inbound" element={load(() => import('@/pages/operations/Inbound'))} />
|
||||||
|
{/* TEMPORARILY DISABLED: These pages still contain MUI imports which crashes Vite.
|
||||||
|
They will be re-enabled once they are migrated to Astryx. */}
|
||||||
<Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} />
|
<Route path="/routing" element={load(() => import('@/pages/operations/Routing'))} />
|
||||||
<Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} />
|
<Route path="/dispatch" element={load(() => import('@/pages/operations/Dispatch'))} />
|
||||||
<Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} />
|
<Route path="/tracking" element={load(() => import('@/pages/operations/TrackingMap'))} />
|
||||||
|
|||||||
16
src/components/Button.jsx
Normal file
16
src/components/Button.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Button as AstryxButton } from '@astryxdesign/core/Button';
|
||||||
|
|
||||||
|
// Wraps Astryx's Button so `variant="primary"` (the default) renders in the
|
||||||
|
// Doormile brand red instead of the theme's neutral accent — primary CTAs
|
||||||
|
// stand out as "the one thing to click" while every other accent-driven
|
||||||
|
// control (focus rings, selected nav, ghost/outline buttons) keeps the
|
||||||
|
// theme's neutral dark. Scoped via a CSS custom property on this element only,
|
||||||
|
// not a global token flip, so nothing else on the page is affected.
|
||||||
|
export default function Button({ variant = 'primary', style, ...props }) {
|
||||||
|
const brandStyle =
|
||||||
|
variant === 'primary'
|
||||||
|
? { '--color-accent': 'var(--color-brand)', '--color-on-accent': 'var(--color-on-brand)' }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return <AstryxButton variant={variant} style={brandStyle ? { ...brandStyle, ...style } : style} {...props} />;
|
||||||
|
}
|
||||||
@@ -1,41 +1,30 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Box, Stack, Typography, IconButton, Button, Popover, Divider, Chip, useMediaQuery } from '@mui/material';
|
|
||||||
import { alpha } from '@mui/material/styles';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||||
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
|
|
||||||
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
|
||||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
import Button from '@/components/Button';
|
||||||
// Shared date-range picker — the same two-month brand calendar used on the
|
|
||||||
// Dashboard, packaged for reuse. Controlled: pass `value={{ from, to }}` (both
|
|
||||||
// 'YYYY-MM-DD') and an `onChange({ from, to })` handler. Optionally cap the
|
|
||||||
// selectable range with `maxDate` (defaults to today, so no future dates).
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const DATE_FMT = 'YYYY-MM-DD';
|
export const DATE_FMT = 'YYYY-MM-DD';
|
||||||
const BRAND = '#C01227';
|
const BRAND = 'var(--color-brand)';
|
||||||
const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||||
|
|
||||||
// A single compact month grid.
|
|
||||||
function MonthGrid({ view, start, end, max, onDay }) {
|
function MonthGrid({ view, start, end, max, onDay }) {
|
||||||
const gridStart = view.startOf('month').subtract(view.startOf('month').day(), 'day');
|
const gridStart = view.startOf('month').subtract(view.startOf('month').day(), 'day');
|
||||||
const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
|
const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: 224 }}>
|
<div style={{ width: 224 }}>
|
||||||
<Typography align="center" sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#212529', mb: 0.75 }}>
|
<div style={{ textAlign: 'center', fontWeight: 700, fontSize: '0.82rem', color: '#212529', marginBottom: '6px' }}>
|
||||||
{view.format('MMMM YYYY')}
|
{view.format('MMMM YYYY')}
|
||||||
</Typography>
|
</div>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', mb: 0.25 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: '2px' }}>
|
||||||
{DAY_LABELS.map((d, i) => (
|
{DAY_LABELS.map((d, i) => (
|
||||||
<Typography key={i} align="center" sx={{ fontSize: '0.62rem', fontWeight: 600, color: '#B0B5BA', py: 0.25 }}>
|
<div key={i} style={{ textAlign: 'center', fontSize: '0.62rem', fontWeight: 600, color: '#B0B5BA', padding: '2px 0' }}>
|
||||||
{d}
|
{d}
|
||||||
</Typography>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</div>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
||||||
{cells.map((d) => {
|
{cells.map((d) => {
|
||||||
const inMonth = d.month() === view.month();
|
const inMonth = d.month() === view.month();
|
||||||
const isStart = start && d.isSame(start, 'day');
|
const isStart = start && d.isSame(start, 'day');
|
||||||
@@ -44,37 +33,40 @@ function MonthGrid({ view, start, end, max, onDay }) {
|
|||||||
const inRange = start && end && d.isAfter(start, 'day') && d.isBefore(end, 'day');
|
const inRange = start && end && d.isAfter(start, 'day') && d.isBefore(end, 'day');
|
||||||
const isToday = d.isSame(dayjs(), 'day');
|
const isToday = d.isSame(dayjs(), 'day');
|
||||||
const disabled = max && d.isAfter(max, 'day');
|
const disabled = max && d.isAfter(max, 'day');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={d.format(DATE_FMT)} sx={{ display: 'flex', justifyContent: 'center', bgcolor: inRange ? alpha(BRAND, 0.07) : 'transparent',
|
<div key={d.format(DATE_FMT)} style={{
|
||||||
|
display: 'flex', justifyContent: 'center',
|
||||||
|
backgroundColor: inRange ? 'var(--color-brand-muted)' : 'transparent',
|
||||||
borderTopLeftRadius: isStart ? 6 : 0, borderBottomLeftRadius: isStart ? 6 : 0,
|
borderTopLeftRadius: isStart ? 6 : 0, borderBottomLeftRadius: isStart ? 6 : 0,
|
||||||
borderTopRightRadius: isEnd ? 6 : 0, borderBottomRightRadius: isEnd ? 6 : 0 }}>
|
borderTopRightRadius: isEnd ? 6 : 0, borderBottomRightRadius: isEnd ? 6 : 0
|
||||||
<Box
|
}}>
|
||||||
component="button"
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onDay(d)}
|
onClick={() => onDay(d)}
|
||||||
sx={{
|
style={{
|
||||||
width: 28, height: 28, m: '1px', border: 'none', cursor: disabled ? 'default' : 'pointer',
|
width: 28, height: 28, margin: '1px', border: 'none', cursor: disabled ? 'default' : 'pointer',
|
||||||
borderRadius: 1.5, fontSize: '0.72rem', fontFamily: 'inherit',
|
borderRadius: 6, fontSize: '0.72rem', fontFamily: 'inherit',
|
||||||
fontWeight: isEndpoint ? 700 : 500,
|
fontWeight: isEndpoint ? 700 : 500,
|
||||||
color: disabled ? '#D5D9DD' : isEndpoint ? '#fff' : inMonth ? '#3C4043' : '#C4C9CE',
|
color: disabled ? '#D5D9DD' : isEndpoint ? '#fff' : inMonth ? '#3C4043' : '#C4C9CE',
|
||||||
bgcolor: isEndpoint ? BRAND : 'transparent',
|
backgroundColor: isEndpoint ? BRAND : 'transparent',
|
||||||
boxShadow: isToday && !isEndpoint ? `inset 0 0 0 1.5px ${BRAND}` : 'none',
|
boxShadow: isToday && !isEndpoint ? `inset 0 0 0 1.5px ${BRAND}` : 'none',
|
||||||
transition: 'background-color .12s',
|
transition: 'background-color .12s',
|
||||||
'&:hover': { bgcolor: disabled ? 'transparent' : isEndpoint ? '#9E0E20' : alpha(BRAND, 0.1) }
|
|
||||||
}}
|
}}
|
||||||
|
onMouseOver={(e) => { if(!disabled && !isEndpoint) e.target.style.backgroundColor = 'var(--color-brand-muted)'; }}
|
||||||
|
onMouseOut={(e) => { if(!disabled && !isEndpoint) e.target.style.backgroundColor = 'transparent'; }}
|
||||||
>
|
>
|
||||||
{d.date()}
|
{d.date()}
|
||||||
</Box>
|
</button>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click a day to set the start, click again to set the end (auto-swaps if reversed).
|
|
||||||
function RangeCalendar({ from, to, maxDate, onSelect }) {
|
function RangeCalendar({ from, to, maxDate, onSelect }) {
|
||||||
const [view, setView] = useState(dayjs(to || from || undefined).startOf('month'));
|
const [view, setView] = useState(dayjs(to || from || undefined).startOf('month'));
|
||||||
const [anchorDate, setAnchorDate] = useState(null);
|
const [anchorDate, setAnchorDate] = useState(null);
|
||||||
@@ -97,22 +89,27 @@ function RangeCalendar({ from, to, maxDate, onSelect }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 1.5, py: 1.5 }}>
|
<div style={{ padding: '12px' }}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
<IconButton size="small" onClick={() => setView((v) => v.subtract(1, 'month'))} sx={{ color: '#9AA0A6', p: 0.5 }}>
|
<button onClick={() => setView((v) => v.subtract(1, 'month'))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9AA0A6' }}>
|
||||||
<ChevronLeftRoundedIcon fontSize="small" />
|
<ChevronLeft size={16} />
|
||||||
</IconButton>
|
</button>
|
||||||
<IconButton size="small" onClick={() => setView((v) => v.add(1, 'month'))} sx={{ color: '#9AA0A6', p: 0.5 }}>
|
<button onClick={() => setView((v) => v.add(1, 'month'))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9AA0A6' }}>
|
||||||
<ChevronRightRoundedIcon fontSize="small" />
|
<ChevronRight size={16} />
|
||||||
</IconButton>
|
</button>
|
||||||
</Stack>
|
</div>
|
||||||
<Stack direction="row" spacing={2}>
|
<div style={{ display: 'flex', gap: '16px' }}>
|
||||||
<MonthGrid view={view} start={start} end={end} max={max} onDay={handleDay} />
|
<MonthGrid view={view} start={start} end={end} max={max} onDay={handleDay} />
|
||||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
|
<div className="hide-on-mobile">
|
||||||
<MonthGrid view={view.add(1, 'month')} start={start} end={end} max={max} onDay={handleDay} />
|
<MonthGrid view={view.add(1, 'month')} start={start} end={end} max={max} onDay={handleDay} />
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
<style>{`
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hide-on-mobile { display: none; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +122,7 @@ const PRESETS = [
|
|||||||
export default function DateRangePicker({ value, onChange, maxDate }) {
|
export default function DateRangePicker({ value, onChange, maxDate }) {
|
||||||
const today = dayjs().format(DATE_FMT);
|
const today = dayjs().format(DATE_FMT);
|
||||||
const max = maxDate ?? today;
|
const max = maxDate ?? today;
|
||||||
const [anchor, setAnchor] = useState(null);
|
const [open, setOpen] = useState(false);
|
||||||
const isMobile = useMediaQuery('(max-width:600px)');
|
|
||||||
|
|
||||||
const from = value?.from || today;
|
const from = value?.from || today;
|
||||||
const to = value?.to || today;
|
const to = value?.to || today;
|
||||||
@@ -143,72 +139,70 @@ export default function DateRangePicker({ value, onChange, maxDate }) {
|
|||||||
const isPreset = (days) => to === today && from === dayjs().subtract(days - 1, 'day').format(DATE_FMT);
|
const isPreset = (days) => to === today && from === dayjs().subtract(days - 1, 'day').format(DATE_FMT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div style={{ position: 'relative' }}>
|
||||||
<Stack direction="row" spacing={1} alignItems="center" useFlexGap sx={{ flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{PRESETS.map((p) => {
|
{PRESETS.map((p) => {
|
||||||
const active = isPreset(p.days);
|
const active = isPreset(p.days);
|
||||||
return (
|
return (
|
||||||
<Chip
|
<button
|
||||||
key={p.label}
|
key={p.label}
|
||||||
label={p.label}
|
|
||||||
onClick={() => applyPreset(p.days)}
|
onClick={() => applyPreset(p.days)}
|
||||||
sx={{
|
style={{
|
||||||
height: 36, fontSize: '0.825rem', fontWeight: 600, borderRadius: 2,
|
height: 36, fontSize: '0.825rem', fontWeight: 600, borderRadius: 8, padding: '0 12px',
|
||||||
bgcolor: active ? BRAND : '#F1F5F9', color: active ? '#fff' : '#475569',
|
backgroundColor: active ? BRAND : '#F1F5F9', color: active ? '#fff' : '#475569',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
boxShadow: active ? '0 4px 10px rgba(192,18,39,0.15)' : 'none',
|
boxShadow: active ? '0 4px 10px rgba(192,18,39,0.15)' : 'none',
|
||||||
'& .MuiChip-label': { px: 1.5 },
|
|
||||||
'&:hover': { bgcolor: active ? '#9E0E20' : '#E2E8F0' }
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={(e) => setAnchor(e.currentTarget)}
|
onClick={() => setOpen(!open)}
|
||||||
variant="outlined"
|
variant="outline"
|
||||||
startIcon={<CalendarTodayOutlinedIcon sx={{ fontSize: 16 }} />}
|
style={{
|
||||||
sx={{
|
height: 36, padding: '0 12px', borderRadius: 8, fontWeight: 600, fontSize: '0.825rem',
|
||||||
height: 36, px: 1.5, borderRadius: 2, textTransform: 'none', fontWeight: 600, fontSize: '0.825rem',
|
color: '#334155', borderColor: invalid ? '#EF4444' : '#E2E8F0', backgroundColor: '#fff',
|
||||||
color: '#334155', borderColor: invalid ? '#EF4444' : '#E2E8F0', bgcolor: '#fff',
|
justifyContent: 'flex-start'
|
||||||
justifyContent: 'flex-start', minWidth: { sm: 210 },
|
|
||||||
'&:hover': { borderColor: BRAND, bgcolor: alpha(BRAND, 0.02) }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ color: '#334155' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#334155' }}>
|
||||||
<Box component="span">{dayjs(from).format('DD MMM YYYY')}</Box>
|
<CalendarIcon size={16} />
|
||||||
<ArrowRightAltRoundedIcon sx={{ fontSize: 16, color: '#94A3B8' }} />
|
<span>{dayjs(from).format('DD MMM YYYY')}</span>
|
||||||
<Box component="span">{dayjs(to).format('DD MMM YYYY')}</Box>
|
<ArrowRight size={16} color="#94A3B8" />
|
||||||
</Stack>
|
<span>{dayjs(to).format('DD MMM YYYY')}</span>
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
<Popover
|
{open && (
|
||||||
open={Boolean(anchor)}
|
<>
|
||||||
anchorEl={anchor}
|
<div style={{ position: 'fixed', inset: 0, zIndex: 999 }} onClick={() => setOpen(false)} />
|
||||||
onClose={() => setAnchor(null)}
|
<div style={{
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: isMobile ? 'left' : 'right' }}
|
position: 'absolute', top: '100%', right: 0, marginTop: '8px', zIndex: 1000,
|
||||||
transformOrigin={{ vertical: 'top', horizontal: isMobile ? 'left' : 'right' }}
|
backgroundColor: '#fff', borderRadius: '12px', border: '1px solid #EEF0F2',
|
||||||
marginThreshold={12}
|
boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden'
|
||||||
PaperProps={{ sx: { mt: 1, borderRadius: 2.5, border: '1px solid #EEF0F2', boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden', maxWidth: 'calc(100vw - 24px)' } }}
|
}}>
|
||||||
>
|
<div style={{ padding: '16px 16px 4px 16px' }}>
|
||||||
<Box sx={{ px: 2, pt: 1.75, pb: 0.5 }}>
|
<div style={{ fontWeight: 700, fontSize: '0.9rem', color: '#212529' }}>Select date range</div>
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9rem', color: '#212529' }}>Select date range</Typography>
|
<div style={{ fontSize: '0.75rem', color: '#8A9099' }}>
|
||||||
<Typography variant="caption" sx={{ color: '#8A9099' }}>
|
{dayjs(from).format('DD MMM')} – {dayjs(to).format('DD MMM YYYY')} · {dayCount} {dayCount === 1 ? 'day' : 'days'}
|
||||||
{dayjs(from).format('DD MMM')} – {dayjs(to).format('DD MMM YYYY')} · {dayCount} {dayCount === 1 ? 'day' : 'days'}
|
</div>
|
||||||
</Typography>
|
</div>
|
||||||
</Box>
|
<RangeCalendar from={from} to={to} maxDate={max} onSelect={(f, t) => onChange({ from: f, to: t })} />
|
||||||
<RangeCalendar from={from} to={to} maxDate={max} onSelect={(f, t) => onChange({ from: f, to: t })} />
|
<div style={{ borderTop: '1px solid #EEF0F2', padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Divider />
|
<button onClick={() => applyPreset(1)} style={{ background: 'none', border: 'none', color: '#5F6368', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
Reset to today
|
||||||
<Button size="small" onClick={() => applyPreset(1)} sx={{ textTransform: 'none', color: '#5F6368', fontWeight: 700 }}>
|
</button>
|
||||||
Reset to today
|
<Button variant="primary" onClick={() => setOpen(false)}>
|
||||||
</Button>
|
Done
|
||||||
<Button size="small" variant="contained" onClick={() => setAnchor(null)}
|
</Button>
|
||||||
sx={{ textTransform: 'none', fontWeight: 700, bgcolor: BRAND, borderRadius: 2, '&:hover': { bgcolor: '#9E0E20' } }}>
|
</div>
|
||||||
Done
|
</div>
|
||||||
</Button>
|
</>
|
||||||
</Stack>
|
)}
|
||||||
</Popover>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,67 @@
|
|||||||
import { Box, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
||||||
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
|
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
|
||||||
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
|
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
|
||||||
// collapsed sidebar) renders just the square "D" badge, since the wordmark won't fit.
|
// collapsed navbar rail) pairs the round navbar mark with a "Doormile" text
|
||||||
|
// wordmark instead, since the full wordmark image is too wide to fit there.
|
||||||
|
|
||||||
const LOGO_SRC = '/Doormile-logo.png';
|
const LOGO_SRC = '/Doormile-logo.png';
|
||||||
|
const NAVBAR_MARK_SRC = '/navbarLogo.png';
|
||||||
|
|
||||||
export default function Logo({ onDark = false, compact = false, height = 26, sx }) {
|
export default function Logo({ onDark = false, compact = false, height = 26, size = 64, style = {} }) {
|
||||||
if (compact) {
|
if (compact) {
|
||||||
const mark = onDark ? '#FFFFFF' : '#C01227';
|
|
||||||
const markText = onDark ? '#C01227' : '#FFFFFF';
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
<div
|
||||||
<Box
|
style={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
width: 34,
|
alignItems: 'center',
|
||||||
height: 34,
|
gap: 9,
|
||||||
borderRadius: 2,
|
// Nudges the mark so its optical centre lines up with the collapsed
|
||||||
bgcolor: mark,
|
// sidebar's icon column directly beneath it (measured ~2px apart —
|
||||||
display: 'flex',
|
// the two live in separate layout trees with their own padding, so
|
||||||
alignItems: 'center',
|
// this small correction keeps them reading as one straight column).
|
||||||
justifyContent: 'center',
|
marginInlineStart: 2,
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={NAVBAR_MARK_SRC}
|
||||||
|
// Adjacent text already announces the brand name, so the mark stays
|
||||||
|
// decorative here rather than making screen readers say it twice.
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'cover',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39, 0.30)'
|
// Matches the mark's original standalone (pre-wordmark) visual
|
||||||
|
// size exactly — scaling only the icon, not the row, keeps the
|
||||||
|
// new "Doormile" text at its own natural size beside it.
|
||||||
|
transform: 'scale(1.4)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: onDark ? '#ffffff' : '#0A1317'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography>
|
Doormile
|
||||||
</Box>
|
</span>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
<div style={{ display: 'flex', alignItems: 'center', ...style }}>
|
||||||
<Box
|
<img
|
||||||
component="img"
|
|
||||||
src={LOGO_SRC}
|
src={LOGO_SRC}
|
||||||
alt="Doormile"
|
alt="Doormile"
|
||||||
sx={{
|
style={{
|
||||||
height,
|
height,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@@ -46,6 +69,6 @@ export default function Logo({ onDark = false, compact = false, height = 26, sx
|
|||||||
filter: 'brightness(0) saturate(100%) invert(15%) sepia(88%) saturate(5900%) hue-rotate(352deg) brightness(86%) contrast(92%)'
|
filter: 'brightness(0) saturate(100%) invert(15%) sepia(88%) saturate(5900%) hue-rotate(352deg) brightness(86%) contrast(92%)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,51 @@
|
|||||||
import { Box, Stack, Typography, Avatar } from '@mui/material';
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
|
|
||||||
// ==============================|| SHARED PAGE HEADER ||============================== //
|
|
||||||
|
|
||||||
export default function PageHeader({ icon: Icon, title, subtitle, action }) {
|
export default function PageHeader({ icon: Icon, title, subtitle, action }) {
|
||||||
return (
|
return (
|
||||||
<Stack
|
<div
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
style={{
|
||||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
display: 'flex',
|
||||||
justifyContent="space-between"
|
flexDirection: 'row',
|
||||||
gap={3}
|
alignItems: 'center',
|
||||||
sx={{ mb: { xs: 4, md: 4.5 } }}
|
justifyContent: 'space-between',
|
||||||
|
gap: '20px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', minWidth: 0 }}>
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={2.5} // Space between icon and title
|
|
||||||
sx={{ minWidth: 0 }}
|
|
||||||
>
|
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<Avatar
|
<div
|
||||||
variant="rounded"
|
style={{
|
||||||
sx={{
|
backgroundColor: 'var(--color-brand-muted)',
|
||||||
bgcolor: 'primary.lighter',
|
color: 'var(--color-brand)',
|
||||||
color: 'primary.main',
|
width: 44,
|
||||||
width: 52,
|
height: 44,
|
||||||
height: 52,
|
borderRadius: 'var(--radius-element)',
|
||||||
borderRadius: 2.5,
|
flexShrink: 0,
|
||||||
flexShrink: 0
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon sx={{ fontSize: 28 }} />
|
<Icon size={22} />
|
||||||
</Avatar>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Typography
|
<Heading level={1} type="display-3">
|
||||||
variant="h4"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 800,
|
|
||||||
color: 'text.primary',
|
|
||||||
letterSpacing: '-0.5px',
|
|
||||||
lineHeight: 1.15,
|
|
||||||
fontSize: {
|
|
||||||
xs: '1.5rem',
|
|
||||||
sm: '1.8rem',
|
|
||||||
md: '2.1rem'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Heading>
|
||||||
|
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Typography
|
<Text type="body" color="secondary" style={{ display: 'block', marginTop: '4px', maxWidth: 700 }}>
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
maxWidth: 700,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
fontSize: {
|
|
||||||
xs: '0.9rem',
|
|
||||||
sm: '0.95rem'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Typography>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
{action && (
|
{action && <div style={{ flexShrink: 0 }}>{action}</div>}
|
||||||
<Box sx={{ flexShrink: 0 }}>
|
</div>
|
||||||
{action}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/components/Panel.jsx
Normal file
33
src/components/Panel.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Heading } from '@astryxdesign/core/Text';
|
||||||
|
|
||||||
|
// Shared "section card": an optional titled header strip over a body slot.
|
||||||
|
// Every page that shows a titled block of content (tables, forms, feeds)
|
||||||
|
// renders it through this so cards share one radius/border/shadow/header
|
||||||
|
// treatment instead of each page re-declaring its own — that drift (16px vs
|
||||||
|
// 12px radius, different border colors) was the main source of visual
|
||||||
|
// inconsistency between screens.
|
||||||
|
export default function Panel({ title, action, children, bodyPadding = 0, style }) {
|
||||||
|
return (
|
||||||
|
<Card padding={0} style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-background-muted)',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Heading level={5}>{title}</Heading>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: bodyPadding, flexGrow: 1, boxSizing: 'border-box', minWidth: 0 }}>{children}</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,70 +1,147 @@
|
|||||||
import { Card, CardContent, Stack, Avatar, Typography, Skeleton, Box } from '@mui/material';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import TrendingUpOutlinedIcon from '@mui/icons-material/TrendingUpOutlined';
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ==============================|| STAT / KPI CARD ||============================== //
|
||||||
// Shared KPI / stat card — the single reference design used across every page so
|
// `tone` maps to Astryx's own categorical tokens (same palette Badge/StatusDot
|
||||||
// the stat strips read as one system:
|
// use), so KPI cards read as part of the same status language as the rest of
|
||||||
// • icon tile + uppercase label on a top row, label centered on the icon's height
|
// the app instead of a one-off set of hand-picked hex values.
|
||||||
// • big number centered on the icon's vertical axis (a 40px column under the icon)
|
|
||||||
// • optional caption below (with an optional green trend arrow)
|
const TONE_VARS = {
|
||||||
// `bg` defaults to a soft tint of `color`. `hover` adds the lift-on-hover effect.
|
blue: { icon: 'var(--color-icon-blue)', bg: 'var(--color-background-blue)' },
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
green: { icon: 'var(--color-icon-green)', bg: 'var(--color-background-green)' },
|
||||||
|
orange: { icon: 'var(--color-icon-orange)', bg: 'var(--color-background-orange)' },
|
||||||
|
purple: { icon: 'var(--color-icon-purple)', bg: 'var(--color-background-purple)' },
|
||||||
|
red: { icon: 'var(--color-icon-red)', bg: 'var(--color-background-red)' },
|
||||||
|
teal: { icon: 'var(--color-icon-teal)', bg: 'var(--color-background-teal)' },
|
||||||
|
cyan: { icon: 'var(--color-icon-cyan)', bg: 'var(--color-background-cyan)' },
|
||||||
|
gray: { icon: 'var(--color-icon-gray)', bg: 'var(--color-background-gray)' }
|
||||||
|
};
|
||||||
|
|
||||||
export default function StatCard({
|
export default function StatCard({
|
||||||
icon: Icon,
|
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
tone = 'blue',
|
||||||
sub,
|
sub,
|
||||||
color = '#1A73E8',
|
size = 'md',
|
||||||
bg,
|
|
||||||
trend = false,
|
|
||||||
loading = false,
|
loading = false,
|
||||||
hover = false
|
hover = true
|
||||||
}) {
|
}) {
|
||||||
|
const isCompact = size === 'sm';
|
||||||
|
const { icon: iconColor, bg: tintBg } = TONE_VARS[tone] || TONE_VARS.blue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
padding={0}
|
||||||
sx={{
|
className={hover ? 'stat-card' : undefined}
|
||||||
borderRadius: 2,
|
style={{
|
||||||
border: '1px solid #ECEEF1',
|
|
||||||
height: '100%',
|
height: '100%',
|
||||||
boxShadow: '0px 2px 14px rgba(38,38,38,0.03)',
|
position: 'relative',
|
||||||
transition: 'all .2s',
|
overflow: 'hidden',
|
||||||
...(hover && { '&:hover': { boxShadow: '0 10px 30px rgba(0,0,0,0.08)', transform: 'translateY(-2px)' } })
|
padding: isCompact ? '16px' : '20px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
cursor: hover ? 'pointer' : 'default'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
|
{Icon && (
|
||||||
{/* Icon + label on a clean top row; label stretches to the icon height and
|
<div
|
||||||
centers its text so the caption is optically centered against the icon. */}
|
style={{
|
||||||
<Stack direction="row" alignItems="center" spacing={1.75} sx={{ mb: 1.5 }}>
|
position: 'absolute',
|
||||||
<Avatar variant="rounded" sx={{ bgcolor: bg || color + '15', color, width: 40, height: 40, borderRadius: 2, flexShrink: 0 }}>
|
right: '-12px',
|
||||||
{Icon && <Icon sx={{ fontSize: 21 }} />}
|
bottom: '-12px',
|
||||||
</Avatar>
|
opacity: 0.05,
|
||||||
<Typography sx={{ display: 'flex', alignItems: 'center', alignSelf: 'stretch', fontSize: '0.72rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1.2 }}>
|
transform: 'rotate(-15deg)',
|
||||||
{label}
|
pointerEvents: 'none',
|
||||||
</Typography>
|
color: iconColor
|
||||||
</Stack>
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={isCompact ? 60 : 88} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Value sits under the icon: a box that is the icon's width (40px) so a short
|
<div
|
||||||
number centers on the icon's axis, but grows with wider values ("0.0 km",
|
style={{
|
||||||
"₹1,234") so they left-align under the icon instead of overflowing the card. */}
|
display: 'flex',
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', width: 'fit-content', minWidth: 40, mb: sub ? 0.5 : 0 }}>
|
justifyContent: 'space-between',
|
||||||
{loading ? (
|
alignItems: 'flex-start',
|
||||||
<Skeleton variant="text" width={28} sx={{ fontSize: '1.6rem' }} />
|
marginBottom: isCompact ? '10px' : '14px',
|
||||||
) : (
|
position: 'relative',
|
||||||
<Typography sx={{ fontSize: '1.6rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, whiteSpace: 'nowrap' }}>
|
zIndex: 2
|
||||||
{value}
|
}}
|
||||||
</Typography>
|
>
|
||||||
)}
|
<span
|
||||||
</Box>
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
{sub && (
|
fontSize: '0.72rem',
|
||||||
<Stack direction="row" alignItems="center" spacing={0.75}>
|
color: 'var(--color-text-secondary)',
|
||||||
{trend && <TrendingUpOutlinedIcon sx={{ fontSize: 15, color: '#1E8E3E', flexShrink: 0 }} />}
|
textTransform: 'uppercase',
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 500 }}>{sub}</Typography>
|
letterSpacing: '0.05em'
|
||||||
</Stack>
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: isCompact ? '26px' : '34px',
|
||||||
|
height: isCompact ? '26px' : '34px',
|
||||||
|
borderRadius: 'var(--radius-inner)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: tintBg,
|
||||||
|
color: iconColor,
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={isCompact ? 14 : 17} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, marginBottom: sub ? (isCompact ? '8px' : '12px') : 0 }}>
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '60px',
|
||||||
|
height: isCompact ? '22px' : '30px',
|
||||||
|
backgroundColor: 'var(--color-skeleton)',
|
||||||
|
borderRadius: 4,
|
||||||
|
animation: 'pulse 1.5s infinite'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: isCompact ? '1.4rem' : '1.85rem',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
lineHeight: 1,
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sub && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: isCompact ? '8px' : '10px',
|
||||||
|
borderTop: '1px dashed var(--color-border)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.72rem', color: 'var(--color-text-secondary)', fontWeight: 600 }}>{sub}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.stat-card { transition: box-shadow 0.2s ease, transform 0.2s ease; }
|
||||||
|
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-med); }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
`}</style>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/index.css
Normal file
112
src/index.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/* ─── Force light mode + brand color: override every Astryx light-dark() token ─── */
|
||||||
|
:root,
|
||||||
|
[data-astryx-theme] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* Surface / background tokens */
|
||||||
|
--color-background-surface: #ffffff;
|
||||||
|
--color-background-body: #f1f4f7;
|
||||||
|
--color-background-card: #ffffff;
|
||||||
|
--color-background-popover: #ffffff;
|
||||||
|
--color-background-muted: rgba(5, 54, 89, 0.047);
|
||||||
|
--color-background-inverted: #0A1317;
|
||||||
|
--color-background-error-inverted: #AA071E;
|
||||||
|
|
||||||
|
/* Text tokens */
|
||||||
|
--color-text-primary: #0A1317;
|
||||||
|
--color-text-secondary: #4E606F;
|
||||||
|
--color-text-disabled: #A4B0BC;
|
||||||
|
--color-text-accent: #0A1317;
|
||||||
|
|
||||||
|
/* Icon tokens */
|
||||||
|
--color-icon-primary: #0A1317;
|
||||||
|
--color-icon-secondary: #4E606F;
|
||||||
|
--color-icon-disabled: #A4B0BC;
|
||||||
|
--color-icon-accent: #0A1317;
|
||||||
|
|
||||||
|
/* Border tokens */
|
||||||
|
--color-border: rgba(5, 54, 89, 0.1);
|
||||||
|
--color-border-emphasized: #CCD3DB;
|
||||||
|
|
||||||
|
/* Interactive overlay tokens */
|
||||||
|
--color-neutral: rgba(5, 54, 89, 0.1);
|
||||||
|
--color-overlay: rgba(1, 18, 40, 0.4);
|
||||||
|
--color-overlay-hover: rgba(5, 54, 89, 0.047);
|
||||||
|
--color-overlay-pressed: rgba(5, 54, 89, 0.098);
|
||||||
|
|
||||||
|
/* Misc */
|
||||||
|
--color-skeleton: #CCD3DB;
|
||||||
|
--color-track: #CCD3DB;
|
||||||
|
--color-shadow: rgba(5, 54, 89, 0.1);
|
||||||
|
|
||||||
|
/* Structural accent (nav selection, focus rings, secondary controls) — stays neutral/dark */
|
||||||
|
--color-accent: #0A1317;
|
||||||
|
--color-accent-muted: rgba(10, 19, 23, 0.12);
|
||||||
|
--color-on-accent: #ffffff;
|
||||||
|
|
||||||
|
/* Doormile brand red — reserved for primary CTAs only. Do not use for
|
||||||
|
structural chrome (nav, focus rings, secondary buttons); use
|
||||||
|
src/components/Button.jsx (variant="primary") to apply it consistently. */
|
||||||
|
--color-brand: #C01227;
|
||||||
|
--color-brand-hover: #A20F20;
|
||||||
|
--color-brand-muted: rgba(192, 18, 39, 0.1);
|
||||||
|
--color-on-brand: #ffffff;
|
||||||
|
|
||||||
|
/* "red" Badge variant */
|
||||||
|
--color-background-red: rgba(5, 54, 89, 0.1);
|
||||||
|
--color-text-red: #0A1317;
|
||||||
|
|
||||||
|
/* App-level overrides */
|
||||||
|
background-color: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
font-family: var(--font-family-body, 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font-family: inherit !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder, textarea::placeholder {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not(.search-bar-input):not([class]), select:not([class]), textarea:not([class]) {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
border: 1px solid rgba(5, 54, 89, 0.12) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
outline: none !important;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not(.search-bar-input):not([class]), select:not([class]) {
|
||||||
|
height: 38px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not(.search-bar-input):not([class]):focus, select:not([class]):focus, textarea:not([class]):focus {
|
||||||
|
border-color: #0A1317 !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(10, 19, 23, 0.12) !important;
|
||||||
|
}
|
||||||
@@ -1,43 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Settings, LogOut, Bell, MessageSquare, Search, Truck, AlertTriangle, ArrowRight, Send, CheckCircle2 } from 'lucide-react';
|
||||||
AppBar,
|
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
|
||||||
Toolbar,
|
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||||
IconButton,
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
Box,
|
import { Avatar } from '@astryxdesign/core/Avatar';
|
||||||
InputBase,
|
|
||||||
Badge,
|
|
||||||
Avatar,
|
|
||||||
Typography,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Divider,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Tooltip,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
TextField,
|
|
||||||
Grid,
|
|
||||||
alpha,
|
|
||||||
InputAdornment
|
|
||||||
} from '@mui/material';
|
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
|
||||||
import ChatIcon from '@mui/icons-material/Chat';
|
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
|
||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
|
||||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
|
||||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
|
||||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
|
||||||
|
|
||||||
|
import Button from '@/components/Button';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { getStaff, getHubContext, clearSession } from '@/auth/session';
|
import { getStaff, getHubContext, clearSession } from '@/auth/session';
|
||||||
import {
|
import {
|
||||||
@@ -49,18 +18,16 @@ import {
|
|||||||
markConversationRead
|
markConversationRead
|
||||||
} from '@/api/hub';
|
} from '@/api/hub';
|
||||||
|
|
||||||
const RED = '#C01227';
|
const RED = 'var(--color-brand)';
|
||||||
|
|
||||||
// Map a notification `type` to an icon component (real API sends type, not an icon).
|
|
||||||
const NOTIF_ICON = {
|
const NOTIF_ICON = {
|
||||||
exception: WarningAmberIcon,
|
exception: AlertTriangle,
|
||||||
inbound: LocalShippingOutlinedIcon,
|
inbound: Truck,
|
||||||
dispatch: LocalShippingOutlinedIcon,
|
dispatch: Truck,
|
||||||
warning: WarningAmberIcon,
|
warning: AlertTriangle,
|
||||||
alert: NotificationsActiveIcon
|
alert: Bell
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build initials from a display name — falls back to a sensible default.
|
|
||||||
const toInitials = (name) =>
|
const toInitials = (name) =>
|
||||||
(name || '')
|
(name || '')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -70,8 +37,6 @@ const toInitials = (name) =>
|
|||||||
.join('')
|
.join('')
|
||||||
.toUpperCase() || 'HB';
|
.toUpperCase() || 'HB';
|
||||||
|
|
||||||
// Normalise a conversation summary from GET /hub/messages. Be tolerant of a few
|
|
||||||
// backend field-name variants so the list still renders if a key is named differently.
|
|
||||||
const toConversation = (c) => {
|
const toConversation = (c) => {
|
||||||
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
|
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
|
||||||
return {
|
return {
|
||||||
@@ -84,14 +49,13 @@ const toConversation = (c) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalise one message in a thread from GET /hub/messages/:id.
|
|
||||||
const toMessage = (m) => ({
|
const toMessage = (m) => ({
|
||||||
sender: m.sender === 'me' ? 'me' : 'them',
|
sender: m.sender === 'me' ? 'me' : 'them',
|
||||||
text: m.text ?? m.body ?? m.message ?? '',
|
text: m.text ?? m.body ?? m.message ?? '',
|
||||||
time: m.time ?? m.createdat ?? m.sentat ?? ''
|
time: m.time ?? m.createdat ?? m.sentat ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Header({ onToggle }) {
|
export default function Header({ isSidebarCollapsed }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const staff = getStaff();
|
const staff = getStaff();
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
@@ -103,14 +67,9 @@ export default function Header({ onToggle }) {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
const [account, setAccount] = useState(null);
|
|
||||||
const [notifAnchor, setNotifAnchor] = useState(null);
|
|
||||||
const [msgAnchor, setMsgAnchor] = useState(null);
|
|
||||||
|
|
||||||
// Dialog State
|
|
||||||
const [selectedNotif, setSelectedNotif] = useState(null);
|
const [selectedNotif, setSelectedNotif] = useState(null);
|
||||||
const [conversations, setConversations] = useState([]);
|
const [conversations, setConversations] = useState([]);
|
||||||
const [activeChat, setActiveChat] = useState(null); // { id, name, initials, messages: [] }
|
const [activeChat, setActiveChat] = useState(null);
|
||||||
const [chatLoading, setChatLoading] = useState(false);
|
const [chatLoading, setChatLoading] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [typedMessage, setTypedMessage] = useState('');
|
const [typedMessage, setTypedMessage] = useState('');
|
||||||
@@ -122,7 +81,6 @@ export default function Header({ onToggle }) {
|
|||||||
|
|
||||||
const unread = notifications.filter((n) => !n.read).length;
|
const unread = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
// Load real notifications from the API (map `type` → an icon component).
|
|
||||||
const loadNotifications = useCallback(async () => {
|
const loadNotifications = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getNotifications();
|
const res = await getNotifications();
|
||||||
@@ -133,43 +91,39 @@ export default function Header({ onToggle }) {
|
|||||||
time: n.time,
|
time: n.time,
|
||||||
read: Boolean(n.read),
|
read: Boolean(n.read),
|
||||||
type: n.type,
|
type: n.type,
|
||||||
icon: NOTIF_ICON[n.type] || NotificationsNoneIcon
|
icon: NOTIF_ICON[n.type] || Bell,
|
||||||
|
desc: n.desc,
|
||||||
|
stats: n.stats || [],
|
||||||
|
to: n.to,
|
||||||
|
actionText: n.actionText
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal: leave the bell empty if it can't load.
|
|
||||||
setNotifications([]);
|
setNotifications([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
const t = setInterval(loadNotifications, 60000); // refresh every 60s
|
const t = setInterval(loadNotifications, 60000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [loadNotifications]);
|
}, [loadNotifications]);
|
||||||
|
|
||||||
// Load conversations (one per miler at the hub) for the messages dropdown.
|
|
||||||
const loadConversations = useCallback(async () => {
|
const loadConversations = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getConversations();
|
const res = await getConversations();
|
||||||
setConversations((res?.data || []).map(toConversation));
|
setConversations((res?.data || []).map(toConversation));
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal: leave the messages list empty if it can't load.
|
|
||||||
setConversations([]);
|
setConversations([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
const t = setInterval(loadConversations, 60000); // refresh every 60s
|
const t = setInterval(loadConversations, 60000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [loadConversations]);
|
}, [loadConversations]);
|
||||||
|
|
||||||
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
|
|
||||||
const closeNotif = () => setNotifAnchor(null);
|
|
||||||
|
|
||||||
const openMessages = (e) => { setMsgAnchor(e.currentTarget); loadConversations(); };
|
|
||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
|
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
|
||||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
@@ -178,18 +132,14 @@ export default function Header({ onToggle }) {
|
|||||||
|
|
||||||
const onNotifClick = async (n) => {
|
const onNotifClick = async (n) => {
|
||||||
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
|
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
|
||||||
closeNotif();
|
if (n.desc || n.stats) setSelectedNotif(n);
|
||||||
|
else if (n.to) navigate(n.to);
|
||||||
try {
|
try {
|
||||||
await markNotificationRead(n.id);
|
await markNotificationRead(n.id);
|
||||||
} catch {
|
} catch {}
|
||||||
/* best effort */
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open a conversation: show the header immediately, then load the thread and
|
|
||||||
// mark the other party's messages as read (which clears the unread badge).
|
|
||||||
const onMessageClick = async (conv) => {
|
const onMessageClick = async (conv) => {
|
||||||
setMsgAnchor(null);
|
|
||||||
setTypedMessage('');
|
setTypedMessage('');
|
||||||
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
|
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
|
||||||
setChatLoading(true);
|
setChatLoading(true);
|
||||||
@@ -207,35 +157,30 @@ export default function Header({ onToggle }) {
|
|||||||
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
|
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Leave the (empty) thread open; the header still shows who it's with.
|
|
||||||
} finally {
|
} finally {
|
||||||
setChatLoading(false);
|
setChatLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
|
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
const text = typedMessage.trim();
|
const text = typedMessage.trim();
|
||||||
if (!text || !activeChat || sending) return;
|
if (!text || !activeChat || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
// Optimistically append; reconcile with the server's stored copy on success.
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
|
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
|
||||||
setTypedMessage('');
|
setTypedMessage('');
|
||||||
try {
|
try {
|
||||||
await sendMessage(activeChat.id, text);
|
await sendMessage(activeChat.id, text);
|
||||||
// Refresh the thread so the persisted message (and its real timestamp) shows.
|
|
||||||
const res = await getConversation(activeChat.id);
|
const res = await getConversation(activeChat.id);
|
||||||
const thread = res?.data || {};
|
const thread = res?.data || {};
|
||||||
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
|
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
|
||||||
// Keep the dropdown preview in sync.
|
|
||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
|
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// On failure, drop the optimistic bubble and restore the draft to retry.
|
|
||||||
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
|
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
|
||||||
setTypedMessage(text);
|
setTypedMessage(text);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -250,336 +195,118 @@ export default function Header({ onToggle }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar
|
<>
|
||||||
position="fixed"
|
<TopNav
|
||||||
elevation={0}
|
label="Main navigation"
|
||||||
sx={{
|
style={{
|
||||||
bgcolor: '#FFFFFF',
|
backgroundColor: '#ffffff',
|
||||||
color: 'text.primary',
|
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
|
||||||
zIndex: (t) => t.zIndex.drawer + 1,
|
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
|
||||||
borderBottom: '1px solid',
|
}}
|
||||||
borderColor: 'grey.200'
|
heading={
|
||||||
}}
|
<TopNavHeading
|
||||||
>
|
logo={<Logo compact={isSidebarCollapsed} size={isSidebarCollapsed ? 36 : 32} height={28} />}
|
||||||
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
|
headingHref="/dashboard"
|
||||||
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
{/* Brand wordmark — left side */}
|
|
||||||
<Box
|
|
||||||
onClick={() => navigate('/dashboard')}
|
|
||||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<Logo height={22} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
onSubmit={submitSearch}
|
|
||||||
sx={{
|
|
||||||
display: { xs: 'none', sm: 'flex' },
|
|
||||||
alignItems: 'center',
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 2,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
width: { sm: 240, md: 320 },
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
'&:hover': { bgcolor: 'grey.200' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} />
|
|
||||||
<InputBase
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Scan package, check destination…"
|
|
||||||
sx={{ fontSize: '0.875rem', flex: 1 }}
|
|
||||||
inputProps={{ 'aria-label': 'search' }}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
}
|
||||||
|
endContent={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<DropdownMenu
|
||||||
|
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><MessageSquare size={20} />{unreadMessages > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unreadMessages}</span>}</div> }}
|
||||||
|
items={[
|
||||||
|
{ label: 'Messages', type: 'label' },
|
||||||
|
...conversations.map(m => ({
|
||||||
|
label: m.name,
|
||||||
|
description: m.lastMessage,
|
||||||
|
onClick: () => onMessageClick(m)
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><Bell size={20} />{unread > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unread}</span>}</div> }}
|
||||||
|
items={[
|
||||||
|
{ label: 'Notifications', type: 'label' },
|
||||||
|
{ label: 'Mark all read', icon: <CheckCircle2 size={16} />, onClick: markAllRead, disabled: unread === 0 },
|
||||||
|
{ type: 'divider' },
|
||||||
|
...(notifications.length === 0 ? [{ label: 'No notifications', disabled: true }] : notifications.map((n, i) => ({
|
||||||
|
label: n.title + '\u200B'.repeat(i),
|
||||||
|
description: n.time,
|
||||||
|
icon: n.icon ? (() => { const Icon = n.icon; return <Icon size={16} />; })() : undefined,
|
||||||
|
onClick: () => onNotifClick(n)
|
||||||
|
})))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tooltip title="Messages">
|
<DropdownMenu
|
||||||
<IconButton color="inherit" onClick={openMessages}>
|
button={{
|
||||||
<Badge badgeContent={unreadMessages} color="error">
|
variant: 'ghost',
|
||||||
<ChatIcon />
|
size: 'lg',
|
||||||
</Badge>
|
icon: <Avatar name={staffName} fallback={toInitials(staffName)} size="sm" />,
|
||||||
</IconButton>
|
label: staffName
|
||||||
</Tooltip>
|
}}
|
||||||
<Tooltip title="Notifications">
|
items={[
|
||||||
<IconButton color="inherit" onClick={openNotif}>
|
{ label: 'Settings', icon: <Settings size={16} />, onClick: () => navigate('/hub-settings') },
|
||||||
<Badge badgeContent={unread} color="error">
|
{ type: 'divider' },
|
||||||
<NotificationsNoneIcon />
|
{ label: 'Logout', icon: <LogOut size={16} />, onClick: handleLogout }
|
||||||
</Badge>
|
]}
|
||||||
</IconButton>
|
/>
|
||||||
</Tooltip>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box
|
{selectedNotif && (
|
||||||
onClick={(e) => setAccount(e.currentTarget)}
|
<dialog open style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, background: '#fff', padding: '24px', borderRadius: '8px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', maxWidth: '500px', width: '100%' }}>
|
||||||
sx={{
|
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.25rem' }}>{selectedNotif.title}</h2>
|
||||||
display: 'flex',
|
<p style={{ margin: '0 0 16px 0', color: '#475569' }}>{selectedNotif.desc}</p>
|
||||||
alignItems: 'center',
|
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
|
||||||
gap: 1,
|
{selectedNotif.stats.map((s, i) => (
|
||||||
ml: 0.5,
|
<div key={i} style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px', flex: 1 }}>
|
||||||
cursor: 'pointer',
|
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold' }}>{s.label}</div>
|
||||||
py: 0.5,
|
<div style={{ fontSize: '1.125rem', fontWeight: 'bold', marginTop: '4px' }}>{s.value}</div>
|
||||||
px: 1,
|
</div>
|
||||||
borderRadius: 2,
|
))}
|
||||||
'&:hover': { bgcolor: 'grey.100' }
|
</div>
|
||||||
}}
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||||
>
|
<Button variant="ghost" onClick={() => setSelectedNotif(null)}>Dismiss</Button>
|
||||||
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{toInitials(staffName)}</Avatar>
|
{selectedNotif.actionText && (
|
||||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
<Button onClick={() => { setSelectedNotif(null); navigate(selectedNotif.to); }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
{selectedNotif.actionText} <ArrowRight size={16} />
|
||||||
{staffName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
{hubName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Notifications dropdown */}
|
|
||||||
<Menu
|
|
||||||
anchorEl={notifAnchor}
|
|
||||||
open={Boolean(notifAnchor)}
|
|
||||||
onClose={closeNotif}
|
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
||||||
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }}
|
|
||||||
>
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
Notifications
|
|
||||||
</Typography>
|
|
||||||
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}>
|
|
||||||
Mark all read
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<Divider />
|
|
||||||
{notifications.length === 0 && (
|
|
||||||
<MenuItem disabled>
|
|
||||||
<ListItemText primary="No notifications" />
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{notifications.map((n) => {
|
|
||||||
const Icon = n.icon;
|
|
||||||
return (
|
|
||||||
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
|
||||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
|
||||||
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={n.title}
|
|
||||||
secondary={n.time}
|
|
||||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
|
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem' }}
|
|
||||||
/>
|
|
||||||
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
{/* Messages dropdown */}
|
|
||||||
<Menu
|
|
||||||
anchorEl={msgAnchor}
|
|
||||||
open={Boolean(msgAnchor)}
|
|
||||||
onClose={() => setMsgAnchor(null)}
|
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
||||||
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
|
|
||||||
Messages
|
|
||||||
</Typography>
|
|
||||||
<Divider />
|
|
||||||
{conversations.length === 0 && (
|
|
||||||
<MenuItem disabled>
|
|
||||||
<ListItemText primary="No messages" />
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{conversations.map((m) => (
|
|
||||||
<MenuItem key={m.id} onClick={() => onMessageClick(m)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
|
||||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
|
||||||
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
|
||||||
{m.initials}
|
|
||||||
</Avatar>
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={m.name}
|
|
||||||
secondary={m.lastMessage || 'No messages yet'}
|
|
||||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: m.unread > 0 ? 700 : 600 }}
|
|
||||||
secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }}
|
|
||||||
/>
|
|
||||||
<Stack alignItems="flex-end" sx={{ ml: 1, flexShrink: 0 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
||||||
{m.time}
|
|
||||||
</Typography>
|
|
||||||
{m.unread > 0 && (
|
|
||||||
<Badge badgeContent={m.unread} color="error" sx={{ mt: 1, mr: 0.75 }} />
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
{/* Account dropdown */}
|
|
||||||
<Menu
|
|
||||||
anchorEl={account}
|
|
||||||
open={Boolean(account)}
|
|
||||||
onClose={() => setAccount(null)}
|
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
||||||
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={() => { setAccount(null); handleLogout(); }} sx={{ color: 'error.main' }}>
|
|
||||||
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
|
||||||
Logout
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
{/* High Fidelity Notification Detail Dialog */}
|
|
||||||
<Dialog open={Boolean(selectedNotif)} onClose={() => setSelectedNotif(null)} fullWidth maxWidth="sm">
|
|
||||||
{selectedNotif && (
|
|
||||||
<>
|
|
||||||
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}>
|
|
||||||
{selectedNotif.title}
|
|
||||||
</DialogTitle>
|
|
||||||
<Divider />
|
|
||||||
<DialogContent sx={{ py: 3 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 3 }}>
|
|
||||||
{selectedNotif.desc}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'text.secondary' }}>
|
|
||||||
Operational Details
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{selectedNotif.stats.map((stat, index) => (
|
|
||||||
<Grid size={{ xs: 6 }} key={index}>
|
|
||||||
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'grey.200' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontWeight: 600 }}>
|
|
||||||
{stat.label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mt: 0.5 }}>
|
|
||||||
{stat.value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</DialogContent>
|
|
||||||
<Divider />
|
|
||||||
<DialogActions sx={{ p: 2 }}>
|
|
||||||
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
endIcon={<ArrowForwardIcon fontSize="small" />}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedNotif(null);
|
|
||||||
navigate(selectedNotif.to);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedNotif.actionText}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
</dialog>
|
||||||
</Dialog>
|
)}
|
||||||
|
|
||||||
{/* High Fidelity Chat Message Dialog */}
|
{activeChat && (
|
||||||
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs">
|
<dialog open style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 1000, background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', width: '360px', padding: 0, overflow: 'hidden' }}>
|
||||||
{activeChat && (
|
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '12px', background: '#f8fafc' }}>
|
||||||
<>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: RED, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }}>{activeChat.initials}</div>
|
||||||
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<h3 style={{ margin: 0, flex: 1 }}>{activeChat.name}</h3>
|
||||||
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
|
<Button variant="ghost" size="icon" onClick={closeChat}>×</Button>
|
||||||
{activeChat.initials}
|
</div>
|
||||||
</Avatar>
|
<div style={{ height: '300px', overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', background: '#fff' }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
{chatLoading ? (
|
||||||
{activeChat.name}
|
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>Loading...</div>
|
||||||
</Typography>
|
) : activeChat.messages.length === 0 ? (
|
||||||
</DialogTitle>
|
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>No messages yet.</div>
|
||||||
<Divider />
|
) : (
|
||||||
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
activeChat.messages.map((m, i) => (
|
||||||
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
|
<div key={i} style={{ alignSelf: m.sender === 'me' ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
|
||||||
{chatLoading && activeChat.messages.length === 0 && (
|
<div style={{ background: m.sender === 'me' ? RED : '#f1f5f9', color: m.sender === 'me' ? '#fff' : '#0f172a', padding: '8px 12px', borderRadius: '12px', borderBottomRightRadius: m.sender === 'me' ? 0 : '12px', borderBottomLeftRadius: m.sender === 'them' ? 0 : '12px' }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
{m.text}
|
||||||
Loading…
|
</div>
|
||||||
</Typography>
|
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textAlign: m.sender === 'me' ? 'right' : 'left', marginTop: '4px' }}>{m.time}</div>
|
||||||
)}
|
</div>
|
||||||
{!chatLoading && activeChat.messages.length === 0 && (
|
))
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
)}
|
||||||
No messages yet. Say hello.
|
</div>
|
||||||
</Typography>
|
<div style={{ padding: '12px', borderTop: '1px solid #e2e8f0', background: '#fff', display: 'flex', gap: '8px' }}>
|
||||||
)}
|
<TextInput style={{ flex: 1 }} value={typedMessage} onChange={(e) => setTypedMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} placeholder="Type a message..." />
|
||||||
{activeChat.messages.map((msg, idx) => (
|
<Button size="icon" onClick={handleSendMessage} disabled={!typedMessage.trim() || sending}><Send size={16} /></Button>
|
||||||
<Box
|
</div>
|
||||||
key={idx}
|
</dialog>
|
||||||
sx={{
|
)}
|
||||||
alignSelf: msg.sender === 'me' ? 'flex-end' : 'flex-start',
|
</>
|
||||||
maxWidth: '80%'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: msg.sender === 'me' ? RED : '#FFFFFF',
|
|
||||||
color: msg.sender === 'me' ? '#FFFFFF' : 'text.primary',
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
|
||||||
border: msg.sender === 'me' ? 'none' : '1px solid',
|
|
||||||
borderColor: 'grey.200'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1">
|
|
||||||
{msg.text}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.5,
|
|
||||||
textAlign: msg.sender === 'me' ? 'right' : 'left'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.time}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<Divider />
|
|
||||||
<DialogActions sx={{ p: 1.5 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
placeholder="Type your message..."
|
|
||||||
value={typedMessage}
|
|
||||||
onChange={(e) => setTypedMessage(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton onClick={handleSendMessage} size="small" color="primary" disabled={sending || !typedMessage.trim()}>
|
|
||||||
<SendIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogActions>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
</AppBar>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,16 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import {
|
import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
|
||||||
Drawer,
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
Box,
|
|
||||||
List,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Typography,
|
|
||||||
Collapse,
|
|
||||||
Tooltip,
|
|
||||||
Toolbar
|
|
||||||
} from '@mui/material';
|
|
||||||
import { alpha } from '@mui/material/styles';
|
|
||||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
|
||||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
|
||||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
|
||||||
|
|
||||||
import navItems from '@/menu/navItems';
|
import navItems from '@/menu/navItems';
|
||||||
import Logo from '@/components/Logo';
|
|
||||||
import { isDoormileStaff } from '@/auth/session';
|
import { isDoormileStaff } from '@/auth/session';
|
||||||
|
|
||||||
export const DRAWER_WIDTH = 240;
|
export default function Sidebar({ isCollapsed, onCollapsedChange }) {
|
||||||
export const MINI_WIDTH = 72;
|
|
||||||
|
|
||||||
const BRAND_RED = '#C01227';
|
|
||||||
|
|
||||||
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
const button = (
|
|
||||||
<ListItemButton
|
|
||||||
selected={active}
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
minHeight: 44,
|
|
||||||
my: 0.25,
|
|
||||||
mx: 1.25,
|
|
||||||
px: open ? 1.5 : 0,
|
|
||||||
justifyContent: open ? 'flex-start' : 'center',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: active ? BRAND_RED : 'text.primary',
|
|
||||||
transition: (theme) => theme.transitions.create(['background-color', 'color', 'padding'], {
|
|
||||||
duration: theme.transitions.duration.shorter,
|
|
||||||
}),
|
|
||||||
'& .MuiListItemIcon-root': {
|
|
||||||
color: active ? BRAND_RED : 'text.secondary',
|
|
||||||
minWidth: open ? 32 : 0,
|
|
||||||
justifyContent: 'center'
|
|
||||||
},
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
color: 'text.primary',
|
|
||||||
'& .MuiListItemIcon-root': { color: 'text.primary' }
|
|
||||||
},
|
|
||||||
'&.Mui-selected': {
|
|
||||||
bgcolor: alpha(BRAND_RED, 0.08),
|
|
||||||
color: BRAND_RED,
|
|
||||||
'& .MuiListItemIcon-root': { color: BRAND_RED },
|
|
||||||
'&:hover': { bgcolor: alpha(BRAND_RED, 0.12) }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
{depth > 0 && !Icon ? (
|
|
||||||
<FiberManualRecordIcon sx={{ fontSize: 6 }} />
|
|
||||||
) : Icon ? (
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
) : null}
|
|
||||||
</ListItemIcon>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<ListItemText
|
|
||||||
primary={item.title}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
noWrap: true
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ListItemButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
|
||||||
<Tooltip title={item.title} placement="right" arrow disableInteractive>
|
|
||||||
{button}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const isActive = (url) => !!url && location.pathname.startsWith(url);
|
||||||
const expanded = open || isMobile;
|
|
||||||
const doormile = isDoormileStaff();
|
const doormile = isDoormileStaff();
|
||||||
|
|
||||||
// Partner accounts don't see Doormile-only groups/items (e.g. Hub Settings).
|
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
navItems
|
navItems
|
||||||
@@ -110,213 +20,121 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
|||||||
[doormile]
|
[doormile]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = (url) => !!(url && location.pathname.startsWith(url));
|
return (
|
||||||
|
<>
|
||||||
// Memoize initial open state to prevent recalculation on every drawer toggle
|
<SideNav
|
||||||
const initialOpen = useMemo(() => {
|
className="doormile-side-nav"
|
||||||
return navItems
|
collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
|
||||||
.flatMap((g) => g.items)
|
style={{
|
||||||
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
backgroundColor: '#ffffff',
|
||||||
.map((i) => i.id);
|
borderRight: '1px solid rgba(5, 54, 89, 0.08)',
|
||||||
}, [location.pathname]);
|
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
||||||
|
paddingBlock: '12px',
|
||||||
const [collapse, setCollapse] = useState(initialOpen);
|
paddingInline: '8px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
const handleToggleCollapse = (id) => {
|
'--spacing-12': '72px'
|
||||||
setCollapse((prev) =>
|
|
||||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const go = (url) => {
|
|
||||||
navigate(url);
|
|
||||||
if (isMobile) onMobileClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const sidebarContent = (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderRight: '1px solid',
|
|
||||||
borderColor: 'divider'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top Branding Section */}
|
|
||||||
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
|
||||||
<Logo compact={!expanded} />
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
{/* Navigation Scroll Area */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
flexGrow: 1,
|
|
||||||
pb: 2,
|
|
||||||
scrollbarWidth: 'thin',
|
|
||||||
'&::-webkit-scrollbar': { width: 5 },
|
|
||||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
|
||||||
'&::-webkit-scrollbar-thumb': {
|
|
||||||
backgroundColor: 'action.hover',
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
'&:hover::-webkit-scrollbar-thumb': {
|
|
||||||
backgroundColor: 'action.focus',
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{groups.map((grp) => (
|
{groups.map((grp) => (
|
||||||
<Box key={grp.group} sx={{ mt: 2.5 }}>
|
<SideNavSection key={grp.group} title={grp.group}>
|
||||||
{expanded && (
|
{grp.items.map((item) => (
|
||||||
<Typography
|
<SideNavItem
|
||||||
variant="overline"
|
key={item.id}
|
||||||
sx={{
|
label={item.title}
|
||||||
px: 2.5,
|
// If you migrate icons to Lucide or similar, you pass it here.
|
||||||
color: 'text.secondary',
|
// Currently keeping the existing MUI icon or whatever item.icon provides.
|
||||||
fontWeight: 800,
|
icon={item.icon}
|
||||||
fontSize: '0.6875rem',
|
href={item.url}
|
||||||
letterSpacing: '0.08em',
|
isSelected={isActive(item.url)}
|
||||||
display: 'block'
|
/>
|
||||||
}}
|
))}
|
||||||
>
|
</SideNavSection>
|
||||||
{grp.group}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<List disablePadding sx={{ mt: 0.5 }}>
|
|
||||||
{grp.items.map((item) => {
|
|
||||||
if (item.children) {
|
|
||||||
const opened = collapse.includes(item.id);
|
|
||||||
const childActive = item.children.some((c) => isActive(c.url));
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
const headerButton = (
|
|
||||||
<ListItemButton
|
|
||||||
onClick={() => expanded ? handleToggleCollapse(item.id) : go(item.children[0].url)}
|
|
||||||
sx={{
|
|
||||||
minHeight: 44,
|
|
||||||
my: 0.25,
|
|
||||||
mx: 1.25,
|
|
||||||
px: expanded ? 1.5 : 0,
|
|
||||||
justifyContent: expanded ? 'flex-start' : 'center',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: 'text.primary',
|
|
||||||
bgcolor: childActive && !opened ? alpha(BRAND_RED, 0.04) : 'transparent',
|
|
||||||
'& .MuiListItemIcon-root': {
|
|
||||||
color: childActive ? BRAND_RED : 'text.secondary',
|
|
||||||
minWidth: expanded ? 32 : 0,
|
|
||||||
justifyContent: 'center'
|
|
||||||
},
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
'& .MuiListItemIcon-root': { color: 'text.primary' }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Icon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
{expanded && (
|
|
||||||
<>
|
|
||||||
<ListItemText
|
|
||||||
primary={item.title}
|
|
||||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: childActive ? 600 : 500 }}
|
|
||||||
/>
|
|
||||||
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ListItemButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={item.id}>
|
|
||||||
{expanded ? headerButton : <Tooltip title={item.title} placement="right" arrow>{headerButton}</Tooltip>}
|
|
||||||
{expanded && (
|
|
||||||
<Collapse in={opened} timeout="auto" unmountOnExit>
|
|
||||||
<Box sx={{ mt: 0.25 }}>
|
|
||||||
{item.children.map((c) => (
|
|
||||||
<NavLeaf
|
|
||||||
key={c.id}
|
|
||||||
item={c}
|
|
||||||
open
|
|
||||||
depth={1}
|
|
||||||
active={isActive(c.url)}
|
|
||||||
onClick={() => go(c.url)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavLeaf
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
open={expanded}
|
|
||||||
active={isActive(item.url)}
|
|
||||||
onClick={() => go(item.url)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</SideNav>
|
||||||
|
<style>{`
|
||||||
|
.doormile-side-nav .astryx-side-nav-section > div:last-child {
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
{/* Bottom Footer Branding */}
|
.doormile-side-nav .astryx-side-nav-item[aria-label] {
|
||||||
{expanded && (
|
width: 40px;
|
||||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.default' }}>
|
height: 40px;
|
||||||
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 600, display: 'block', lineHeight: 1.3 }}>
|
margin-inline: auto;
|
||||||
Hub Control Panel
|
border-radius: 10px;
|
||||||
</Typography>
|
display: flex;
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
align-items: center;
|
||||||
Doormile Logistics · v1.0
|
justify-content: center;
|
||||||
</Typography>
|
transition: background-color 0.15s ease;
|
||||||
</Box>
|
}
|
||||||
)}
|
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
||||||
</Box>
|
width: 18px !important;
|
||||||
);
|
height: 18px !important;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
||||||
|
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
|
||||||
|
outline: 2px solid rgba(10, 19, 23, 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
|
||||||
|
background-color: rgba(10, 19, 23, 0.12) !important;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
|
||||||
|
color: #0A1317 !important;
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
|
||||||
return (
|
position: relative;
|
||||||
<Drawer
|
margin-inline: 2px;
|
||||||
variant="temporary"
|
height: auto;
|
||||||
open={mobileOpen}
|
padding-block: 12px !important;
|
||||||
onClose={onMobileClose}
|
transition: background-color 0.15s ease, transform 0.15s ease;
|
||||||
ModalProps={{ keepMounted: true }}
|
}
|
||||||
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
|
||||||
>
|
content: '';
|
||||||
{sidebarContent}
|
position: absolute;
|
||||||
</Drawer>
|
left: -2px;
|
||||||
);
|
top: 12px;
|
||||||
}
|
bottom: 12px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #0A1317;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
|
||||||
|
background-color: rgba(10, 19, 23, 0.05) !important;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
|
||||||
|
outline: 2px solid rgba(10, 19, 23, 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
|
||||||
|
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected']::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] .astryx-icon {
|
||||||
|
color: #0A1317 !important;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
.doormile-side-nav > div:last-child {
|
||||||
<Drawer
|
padding-block: 8px !important;
|
||||||
variant="permanent"
|
}
|
||||||
sx={{
|
|
||||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
.doormile-side-nav button[aria-label*="sidebar"],
|
||||||
flexShrink: 0,
|
.doormile-side-nav button[aria-label*="navigation"] {
|
||||||
whiteSpace: 'nowrap',
|
border-radius: 50% !important;
|
||||||
'& .MuiDrawer-paper': {
|
transition: background-color 0.15s ease !important;
|
||||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
}
|
||||||
border: 'none',
|
.doormile-side-nav button[aria-label*="sidebar"]:hover,
|
||||||
overflowX: 'hidden',
|
.doormile-side-nav button[aria-label*="navigation"]:hover {
|
||||||
transition: (theme) => theme.transitions.create('width', {
|
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||||
easing: theme.transitions.easing.sharp,
|
}
|
||||||
duration: theme.transitions.duration.standard,
|
`}</style>
|
||||||
}),
|
</>
|
||||||
},
|
|
||||||
}}
|
|
||||||
open={open}
|
|
||||||
>
|
|
||||||
{sidebarContent}
|
|
||||||
</Drawer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,35 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { Box, useMediaQuery } from '@mui/material';
|
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const theme = useTheme();
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
if (isMobile) setMobileOpen((p) => !p);
|
|
||||||
else setOpen((p) => !p);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
|
<AppShell
|
||||||
<Header onToggle={toggle} />
|
variant="section"
|
||||||
<Sidebar
|
height="fill"
|
||||||
open={open}
|
contentPadding={0}
|
||||||
isMobile={isMobile}
|
topNav={<Header isSidebarCollapsed={isSidebarCollapsed} />}
|
||||||
mobileOpen={mobileOpen}
|
sideNav={<Sidebar isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} />}
|
||||||
onMobileClose={() => setMobileOpen(false)}
|
mobileNav={{ breakpoint: 'lg' }}
|
||||||
/>
|
>
|
||||||
<Box
|
<div className="main-content-area" style={{ minHeight: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }}>
|
||||||
component="main"
|
<Outlet />
|
||||||
sx={{
|
</div>
|
||||||
flexGrow: 1,
|
<style>{`
|
||||||
minWidth: 0,
|
.main-content-area {
|
||||||
minHeight: '100vh',
|
padding: 24px;
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
@media (max-width: 768px) {
|
||||||
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
|
.main-content-area {
|
||||||
}}
|
padding: 16px;
|
||||||
>
|
}
|
||||||
<Box sx={{ height: 64, flexShrink: 0 }} />
|
}
|
||||||
<Box
|
`}</style>
|
||||||
sx={{
|
</AppShell>
|
||||||
flexGrow: 1,
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
px: { xs: 1.5, sm: 2.5, md: 3.5 },
|
|
||||||
py: { xs: 2, sm: 2.5, md: 3 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
|
|
||||||
// Used by auth + maintenance pages — full-bleed, no shell.
|
// Used by auth + maintenance pages — full-bleed, no shell.
|
||||||
export default function MinimalLayout() {
|
export default function MinimalLayout() {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
<div style={{ minHeight: '100vh', backgroundColor: 'var(--color-background-body)' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/main.jsx
28
src/main.jsx
@@ -1,18 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter, Link } from 'react-router-dom';
|
||||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
|
||||||
|
|
||||||
import theme from '@/theme';
|
// Astryx UI CSS
|
||||||
|
import '@astryxdesign/core/reset.css';
|
||||||
|
import '@astryxdesign/core/astryx.css';
|
||||||
|
import '@astryxdesign/theme-neutral/theme.css';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
import { Theme } from '@astryxdesign/core';
|
||||||
|
import { LinkProvider } from '@astryxdesign/core/Link';
|
||||||
|
import { ToastViewport } from '@astryxdesign/core/Toast';
|
||||||
|
import { neutralTheme } from '@astryxdesign/theme-neutral/built';
|
||||||
import App from '@/App';
|
import App from '@/App';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<Theme theme={neutralTheme}>
|
||||||
<ThemeProvider theme={theme}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<CssBaseline />
|
<LinkProvider component={Link}>
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
<ToastViewport />
|
||||||
</ThemeProvider>
|
</LinkProvider>
|
||||||
</React.StrictMode>
|
</BrowserRouter>
|
||||||
|
</Theme>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import SpaceDashboardRoundedIcon from '@mui/icons-material/SpaceDashboardRounded';
|
import { LayoutDashboard, Map, HandHeart, Inbox, Route, Truck, Bike, Settings } from 'lucide-react';
|
||||||
import MapRoundedIcon from '@mui/icons-material/MapRounded';
|
|
||||||
import HailRoundedIcon from '@mui/icons-material/HailRounded';
|
|
||||||
import MoveToInboxRoundedIcon from '@mui/icons-material/MoveToInboxRounded';
|
|
||||||
import AltRouteRoundedIcon from '@mui/icons-material/AltRouteRounded';
|
|
||||||
import LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded';
|
|
||||||
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
|
|
||||||
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
|
||||||
|
|
||||||
// ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== //
|
// ==============================|| DOORMILE HUB NAVIGATION ITEMS ||============================== //
|
||||||
// Menu follows the parcel's real journey in plain language so any hub staff member
|
// Menu follows the parcel's real journey in plain language so any hub staff member
|
||||||
@@ -15,39 +8,39 @@ const navItems = [
|
|||||||
{
|
{
|
||||||
group: 'Overview',
|
group: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: SpaceDashboardRoundedIcon },
|
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'tracking', title: 'Live Map', url: '/tracking', icon: MapRoundedIcon }
|
{ id: 'tracking', title: 'Live Map', url: '/tracking', icon: Map }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: '1. Pick Up',
|
group: '1. Pick Up',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'assignments', title: 'Pickup Requests', url: '/assignments', icon: HailRoundedIcon }
|
{ id: 'assignments', title: 'Pickup Requests', url: '/assignments', icon: HandHeart }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: '2. Receive at Hub',
|
group: '2. Receive at Hub',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'inbound', title: 'Receive Parcels', url: '/inbound', icon: MoveToInboxRoundedIcon }
|
{ id: 'inbound', title: 'Receive Parcels', url: '/inbound', icon: Inbox }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: '3. Sort & Store',
|
group: '3. Sort & Store',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: AltRouteRoundedIcon }
|
{ id: 'routing', title: 'Where Does It Go?', url: '/routing', icon: Route }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: '4. Send Out',
|
group: '4. Send Out',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'dispatch', title: 'Dispatch & Transfer', url: '/dispatch', icon: LocalShippingRoundedIcon }
|
{ id: 'dispatch', title: 'Dispatch & Transfer', url: '/dispatch', icon: Truck }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Team',
|
group: 'Team',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'riders', title: 'Milers', url: '/riders', icon: DeliveryDiningRoundedIcon },
|
{ id: 'riders', title: 'Milers', url: '/riders', icon: Bike },
|
||||||
{ id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: AltRouteRoundedIcon }
|
{ id: 'rider-routes', title: 'Rider Routes', url: '/rider-routes', icon: Route }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,7 +48,7 @@ const navItems = [
|
|||||||
// Doormile staff only — hidden for partner (restricted) accounts.
|
// Doormile staff only — hidden for partner (restricted) accounts.
|
||||||
doormileOnly: true,
|
doormileOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'hub-settings', title: 'Hub Settings', url: '/hub-settings', icon: SettingsRoundedIcon, doormileOnly: true }
|
{ id: 'hub-settings', title: 'Hub Settings', url: '/hub-settings', icon: Settings, doormileOnly: true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Zap, Truck, ShieldCheck, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
Box,
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
Card,
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
InputAdornment,
|
|
||||||
IconButton,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
FormControlLabel,
|
|
||||||
Link,
|
|
||||||
Alert,
|
|
||||||
CircularProgress
|
|
||||||
} from '@mui/material';
|
|
||||||
import Visibility from '@mui/icons-material/Visibility';
|
|
||||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
|
||||||
import BoltIcon from '@mui/icons-material/Bolt';
|
|
||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
|
|
||||||
|
import Button from '@/components/Button';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { login as loginRequest } from '@/api/hub';
|
import { login as loginRequest } from '@/api/hub';
|
||||||
import { setSession } from '@/auth/session';
|
import { setSession } from '@/auth/session';
|
||||||
@@ -56,17 +40,18 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100vw', bgcolor: '#ffffff', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', minHeight: '100vh', width: '100vw', backgroundColor: '#ffffff', overflow: 'hidden' }}>
|
||||||
|
|
||||||
{/* Brand Side Panel */}
|
{/* Brand Side Panel */}
|
||||||
<Box
|
<div
|
||||||
sx={{
|
className="hide-on-mobile"
|
||||||
display: { xs: 'none', md: 'flex' },
|
style={{
|
||||||
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
width: { md: '28%', lg: '27%', xl: '25%' },
|
width: '28%',
|
||||||
minWidth: '360px',
|
minWidth: '360px',
|
||||||
p: 5,
|
padding: '40px',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -75,172 +60,160 @@ export default function Login() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background Decorative Circles */}
|
{/* Background Decorative Circles */}
|
||||||
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
<div style={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
||||||
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
<div style={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
||||||
|
|
||||||
{/* BLACK LOGO REPLACEMENT (Sidebar) */}
|
<div style={{ filter: 'brightness(0) invert(1)', display: 'inline-flex' }}>
|
||||||
<Box sx={{ filter: 'brightness(0) invert(0)', display: 'inline-flex' }}>
|
|
||||||
<Logo height={24} />
|
<Logo height={24} />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Box sx={{ position: 'relative', my: 'auto' }}>
|
<div style={{ position: 'relative', margin: 'auto 0' }}>
|
||||||
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600 }}>
|
<div style={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600, textTransform: 'uppercase', fontSize: '0.75rem', marginBottom: '8px' }}>
|
||||||
Doormile Hub Console
|
Doormile Hub Console
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="h4" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, mt: 1, mb: 2, fontSize: { md: '1.8rem', lg: '2.2rem' } }}>
|
<h1 style={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, margin: '8px 0 16px', fontSize: '2rem' }}>
|
||||||
Every parcel,
|
Every parcel,<br /> handled with ease.
|
||||||
<br /> handled with ease.
|
</h1>
|
||||||
</Typography>
|
<p style={{ color: 'rgba(255,255,255,0.8)', marginBottom: '32px', fontSize: '0.9rem', lineHeight: 1.5 }}>
|
||||||
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
|
|
||||||
Receive parcels, sort them, and send them out for delivery or transfer to another city all from one simple screen.
|
Receive parcels, sort them, and send them out for delivery or transfer to another city all from one simple screen.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Stack spacing={2.5}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
{[
|
{[
|
||||||
{ icon: BoltIcon, t: 'We tell you which shelf each parcel goes on' },
|
{ icon: Zap, t: 'We tell you which shelf each parcel goes on' },
|
||||||
{ icon: LocalShippingOutlinedIcon, t: 'Scan parcels in as trucks arrive' },
|
{ icon: Truck, t: 'Scan parcels in as trucks arrive' },
|
||||||
{ icon: VerifiedOutlinedIcon, t: 'Keep an eye on cold-storage parcels' }
|
{ icon: ShieldCheck, t: 'Keep an eye on cold-storage parcels' }
|
||||||
].map((f) => (
|
].map((f) => (
|
||||||
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center">
|
<div key={f.t} style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<f.icon fontSize="small" />
|
<f.icon size={16} />
|
||||||
</Box>
|
</div>
|
||||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</Typography>
|
<div style={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</div>
|
||||||
</Stack>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 3 }}>
|
<div style={{ color: 'rgba(255,255,255,0.5)', marginTop: '24px', fontSize: '0.75rem' }}>
|
||||||
© 2026 Doormile Logistics Pvt. Ltd.
|
© 2026 Doormile Logistics Pvt. Ltd.
|
||||||
</Typography>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{/* Form Panel */}
|
{/* Form Panel */}
|
||||||
<Box
|
<div
|
||||||
sx={{
|
style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
p: { xs: 3, sm: 6 },
|
padding: '48px',
|
||||||
bgcolor: '#ffffff'
|
backgroundColor: '#ffffff'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<div
|
||||||
elevation={0}
|
style={{
|
||||||
sx={{
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
p: { xs: 3, sm: 4.5 },
|
padding: '36px',
|
||||||
border: '1px solid #eaeaea',
|
border: '1px solid #eaeaea',
|
||||||
borderRadius: 3,
|
borderRadius: 12,
|
||||||
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
|
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* BLACK LOGO REPLACEMENT (Mobile View) */}
|
<div className="show-on-mobile" style={{ marginBottom: '24px', filter: 'brightness(0)' }}>
|
||||||
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 3, filter: 'brightness(0)' }}>
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem' }}>Hub Sign In</Typography>
|
<h2 style={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem', margin: '0 0 4px 0' }}>Hub Sign In</h2>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 4 }}>
|
<p style={{ color: '#64748b', fontSize: '0.875rem', margin: '0 0 32px 0' }}>
|
||||||
Sign in to your Doormile Hub operations account.
|
Sign in to your Doormile Hub operations account.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>
|
||||||
Username / Email
|
Username / Email
|
||||||
</Typography>
|
</div>
|
||||||
<TextField
|
<TextInput
|
||||||
fullWidth
|
style={{ width: '100%' }}
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
value={auth}
|
value={auth}
|
||||||
onChange={(e) => setAuth(e.target.value)}
|
onChange={setAuth}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>
|
||||||
Password
|
Password
|
||||||
</Typography>
|
</div>
|
||||||
<TextField
|
<div style={{ position: 'relative' }}>
|
||||||
fullWidth
|
<TextInput
|
||||||
type={show ? 'text' : 'password'}
|
style={{ width: '100%', paddingRight: '40px' }}
|
||||||
placeholder="Enter your password"
|
type={show ? 'text' : 'password'}
|
||||||
value={pwd}
|
placeholder="Enter your password"
|
||||||
onChange={(e) => setPwd(e.target.value)}
|
value={pwd}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
|
onChange={setPwd}
|
||||||
InputProps={{
|
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
|
||||||
endAdornment: (
|
/>
|
||||||
<InputAdornment position="end">
|
<button
|
||||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
onClick={() => setShow(!show)}
|
||||||
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
style={{ position: 'absolute', right: 12, top: 8, background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
|
||||||
</IconButton>
|
>
|
||||||
</InputAdornment>
|
{show ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
)
|
</button>
|
||||||
}}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<FormControlLabel
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
control={<Checkbox defaultChecked size="small" sx={{ color: '#C01227', '&.Mui-checked': { color: '#C01227' } }} />}
|
<CheckboxInput defaultChecked />
|
||||||
label={<Typography variant="body2" sx={{ color: '#555' }}>Remember me</Typography>}
|
<span style={{ fontSize: '0.875rem', color: '#555' }}>Remember me</span>
|
||||||
/>
|
</div>
|
||||||
<Link href="#" underline="hover" variant="body2" sx={{ color: '#C01227', fontWeight: 600 }}>
|
<a href="#" style={{ fontSize: '0.875rem', color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</a>
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
<div style={{ backgroundColor: '#fef2f2', color: '#b91c1c', padding: '12px', borderRadius: '8px', fontSize: '0.875rem' }}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
variant="primary"
|
||||||
size="large"
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSignIn}
|
onClick={handleSignIn}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
sx={{
|
|
||||||
bgcolor: '#C01227',
|
|
||||||
color: '#fff',
|
|
||||||
py: 1.5,
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: 2,
|
|
||||||
textTransform: 'none',
|
|
||||||
boxShadow: 'none',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: '#9E0E20',
|
|
||||||
boxShadow: 'none'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{loading && <Loader2 size={16} className="spin" style={{ marginRight: '8px' }} />}
|
||||||
{loading ? 'Signing in…' : 'Sign In'}
|
{loading ? 'Signing in…' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box sx={{ textAlign: 'center', mt: 1 }}>
|
<div style={{ textAlign: 'center', marginTop: '8px', fontSize: '0.875rem', color: '#64748b' }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
New to Hub Operations?{' '}
|
||||||
New to Hub Operations?{' '}
|
<a
|
||||||
<Link
|
href="#"
|
||||||
href="#"
|
onClick={(e) => { e.preventDefault(); navigate('/signup'); }}
|
||||||
onClick={(e) => { e.preventDefault(); navigate('/signup'); }}
|
style={{ color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}
|
||||||
underline="hover"
|
>
|
||||||
sx={{ color: '#C01227', fontWeight: 600 }}
|
Create an account
|
||||||
>
|
</a>
|
||||||
Create an account
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</Typography>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
<style>{`
|
||||||
</Card>
|
.spin { animation: spin 1s linear infinite; }
|
||||||
</Box>
|
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||||
</Box>
|
@media (max-width: 768px) {
|
||||||
|
.hide-on-mobile { display: none !important; }
|
||||||
|
}
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.show-on-mobile { display: none !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import { Network, UserCheck, Shield, Eye, EyeOff } from 'lucide-react';
|
||||||
Box,
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
Card,
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
Grid,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
InputAdornment,
|
|
||||||
IconButton,
|
|
||||||
Button,
|
|
||||||
Link,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
Select,
|
|
||||||
Checkbox,
|
|
||||||
FormControlLabel
|
|
||||||
} from '@mui/material';
|
|
||||||
import Visibility from '@mui/icons-material/Visibility';
|
|
||||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
|
||||||
import HubIcon from '@mui/icons-material/Hub';
|
|
||||||
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
|
|
||||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
|
||||||
|
|
||||||
|
import Button from '@/components/Button';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
const HUBS = [
|
const HUBS = [
|
||||||
@@ -58,17 +40,18 @@ export default function Signup() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100vw', bgcolor: '#ffffff', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', minHeight: '100vh', width: '100vw', backgroundColor: '#ffffff', overflow: 'hidden' }}>
|
||||||
|
|
||||||
{/* Brand Side Panel */}
|
{/* Brand Side Panel */}
|
||||||
<Box
|
<div
|
||||||
sx={{
|
className="hide-on-mobile"
|
||||||
display: { xs: 'none', md: 'flex' },
|
style={{
|
||||||
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
width: { md: '28%', lg: '27%', xl: '25%' },
|
width: '28%',
|
||||||
minWidth: '360px',
|
minWidth: '360px',
|
||||||
p: 5,
|
padding: '40px',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -77,185 +60,172 @@ export default function Signup() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background Decorative Circles */}
|
{/* Background Decorative Circles */}
|
||||||
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
<div style={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
||||||
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
<div style={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', backgroundColor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
||||||
|
|
||||||
{/* BLACK LOGO REPLACEMENT (Sidebar) */}
|
<div style={{ filter: 'brightness(0) invert(1)', display: 'inline-flex' }}>
|
||||||
<Box sx={{ filter: 'brightness(0) invert(0)', display: 'inline-flex' }}>
|
|
||||||
<Logo height={24} />
|
<Logo height={24} />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Box sx={{ position: 'relative', my: 'auto' }}>
|
<div style={{ position: 'relative', margin: 'auto 0' }}>
|
||||||
<Typography variant="overline" sx={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600 }}>
|
<div style={{ color: 'rgba(255,255,255,0.65)', letterSpacing: '0.12em', fontWeight: 600, textTransform: 'uppercase', fontSize: '0.75rem', marginBottom: '8px' }}>
|
||||||
Hub Registration Gateway
|
Hub Registration Gateway
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="h4" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, mt: 1, mb: 2, fontSize: { md: '1.8rem', lg: '2.2rem' } }}>
|
<h1 style={{ color: '#fff', fontWeight: 800, lineHeight: 1.2, margin: '8px 0 16px', fontSize: '2rem' }}>
|
||||||
Join the Connected
|
Join the Connected<br /> Logistics Network.
|
||||||
<br /> Logistics Network.
|
</h1>
|
||||||
</Typography>
|
<p style={{ color: 'rgba(255,255,255,0.8)', marginBottom: '32px', fontSize: '0.9rem', lineHeight: 1.5 }}>
|
||||||
<Typography sx={{ color: 'rgba(255,255,255,0.8)', mb: 4, fontSize: '0.9rem', lineHeight: 1.5 }}>
|
|
||||||
Create an operational profile to access state-of-the-art sorting stations, live manifest creations, and miler optimization modules.
|
Create an operational profile to access state-of-the-art sorting stations, live manifest creations, and miler optimization modules.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Stack spacing={2.5}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
{[
|
{[
|
||||||
{ icon: HubIcon, t: 'Connect to any of the 15+ nationwide hubs' },
|
{ icon: Network, t: 'Connect to any of the 15+ nationwide hubs' },
|
||||||
{ icon: AssignmentIndIcon, t: 'Role-based access controls for security' },
|
{ icon: UserCheck, t: 'Role-based access controls for security' },
|
||||||
{ icon: VerifiedUserIcon, t: 'Activity logging and dispatch compliance verification' }
|
{ icon: Shield, t: 'Activity logging and dispatch compliance verification' }
|
||||||
].map((f) => (
|
].map((f) => (
|
||||||
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center">
|
<div key={f.t} style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 34, height: 34, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<f.icon fontSize="small" />
|
<f.icon size={16} />
|
||||||
</Box>
|
</div>
|
||||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</Typography>
|
<div style={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500, fontSize: '0.85rem' }}>{f.t}</div>
|
||||||
</Stack>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 3 }}>
|
<div style={{ color: 'rgba(255,255,255,0.5)', marginTop: '24px', fontSize: '0.75rem' }}>
|
||||||
© 2026 Doormile Logistics Pvt. Ltd.
|
© 2026 Doormile Logistics Pvt. Ltd.
|
||||||
</Typography>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{/* Form Panel */}
|
{/* Form Panel */}
|
||||||
<Box
|
<div
|
||||||
sx={{
|
style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
p: { xs: 3, sm: 6 },
|
padding: '48px',
|
||||||
bgcolor: '#ffffff'
|
backgroundColor: '#ffffff'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<div
|
||||||
elevation={0}
|
style={{
|
||||||
sx={{
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
p: { xs: 3, sm: 4.5 },
|
padding: '36px',
|
||||||
border: '1px solid #eaeaea',
|
border: '1px solid #eaeaea',
|
||||||
borderRadius: 3,
|
borderRadius: 12,
|
||||||
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
|
boxShadow: '0px 4px 24px rgba(0, 0, 0, 0.02)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* BLACK LOGO REPLACEMENT (Mobile View) */}
|
<div className="show-on-mobile" style={{ marginBottom: '24px', filter: 'brightness(0)' }}>
|
||||||
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 3, filter: 'brightness(0)' }}>
|
|
||||||
<Logo />
|
<Logo />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem' }}>Request Access</Typography>
|
<h2 style={{ fontWeight: 700, color: '#111111', fontSize: '1.75rem', margin: '0 0 4px 0' }}>Request Access</h2>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 4 }}>
|
<p style={{ color: '#64748b', fontSize: '0.875rem', margin: '0 0 32px 0' }}>
|
||||||
Register your credentials to join hub operations.
|
Register your credentials to join hub operations.
|
||||||
</Typography>
|
</p>
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSignUp}>
|
<form onSubmit={handleSignUp} style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
<Stack spacing={3}>
|
<div>
|
||||||
<Box>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Full Name</div>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Full Name</Typography>
|
<TextInput style={{ width: '100%' }} required placeholder="Enter full name" value={name} onChange={setName} />
|
||||||
<TextField fullWidth required placeholder="Enter full name" value={name} onChange={(e) => setName(e.target.value)} />
|
</div>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Work Email</Typography>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Work Email</div>
|
||||||
<TextField fullWidth required type="email" placeholder="Enter work email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
<TextInput style={{ width: '100%' }} required type="email" placeholder="Enter work email" value={email} onChange={setEmail} />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
<Grid size={{ xs: 12, sm: 6 }} >
|
<div>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Assign Hub</Typography>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Assign Hub</div>
|
||||||
<FormControl fullWidth>
|
<select
|
||||||
<Select value={hub} onChange={(e) => setHub(e.target.value)}>
|
value={hub}
|
||||||
{HUBS.map((h) => (
|
onChange={(e) => setHub(e.target.value)}
|
||||||
<MenuItem key={h.value} value={h.value}>{h.label}</MenuItem>
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.875rem' }}
|
||||||
))}
|
>
|
||||||
</Select>
|
{HUBS.map((h) => (
|
||||||
</FormControl>
|
<option key={h.value} value={h.value}>{h.label}</option>
|
||||||
</Grid>
|
))}
|
||||||
<Grid size={{ xs: 12, sm: 6 }} >
|
</select>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Select Role</Typography>
|
</div>
|
||||||
<FormControl fullWidth>
|
<div>
|
||||||
<Select value={role} onChange={(e) => setRole(e.target.value)}>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Select Role</div>
|
||||||
{ROLES.map((r) => (
|
<select
|
||||||
<MenuItem key={r.value} value={r.value}>{r.label}</MenuItem>
|
value={role}
|
||||||
))}
|
onChange={(e) => setRole(e.target.value)}
|
||||||
</Select>
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.875rem' }}
|
||||||
</FormControl>
|
>
|
||||||
</Grid>
|
{ROLES.map((r) => (
|
||||||
</Grid>
|
<option key={r.value} value={r.value}>{r.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Password</Typography>
|
<div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Password</div>
|
||||||
<TextField
|
<div style={{ position: 'relative' }}>
|
||||||
fullWidth
|
<TextInput
|
||||||
|
style={{ width: '100%', paddingRight: '40px' }}
|
||||||
required
|
required
|
||||||
type={show ? 'text' : 'password'}
|
type={show ? 'text' : 'password'}
|
||||||
placeholder="Create password"
|
placeholder="Create password"
|
||||||
value={pwd}
|
value={pwd}
|
||||||
onChange={(e) => setPwd(e.target.value)}
|
onChange={setPwd}
|
||||||
InputProps={{
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
|
||||||
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShow(!show)}
|
||||||
|
style={{ position: 'absolute', right: 12, top: 8, background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
|
||||||
|
>
|
||||||
|
{show ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormControlLabel
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
|
||||||
control={<Checkbox checked={agree} onChange={(e) => setAgree(e.target.checked)} size="small" sx={{ color: '#C01227', '&.Mui-checked': { color: '#C01227' } }} required />}
|
<CheckboxInput checked={agree} onCheckedChange={setAgree} required style={{ marginTop: '2px' }} />
|
||||||
label={
|
<div style={{ fontSize: '0.875rem', color: '#64748b', lineHeight: 1.4 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
I agree to the{' '}
|
||||||
I agree to the{' '}
|
<a href="#" style={{ color: 'var(--color-brand)', fontWeight: 500, textDecoration: 'none' }}>Terms of Service</a> and{' '}
|
||||||
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Terms of Service</Link> and{' '}
|
<a href="#" style={{ color: 'var(--color-brand)', fontWeight: 500, textDecoration: 'none' }}>Operations Guidelines</a>.
|
||||||
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Operations Guidelines</Link>.
|
</div>
|
||||||
</Typography>
|
</div>
|
||||||
}
|
|
||||||
/>
|
<Button
|
||||||
|
variant="primary"
|
||||||
<Button
|
type="submit"
|
||||||
fullWidth
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
size="large"
|
>
|
||||||
variant="contained"
|
Register Account
|
||||||
type="submit"
|
</Button>
|
||||||
sx={{
|
|
||||||
bgcolor: '#C01227',
|
<div style={{ textAlign: 'center', marginTop: '8px', fontSize: '0.875rem', color: '#64748b' }}>
|
||||||
color: '#fff',
|
Already have an account?{' '}
|
||||||
py: 1.5,
|
<a
|
||||||
fontWeight: 600,
|
href="#"
|
||||||
borderRadius: 2,
|
onClick={(e) => { e.preventDefault(); navigate('/login'); }}
|
||||||
textTransform: 'none',
|
style={{ color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}
|
||||||
boxShadow: 'none',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: '#9E0E20',
|
|
||||||
boxShadow: 'none'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Register Account
|
Sign In
|
||||||
</Button>
|
</a>
|
||||||
|
</div>
|
||||||
<Box sx={{ textAlign: 'center', mt: 1 }}>
|
</form>
|
||||||
<Typography variant="body2" color="text.secondary">
|
</div>
|
||||||
Already have an account?{' '}
|
</div>
|
||||||
<Link
|
<style>{`
|
||||||
href="#"
|
@media (max-width: 768px) {
|
||||||
onClick={(e) => { e.preventDefault(); navigate('/login'); }}
|
.hide-on-mobile { display: none !important; }
|
||||||
underline="hover"
|
}
|
||||||
sx={{ color: '#C01227', fontWeight: 600 }}
|
@media (min-width: 769px) {
|
||||||
>
|
.show-on-mobile { display: none !important; }
|
||||||
Sign In
|
}
|
||||||
</Link>
|
`}</style>
|
||||||
</Typography>
|
</div>
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,58 +1,57 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
/* eslint-disable react/prop-types */
|
||||||
import {
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
Box,
|
import { Truck, Plus, ArrowRight } from 'lucide-react';
|
||||||
Typography,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Grid,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
MenuItem,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Snackbar,
|
|
||||||
Alert,
|
|
||||||
Avatar,
|
|
||||||
Divider,
|
|
||||||
useMediaQuery,
|
|
||||||
CircularProgress
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import EastRoundedIcon from '@mui/icons-material/EastRounded';
|
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|
||||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
|
||||||
import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
|
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||||
|
import { HStack, VStack } from '@astryxdesign/core/Layout';
|
||||||
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
|
import { useToast } from '@astryxdesign/core/Toast';
|
||||||
|
|
||||||
|
import Panel from '@/components/Panel';
|
||||||
|
import Button from '@/components/Button';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
|
import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
|
||||||
import { getBatchesRange, createBatch, updateBatchStatus } from '@/api/hub';
|
import { getBatchesRange, createBatch, updateBatchStatus } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
|
function useMediaQuery(query) {
|
||||||
|
const [matches, setMatches] = useState(window.matchMedia(query).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
if (media.matches !== matches) {
|
||||||
|
setMatches(media.matches);
|
||||||
|
}
|
||||||
|
const listener = () => setMatches(media.matches);
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, [matches, query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_META = {
|
const STATUS_META = {
|
||||||
Preparing: { color: '#B06000', bg: '#FEF7E0', label: 'Preparing' },
|
Preparing: { variant: 'warning', label: 'Preparing' },
|
||||||
Ready: { color: '#1A73E8', bg: '#E8F0FE', label: 'Ready to send' },
|
Ready: { variant: 'info', label: 'Ready to send' },
|
||||||
Sent: { color: '#1E8E3E', bg: '#E6F4EA', label: 'Sent' }
|
Sent: { variant: 'success', label: 'Sent' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// API batch status (Draft/Ready/Dispatched) → the label set this page renders.
|
|
||||||
const API_TO_UI_STATUS = { Draft: 'Preparing', Ready: 'Ready', Dispatched: 'Sent' };
|
const API_TO_UI_STATUS = { Draft: 'Preparing', Ready: 'Ready', Dispatched: 'Sent' };
|
||||||
|
|
||||||
|
const ROUTE_OPTIONS = [
|
||||||
|
{ value: 'Transfer to Mumbai Hub', label: 'Transfer to another city — Mumbai Hub' },
|
||||||
|
{ value: 'Transfer to Bengaluru Hub', label: 'Transfer to another city — Bengaluru Hub' },
|
||||||
|
{ value: 'Local Delivery: Dwarka', label: 'Local Delivery — Dwarka' },
|
||||||
|
{ value: 'Local Delivery: Saket', label: 'Local Delivery — Saket' },
|
||||||
|
{ value: 'Local Delivery: Rohini', label: 'Local Delivery — Rohini' }
|
||||||
|
];
|
||||||
|
|
||||||
const timeAgo = (iso, verb = 'Created') => {
|
const timeAgo = (iso, verb = 'Created') => {
|
||||||
if (!iso) return `${verb} recently`;
|
if (!iso) return `${verb} recently`;
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
@@ -65,13 +64,22 @@ const timeAgo = (iso, verb = 'Created') => {
|
|||||||
return `${verb} ${Math.round(hrs / 24)} d ago`;
|
return `${verb} ${Math.round(hrs / 24)} d ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Route/destination color per batch status — same categorical tokens as
|
||||||
|
// Badge, so the highlighted destination reads as part of the same status
|
||||||
|
// language as the rest of the app instead of a one-off hex value.
|
||||||
|
const destinationTone = (variant) => {
|
||||||
|
if (variant === 'warning') return 'var(--color-warning)';
|
||||||
|
if (variant === 'info') return 'var(--color-text-primary)';
|
||||||
|
return 'var(--color-icon-green)';
|
||||||
|
};
|
||||||
|
|
||||||
export default function Dispatch() {
|
export default function Dispatch() {
|
||||||
const theme = useTheme();
|
const isMdDown = useMediaQuery('(max-width: 900px)');
|
||||||
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
const hubName = hub.hubname || 'this hub';
|
const hubName = hub.hubname || 'this hub';
|
||||||
|
const toast = useToast();
|
||||||
|
const notify = (msg, status = 'success') => toast({ body: msg, type: status });
|
||||||
|
|
||||||
// Real backend: tripsheetno / route / destination / item_count / kind (no vehicle field).
|
|
||||||
const mapBatch = useCallback(
|
const mapBatch = useCallback(
|
||||||
(b) => ({
|
(b) => ({
|
||||||
tripsheetid: b.tripsheetid,
|
tripsheetid: b.tripsheetid,
|
||||||
@@ -96,7 +104,6 @@ export default function Dispatch() {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [busyId, setBusyId] = useState(null);
|
const [busyId, setBusyId] = useState(null);
|
||||||
|
|
||||||
// Date range for the batch history. Defaults to today.
|
|
||||||
const today = dayjs().format(DATE_FMT);
|
const today = dayjs().format(DATE_FMT);
|
||||||
const [range, setRange] = useState({ from: today, to: today });
|
const [range, setRange] = useState({ from: today, to: today });
|
||||||
const isToday = range.from === today && range.to === today;
|
const isToday = range.from === today && range.to === today;
|
||||||
@@ -106,15 +113,11 @@ export default function Dispatch() {
|
|||||||
? dayjs(range.from).format('DD MMM')
|
? dayjs(range.from).format('DD MMM')
|
||||||
: `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`;
|
: `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`;
|
||||||
|
|
||||||
// Create form state
|
|
||||||
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
|
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
|
||||||
const [newDestination, setNewDestination] = useState('');
|
const [newDestination, setNewDestination] = useState('');
|
||||||
const [newVehicle, setNewVehicle] = useState('');
|
const [newVehicle, setNewVehicle] = useState('');
|
||||||
const [newPkgsCount, setNewPkgsCount] = useState('5');
|
const [newPkgsCount, setNewPkgsCount] = useState('5');
|
||||||
|
|
||||||
// Toast state
|
|
||||||
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
@@ -136,7 +139,7 @@ export default function Dispatch() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (creating) return;
|
if (creating) return;
|
||||||
if (!newVehicle.trim() || !newDestination.trim()) {
|
if (!newVehicle.trim() || !newDestination.trim()) {
|
||||||
setToast({ open: true, msg: 'Enter the destination and the miler / vehicle.', severity: 'warning' });
|
notify('Enter the destination and the miler / vehicle.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local';
|
const kind = newRoute.toLowerCase().startsWith('transfer') ? 'transfer' : 'local';
|
||||||
@@ -153,16 +156,15 @@ export default function Dispatch() {
|
|||||||
setOpenModal(false);
|
setOpenModal(false);
|
||||||
setNewVehicle('');
|
setNewVehicle('');
|
||||||
setNewDestination('');
|
setNewDestination('');
|
||||||
setToast({ open: true, msg: `Batch ${label} created successfully`, severity: 'success' });
|
notify(`Batch ${label} created successfully`);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setToast({ open: true, msg: err?.message || 'Could not create the batch.', severity: 'error' });
|
notify(err?.message || 'Could not create the batch.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move a batch to the next status via PATCH.
|
|
||||||
const advance = async (m, apiStatus, uiStatus, okMsg) => {
|
const advance = async (m, apiStatus, uiStatus, okMsg) => {
|
||||||
if (busyId) return;
|
if (busyId) return;
|
||||||
setBusyId(m.tripsheetid);
|
setBusyId(m.tripsheetid);
|
||||||
@@ -175,9 +177,9 @@ export default function Dispatch() {
|
|||||||
: x
|
: x
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setToast({ open: true, msg: okMsg, severity: 'success' });
|
notify(okMsg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setToast({ open: true, msg: err?.message || 'Could not update this batch.', severity: 'error' });
|
notify(err?.message || 'Could not update this batch.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -186,245 +188,259 @@ export default function Dispatch() {
|
|||||||
const handleSeal = (m) => advance(m, 'Ready', 'Ready', `Batch ${m.id} checked and ready to send.`);
|
const handleSeal = (m) => advance(m, 'Ready', 'Ready', `Batch ${m.id} checked and ready to send.`);
|
||||||
const handleDispatch = (m) => advance(m, 'Dispatched', 'Sent', `Batch ${m.id} sent out! The miler/driver has been notified.`);
|
const handleDispatch = (m) => advance(m, 'Dispatched', 'Sent', `Batch ${m.id} sent out! The miler/driver has been notified.`);
|
||||||
|
|
||||||
// Action button shown for each batch based on its status
|
|
||||||
const BatchAction = ({ m, fullWidth }) => {
|
const BatchAction = ({ m, fullWidth }) => {
|
||||||
const isBusy = busyId === m.tripsheetid;
|
const isBusy = busyId === m.tripsheetid;
|
||||||
if (m.status === 'Preparing') {
|
if (m.status === 'Preparing') {
|
||||||
return (
|
return (
|
||||||
<Button size="small" variant="outlined" color="info" fullWidth={fullWidth} disabled={isBusy} onClick={() => handleSeal(m)}
|
<Button variant="secondary" size="sm" style={{ width: fullWidth ? '100%' : 'auto', justifyContent: 'center' }} disabled={isBusy} onClick={() => handleSeal(m)}>
|
||||||
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : null} sx={{ borderRadius: 2, fontWeight: 700 }}>
|
{isBusy ? 'Working...' : 'Check & Mark Ready'}
|
||||||
Check & Mark Ready
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (m.status === 'Ready') {
|
if (m.status === 'Ready') {
|
||||||
return (
|
return (
|
||||||
<Button size="small" variant="contained" color="success" fullWidth={fullWidth} disabled={isBusy}
|
<Button variant="primary" size="sm" style={{ width: fullWidth ? '100%' : 'auto', justifyContent: 'center' }} disabled={isBusy} onClick={() => handleDispatch(m)}>
|
||||||
startIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : <SendIcon sx={{ fontSize: 16 }} />} onClick={() => handleDispatch(m)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}>
|
{isBusy ? 'Working...' : 'Send Out'}
|
||||||
Send Out
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <Chip size="small" icon={<CheckCircleIcon />} label="Sent" color="success" variant="outlined" sx={{ fontWeight: 700 }} />;
|
return <Badge variant="success" label="Sent" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Origin → destination journey, reused in cards and table
|
|
||||||
const Journey = ({ m }) => {
|
const Journey = ({ m }) => {
|
||||||
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
||||||
return (
|
return (
|
||||||
<Stack spacing={0.75}>
|
<VStack gap={0.5}>
|
||||||
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75 }}>
|
<HStack gap={1.5} align="center" wrap="wrap">
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{m.origin}</Typography>
|
<Text type="body" weight="bold">{m.origin}</Text>
|
||||||
<EastRoundedIcon sx={{ fontSize: 16, color: '#ADB5BD' }} />
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><ArrowRight size={14} /></span>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: meta.color }}>{m.destination}</Typography>
|
<Text type="body" weight="bold" style={{ color: destinationTone(meta.variant) }}>{m.destination}</Text>
|
||||||
</Stack>
|
</HStack>
|
||||||
<Typography variant="caption" color="text.secondary">{m.currentLoc}</Typography>
|
<Text type="supporting" color="secondary">{m.currentLoc}</Text>
|
||||||
</Stack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'batch',
|
||||||
|
header: 'Batch',
|
||||||
|
width: proportional(1.2),
|
||||||
|
renderCell: (row) => (
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
|
||||||
|
<Text type="supporting" color="secondary">{row.time}</Text>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'journey',
|
||||||
|
header: 'Journey',
|
||||||
|
width: proportional(2),
|
||||||
|
renderCell: (row) => <Journey m={row} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vehicle',
|
||||||
|
header: 'Miler / Vehicle',
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (row) => <Text type="body" weight="semibold">{row.vehicle}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'parcels',
|
||||||
|
header: 'Parcels',
|
||||||
|
width: pixel(90),
|
||||||
|
renderCell: (row) => <Text type="body" weight="bold">{row.packagesCount}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
width: pixel(120),
|
||||||
|
renderCell: (row) => {
|
||||||
|
const meta = STATUS_META[row.status] || STATUS_META.Preparing;
|
||||||
|
return <Badge variant={meta.variant} label={meta.label} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
width: pixel(160),
|
||||||
|
align: 'end',
|
||||||
|
renderCell: (row) => <BatchAction m={row} />
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<div style={{ paddingBottom: '32px' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={LocalShippingIcon}
|
icon={Truck}
|
||||||
title="Dispatch & Transfer"
|
title="Dispatch & Transfer"
|
||||||
subtitle="Group parcels that go out together, check them, and send them either out for local delivery or transferred to another city hub."
|
subtitle="Group parcels that go out together, check them, and send them either out for local delivery or transferred to another city hub."
|
||||||
action={<DateRangePicker value={range} onChange={setRange} />}
|
action={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Panel>
|
||||||
{/* Manifest Actions & List */}
|
<div
|
||||||
<Grid size={{ xs: 12 }} >
|
style={{
|
||||||
<Card>
|
padding: '16px 20px',
|
||||||
<CardHeader
|
borderBottom: '1px solid var(--color-border)',
|
||||||
title="Outgoing Batches"
|
display: 'flex',
|
||||||
subheader={
|
alignItems: 'center',
|
||||||
isToday
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack gap={3} align="center">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-background-blue)',
|
||||||
|
color: 'var(--color-icon-blue)',
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Truck size={18} />
|
||||||
|
</span>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Heading level={4} style={{ margin: 0 }}>Outgoing Batches</Heading>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{isToday
|
||||||
? `Each batch is a group of parcels leaving ${hubName} together`
|
? `Each batch is a group of parcels leaving ${hubName} together`
|
||||||
: `${manifests.length} batch${manifests.length === 1 ? '' : 'es'} · ${rangeLabel}`
|
: `${manifests.length} batch${manifests.length === 1 ? '' : 'es'} · ${rangeLabel}`}
|
||||||
}
|
</Text>
|
||||||
action={
|
</VStack>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}>
|
</HStack>
|
||||||
New Batch
|
<Button variant="primary" onClick={() => setOpenModal(true)} icon={<Plus size={16} />}>
|
||||||
</Button>
|
New Batch
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{loadError && (
|
|
||||||
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
|
|
||||||
{loadError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
) : manifests.length === 0 && !loadError ? (
|
|
||||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
|
||||||
<LocalShippingIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : isMdDown ? (
|
|
||||||
/* ── MOBILE / TABLET: spacious cards ── */
|
|
||||||
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
{manifests.map((m) => {
|
|
||||||
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
|
||||||
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon;
|
|
||||||
return (
|
|
||||||
<Card key={m.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}>
|
|
||||||
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }} gap={1}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={2.25} sx={{ minWidth: 0 }}>
|
|
||||||
<Avatar variant="rounded" sx={{ bgcolor: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}>
|
|
||||||
<KindIcon sx={{ fontSize: 20 }} />
|
|
||||||
</Avatar>
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{m.id}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{m.time}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<Chip size="small" label={meta.label} sx={{ fontWeight: 700, bgcolor: meta.bg, color: meta.color, flexShrink: 0 }} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Box sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
|
|
||||||
<Journey m={m} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}>
|
|
||||||
<Chip size="small" icon={<TwoWheelerOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={m.vehicle}
|
|
||||||
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, maxWidth: '100%', '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
|
||||||
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '15px !important' }} />} label={`${m.packagesCount} parcels`}
|
|
||||||
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<BatchAction m={m} fullWidth />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
/* ── DESKTOP: spacious table ── */
|
|
||||||
<TableContainer sx={{ overflowX: 'auto' }}>
|
|
||||||
<Table sx={{ minWidth: 820 }}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
|
||||||
{['Batch', 'Journey', 'Miler / Vehicle', 'Parcels', 'Status', 'Action'].map((h, i) => (
|
|
||||||
<TableCell key={h} align={i === 3 ? 'center' : i === 5 ? 'right' : 'left'}
|
|
||||||
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
|
|
||||||
{h}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{manifests.map((m) => {
|
|
||||||
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
|
||||||
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon;
|
|
||||||
return (
|
|
||||||
<TableRow key={m.id} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8', py: 2.25 }, '&:last-child td': { border: 0 } }}>
|
|
||||||
<TableCell>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={2.25}>
|
|
||||||
<Avatar variant="rounded" sx={{ bgcolor: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}>
|
|
||||||
<KindIcon sx={{ fontSize: 20 }} />
|
|
||||||
</Avatar>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{m.id}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{m.time}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ minWidth: 240 }}><Journey m={m} /></TableCell>
|
|
||||||
<TableCell sx={{ color: '#495057', fontWeight: 600, maxWidth: 200 }}>{m.vehicle}</TableCell>
|
|
||||||
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 700, color: '#1A1A2E' }}>{m.packagesCount}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={meta.label} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: meta.bg, color: meta.color }} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right"><BatchAction m={m} /></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Creation Modal */}
|
|
||||||
<Dialog open={openModal} onClose={() => setOpenModal(false)} fullWidth maxWidth="xs">
|
|
||||||
<DialogTitle sx={{ fontWeight: 700 }}>Create a New Batch</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box component="form" onSubmit={handleCreateManifest} sx={{ mt: 1 }}>
|
|
||||||
<Stack spacing={2.5}>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
fullWidth
|
|
||||||
label="Where is this batch going?"
|
|
||||||
value={newRoute}
|
|
||||||
onChange={(e) => setNewRoute(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value="Transfer to Mumbai Hub">Transfer to another city — Mumbai Hub</MenuItem>
|
|
||||||
<MenuItem value="Transfer to Bengaluru Hub">Transfer to another city — Bengaluru Hub</MenuItem>
|
|
||||||
<MenuItem value="Local Delivery: Dwarka">Local Delivery — Dwarka</MenuItem>
|
|
||||||
<MenuItem value="Local Delivery: Saket">Local Delivery — Saket</MenuItem>
|
|
||||||
<MenuItem value="Local Delivery: Rohini">Local Delivery — Rohini</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Full destination address / hub"
|
|
||||||
placeholder="e.g. Dwarka Sector 12, Delhi"
|
|
||||||
value={newDestination}
|
|
||||||
onChange={(e) => setNewDestination(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Miler name or vehicle number"
|
|
||||||
placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092"
|
|
||||||
value={newVehicle}
|
|
||||||
onChange={(e) => setNewVehicle(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
type="number"
|
|
||||||
label="How many parcels?"
|
|
||||||
value={newPkgsCount}
|
|
||||||
onChange={(e) => setNewPkgsCount(e.target.value)}
|
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button>
|
|
||||||
<Button variant="contained" onClick={handleCreateManifest} disabled={creating}
|
|
||||||
startIcon={creating ? <CircularProgress size={16} color="inherit" /> : null}>
|
|
||||||
{creating ? 'Creating…' : 'Create Batch'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</div>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Snackbar
|
{loadError && (
|
||||||
open={toast.open}
|
<div style={{ padding: '16px' }}>
|
||||||
autoHideDuration={4000}
|
<Banner status="error" title="Error" description={loadError} />
|
||||||
onClose={() => setToast({ ...toast, open: false })}
|
</div>
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
)}
|
||||||
>
|
|
||||||
<Alert severity={toast.severity} onClose={() => setToast({ ...toast, open: false })} sx={{ width: '100%' }}>
|
{loading ? (
|
||||||
{toast.msg}
|
<div style={{ padding: '48px', textAlign: 'center' }}>
|
||||||
</Alert>
|
<Text type="body" color="secondary">Loading...</Text>
|
||||||
</Snackbar>
|
</div>
|
||||||
</Box>
|
) : manifests.length === 0 && !loadError ? (
|
||||||
|
<div style={{ padding: '48px', textAlign: 'center' }}>
|
||||||
|
<Text type="body" color="secondary">
|
||||||
|
{isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : isMdDown ? (
|
||||||
|
/* ── MOBILE / TABLET: cards ── */
|
||||||
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{manifests.map((m) => {
|
||||||
|
const meta = STATUS_META[m.status] || STATUS_META.Preparing;
|
||||||
|
return (
|
||||||
|
<Card key={m.id} padding={3} style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
<HStack justify="between" align="center" style={{ marginBottom: '10px' }}>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{m.id}</Text>
|
||||||
|
<Text type="supporting" color="secondary">{m.time}</Text>
|
||||||
|
</VStack>
|
||||||
|
<Badge variant={meta.variant} label={meta.label} />
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<div style={{ padding: '10px 12px', background: 'var(--color-background-muted)', borderRadius: 'var(--radius-inner)', marginBottom: '12px' }}>
|
||||||
|
<Journey m={m} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HStack gap={4} style={{ marginBottom: '12px' }}>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="supporting" color="secondary">Miler / Vehicle</Text>
|
||||||
|
<Text type="body" weight="semibold">{m.vehicle}</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="supporting" color="secondary">Parcels</Text>
|
||||||
|
<Text type="body" weight="semibold">{m.packagesCount}</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<BatchAction m={m} fullWidth />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── DESKTOP: table ── */
|
||||||
|
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||||
|
<Table
|
||||||
|
data={manifests}
|
||||||
|
columns={columns}
|
||||||
|
idKey="tripsheetid"
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Create Batch Dialog */}
|
||||||
|
<Dialog isOpen={openModal} onOpenChange={setOpenModal} width={480}>
|
||||||
|
<form onSubmit={handleCreateManifest}>
|
||||||
|
<Layout
|
||||||
|
header={<DialogHeader title="Create a New Batch" onOpenChange={setOpenModal} />}
|
||||||
|
content={
|
||||||
|
<LayoutContent>
|
||||||
|
<VStack gap={4} style={{ padding: '24px' }}>
|
||||||
|
<Selector
|
||||||
|
label="Where is this batch going?"
|
||||||
|
options={ROUTE_OPTIONS}
|
||||||
|
value={newRoute}
|
||||||
|
onChange={setNewRoute}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Full destination address / hub"
|
||||||
|
placeholder="e.g. Dwarka Sector 12, Delhi"
|
||||||
|
value={newDestination}
|
||||||
|
onChange={(e) => setNewDestination(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Miler name or vehicle number"
|
||||||
|
placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092"
|
||||||
|
value={newVehicle}
|
||||||
|
onChange={(e) => setNewVehicle(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
label="How many parcels?"
|
||||||
|
value={newPkgsCount}
|
||||||
|
onChange={(e) => setNewPkgsCount(e.target.value)}
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<LayoutFooter hasDivider>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', width: '100%', padding: '16px 24px' }}>
|
||||||
|
<Button variant="ghost" onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button>
|
||||||
|
<Button variant="primary" type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create Batch'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</LayoutFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
/* eslint-disable react/prop-types */
|
||||||
import {
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
Box, Card, CardContent, CardHeader, Button, Stack, Typography, Divider, Avatar, Chip,
|
import { Settings, Warehouse, Plus, UserPlus, Eye, EyeOff } from 'lucide-react';
|
||||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, useMediaQuery,
|
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, InputAdornment,
|
|
||||||
IconButton, Snackbar, Alert, CircularProgress
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
|
||||||
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import PersonAddAlt1OutlinedIcon from '@mui/icons-material/PersonAddAlt1Outlined';
|
|
||||||
import Visibility from '@mui/icons-material/Visibility';
|
|
||||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
|
||||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
|
||||||
import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined';
|
|
||||||
|
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { HStack, VStack } from '@astryxdesign/core/Layout';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
|
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||||
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
|
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||||
|
import { useToast } from '@astryxdesign/core/Toast';
|
||||||
|
|
||||||
|
import Panel from '@/components/Panel';
|
||||||
|
import Button from '@/components/Button';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import { getHubs, createHub, createStaff } from '@/api/hub';
|
import { getHubs, createHub, createStaff } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
const BRAND = '#C01227';
|
function useMediaQuery(query) {
|
||||||
|
const [matches, setMatches] = useState(window.matchMedia(query).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
if (media.matches !== matches) {
|
||||||
|
setMatches(media.matches);
|
||||||
|
}
|
||||||
|
const listener = () => setMatches(media.matches);
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, [matches, query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
const HUB_TYPES = [
|
const HUB_TYPES = [
|
||||||
{ value: 'sorting_center', label: 'Sorting Center' },
|
{ value: 'sorting_center', label: 'Sorting Center' },
|
||||||
{ value: 'delivery_hub', label: 'Delivery Hub' },
|
{ value: 'delivery_hub', label: 'Delivery Hub' },
|
||||||
@@ -27,20 +45,24 @@ const HUB_TYPES = [
|
|||||||
{ value: 'warehouse', label: 'Warehouse' }
|
{ value: 'warehouse', label: 'Warehouse' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const EMPTY_HUB = { hubname: '', hubtype: 'spoke', capacity: '30', contact: '', address: '', pincode: '' };
|
const EMPTY_HUB = { hubname: '', hubtype: 'spoke', capacity: 30, contact: '', address: '', pincode: '' };
|
||||||
const EMPTY_STAFF = { hubid: '', email: '', password: '', displayname: '' };
|
const EMPTY_STAFF = { hubid: '', email: '', password: '', displayname: '' };
|
||||||
|
|
||||||
const prettyType = (t) => HUB_TYPES.find((h) => h.value === t)?.label || t || '—';
|
const prettyType = (t) => HUB_TYPES.find((h) => h.value === t)?.label || t || '—';
|
||||||
|
|
||||||
|
function StaffBadge({ has }) {
|
||||||
|
return has ? <Badge variant="success" label="Has login" /> : <Badge variant="warning" label="No login" />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HubSettings() {
|
export default function HubSettings() {
|
||||||
const theme = useTheme();
|
const isMdDown = useMediaQuery('(max-width: 900px)');
|
||||||
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
|
const toast = useToast();
|
||||||
|
const notify = (msg, status = 'success') => toast({ body: msg, type: status });
|
||||||
|
|
||||||
const [hubs, setHubs] = useState([]);
|
const [hubs, setHubs] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
|
||||||
|
|
||||||
const [hubDialog, setHubDialog] = useState(false);
|
const [hubDialog, setHubDialog] = useState(false);
|
||||||
const [hubForm, setHubForm] = useState(EMPTY_HUB);
|
const [hubForm, setHubForm] = useState(EMPTY_HUB);
|
||||||
@@ -51,8 +73,6 @@ export default function HubSettings() {
|
|||||||
const [showPwd, setShowPwd] = useState(false);
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
const [savingStaff, setSavingStaff] = useState(false);
|
const [savingStaff, setSavingStaff] = useState(false);
|
||||||
|
|
||||||
const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity });
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
@@ -80,7 +100,8 @@ export default function HubSettings() {
|
|||||||
setStaffDialog(true);
|
setStaffDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitHub = async () => {
|
const submitHub = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
if (savingHub) return;
|
if (savingHub) return;
|
||||||
if (!hubForm.hubname.trim()) {
|
if (!hubForm.hubname.trim()) {
|
||||||
notify('Enter a hub name.', 'warning');
|
notify('Enter a hub name.', 'warning');
|
||||||
@@ -91,7 +112,7 @@ export default function HubSettings() {
|
|||||||
await createHub({
|
await createHub({
|
||||||
hubname: hubForm.hubname.trim(),
|
hubname: hubForm.hubname.trim(),
|
||||||
hubtype: hubForm.hubtype,
|
hubtype: hubForm.hubtype,
|
||||||
capacity: parseInt(hubForm.capacity, 10) || 0,
|
capacity: hubForm.capacity || 0,
|
||||||
contact: hubForm.contact.trim(),
|
contact: hubForm.contact.trim(),
|
||||||
address: hubForm.address.trim(),
|
address: hubForm.address.trim(),
|
||||||
pincode: hubForm.pincode.trim()
|
pincode: hubForm.pincode.trim()
|
||||||
@@ -106,7 +127,8 @@ export default function HubSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitStaff = async () => {
|
const submitStaff = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
if (savingStaff) return;
|
if (savingStaff) return;
|
||||||
if (!staffForm.hubid || !staffForm.email.trim() || !staffForm.password) {
|
if (!staffForm.hubid || !staffForm.email.trim() || !staffForm.password) {
|
||||||
notify('Hub, email and password are all required.', 'warning');
|
notify('Hub, email and password are all required.', 'warning');
|
||||||
@@ -131,184 +153,209 @@ export default function HubSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const StaffChip = ({ has }) =>
|
const columns = useMemo(() => [
|
||||||
has ? (
|
{
|
||||||
<Chip size="small" icon={<CheckCircleOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="Has login"
|
key: 'hubname',
|
||||||
sx={{ fontWeight: 700, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />
|
header: 'Hub',
|
||||||
) : (
|
width: proportional(1.4),
|
||||||
<Chip size="small" icon={<HighlightOffOutlinedIcon sx={{ fontSize: '15px !important' }} />} label="No login"
|
renderCell: (h) => <Text type="body" weight="bold">{h.hubname}</Text>
|
||||||
sx={{ fontWeight: 700, bgcolor: '#FEF7E0', color: '#B06000', '& .MuiChip-icon': { color: '#B06000' } }} />
|
},
|
||||||
);
|
{
|
||||||
|
key: 'hubtype',
|
||||||
|
header: 'Type',
|
||||||
|
width: proportional(1),
|
||||||
|
renderCell: (h) => <Text type="body">{prettyType(h.hubtype)}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'capacity',
|
||||||
|
header: 'Capacity',
|
||||||
|
width: pixel(110),
|
||||||
|
renderCell: (h) => <Text type="body">{h.capacity}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
width: pixel(120),
|
||||||
|
renderCell: (h) => <Badge variant="blue" label={h.status || 'active'} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'staff',
|
||||||
|
header: 'Staff Login',
|
||||||
|
width: pixel(140),
|
||||||
|
renderCell: (h) => <StaffBadge has={h.has_staff ?? h.has_staff_account} />
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<div style={{ paddingBottom: '32px' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={SettingsRoundedIcon}
|
icon={Settings}
|
||||||
title="Hub Settings"
|
title="Hub Settings"
|
||||||
subtitle={`Create and manage hubs and staff logins${hub.city ? ` across ${hub.city}` : ''}. Doormile staff only.`}
|
subtitle={`Create and manage hubs and staff logins${hub.city ? ` across ${hub.city}` : ''}. Doormile staff only.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Panel>
|
||||||
<CardHeader
|
<div
|
||||||
title={`Hubs in ${hub.city || 'your city'}`}
|
style={{
|
||||||
subheader="Every hub Doormile operates here"
|
padding: '16px 20px',
|
||||||
avatar={<Avatar variant="rounded" sx={{ bgcolor: '#C0122710', color: BRAND, borderRadius: 2 }}><WarehouseOutlinedIcon /></Avatar>}
|
borderBottom: '1px solid var(--color-border)',
|
||||||
action={
|
display: 'flex',
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
alignItems: 'center',
|
||||||
<Button variant="outlined" startIcon={<PersonAddAlt1OutlinedIcon />} onClick={openStaffDialog} disabled={hubs.length === 0}
|
justifyContent: 'space-between',
|
||||||
sx={{ borderRadius: 2, fontWeight: 700 }}>
|
flexWrap: 'wrap',
|
||||||
Add Staff
|
gap: '16px'
|
||||||
</Button>
|
}}
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openHubDialog}
|
>
|
||||||
sx={{ borderRadius: 2, fontWeight: 700, bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
|
<HStack gap={3} align="center">
|
||||||
Add New Hub
|
<span
|
||||||
</Button>
|
style={{
|
||||||
</Stack>
|
background: 'var(--color-background-blue)',
|
||||||
}
|
color: 'var(--color-icon-blue)',
|
||||||
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
|
width: 40,
|
||||||
/>
|
height: 40,
|
||||||
<Divider />
|
borderRadius: 'var(--radius-element)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Warehouse size={20} />
|
||||||
|
</span>
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text type="body" weight="bold">Hubs in {hub.city || 'your city'}</Text>
|
||||||
|
<Text type="supporting" color="secondary">Every hub Doormile operates here</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Button variant="secondary" icon={<UserPlus size={16} />} onClick={openStaffDialog} disabled={hubs.length === 0}>Add Staff</Button>
|
||||||
|
<Button variant="primary" icon={<Plus size={16} />} onClick={openHubDialog}>Add New Hub</Button>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
|
<div style={{ padding: '16px' }}>
|
||||||
{loadError}
|
<Banner status="error" title="Error" description={loadError} />
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
|
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||||
<CircularProgress />
|
<Spinner label="Loading hubs" size="lg" />
|
||||||
</Box>
|
</div>
|
||||||
) : hubs.length === 0 && !loadError ? (
|
) : hubs.length === 0 && !loadError ? (
|
||||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
<EmptyState
|
||||||
<WarehouseOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
|
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Warehouse size={48} /></span>}
|
||||||
<Typography variant="body2" color="text.secondary">No hubs yet. Add your first hub.</Typography>
|
title="No hubs yet"
|
||||||
</Box>
|
description="Add your first hub to get started."
|
||||||
|
style={{ padding: '48px 0' }}
|
||||||
|
/>
|
||||||
) : isMdDown ? (
|
) : isMdDown ? (
|
||||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{hubs.map((h) => (
|
{hubs.map((h) => (
|
||||||
<Card key={h.hubid} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}>
|
<Card key={h.hubid} padding={3} style={{ border: '1px solid var(--color-border)' }}>
|
||||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
<HStack justify="between" align="start" gap={2}>
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
|
<VStack gap={0}>
|
||||||
<Box>
|
<Text type="body" weight="bold">{h.hubname}</Text>
|
||||||
<Typography sx={{ fontWeight: 800, color: '#1A1A2E' }}>{h.hubname}</Typography>
|
<Text type="supporting" color="secondary">{prettyType(h.hubtype)} · Cap {h.capacity}</Text>
|
||||||
<Typography variant="caption" color="text.secondary">{prettyType(h.hubtype)} · Cap {h.capacity}</Typography>
|
</VStack>
|
||||||
</Box>
|
<StaffBadge has={h.has_staff ?? h.has_staff_account} />
|
||||||
<StaffChip has={(h.has_staff ?? h.has_staff_account)} />
|
</HStack>
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer>
|
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||||
<Table sx={{ minWidth: 640 }}>
|
<Table data={hubs} columns={columns} idKey="hubid" density="balanced" dividers="rows" hasHover />
|
||||||
<TableHead>
|
</div>
|
||||||
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
|
||||||
{['Hub', 'Type', 'Capacity', 'Status', 'Staff Login'].map((h) => (
|
|
||||||
<TableCell key={h} sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 1.75 }}>
|
|
||||||
{h}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{hubs.map((h) => (
|
|
||||||
<TableRow key={h.hubid} hover>
|
|
||||||
<TableCell sx={{ fontWeight: 700, color: '#1A1A2E' }}>{h.hubname}</TableCell>
|
|
||||||
<TableCell>{prettyType(h.hubtype)}</TableCell>
|
|
||||||
<TableCell>{h.capacity}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={h.status || 'active'}
|
|
||||||
sx={{ fontWeight: 700, textTransform: 'capitalize', bgcolor: '#E8F0FE', color: '#1A73E8' }} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell><StaffChip has={(h.has_staff ?? h.has_staff_account)} /></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Panel>
|
||||||
|
|
||||||
{/* Create Hub dialog */}
|
{/* Create Hub dialog */}
|
||||||
<Dialog open={hubDialog} onClose={() => setHubDialog(false)} fullWidth maxWidth="sm">
|
<Dialog isOpen={hubDialog} onOpenChange={setHubDialog} width={480} purpose="form">
|
||||||
<DialogTitle sx={{ fontWeight: 700 }}>Add a New Hub</DialogTitle>
|
<form onSubmit={submitHub}>
|
||||||
<DialogContent>
|
<Layout
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
header={<DialogHeader title="Add a New Hub" onOpenChange={setHubDialog} />}
|
||||||
The city is set automatically from your hub — it can’t be changed here.
|
content={
|
||||||
</Typography>
|
<LayoutContent>
|
||||||
<Stack spacing={2.5} sx={{ mt: 0.5 }}>
|
<VStack gap={4} style={{ padding: '24px' }}>
|
||||||
<TextField fullWidth label="Hub name" value={hubForm.hubname} required
|
<Text type="supporting" color="secondary">The city is set automatically from your hub — it can’t be changed here.</Text>
|
||||||
onChange={(e) => setHubForm((f) => ({ ...f, hubname: e.target.value }))} />
|
<TextInput label="Hub name" value={hubForm.hubname} isRequired onChange={(e) => setHubForm((f) => ({ ...f, hubname: e.target.value }))} />
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
<HStack gap={3} wrap="wrap">
|
||||||
<TextField select fullWidth label="Hub type" value={hubForm.hubtype}
|
<div style={{ flex: '1 1 180px' }}>
|
||||||
onChange={(e) => setHubForm((f) => ({ ...f, hubtype: e.target.value }))}>
|
<Selector label="Hub type" options={HUB_TYPES} value={hubForm.hubtype} onChange={(v) => setHubForm((f) => ({ ...f, hubtype: v }))} />
|
||||||
{HUB_TYPES.map((t) => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
</div>
|
||||||
</TextField>
|
<div style={{ flex: '1 1 140px' }}>
|
||||||
<TextField fullWidth type="number" label="Capacity" value={hubForm.capacity} inputProps={{ min: 0 }}
|
<NumberInput label="Capacity" value={hubForm.capacity} min={0} onChange={(v) => setHubForm((f) => ({ ...f, capacity: v }))} />
|
||||||
onChange={(e) => setHubForm((f) => ({ ...f, capacity: e.target.value }))} />
|
</div>
|
||||||
</Stack>
|
</HStack>
|
||||||
<TextField fullWidth label="Contact number" value={hubForm.contact}
|
<TextInput label="Contact number" value={hubForm.contact} onChange={(e) => setHubForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||||
onChange={(e) => setHubForm((f) => ({ ...f, contact: e.target.value }))} />
|
<TextInput label="Address" value={hubForm.address} onChange={(e) => setHubForm((f) => ({ ...f, address: e.target.value }))} />
|
||||||
<TextField fullWidth label="Address" value={hubForm.address}
|
<TextInput label="Pincode" value={hubForm.pincode} onChange={(e) => setHubForm((f) => ({ ...f, pincode: e.target.value }))} />
|
||||||
onChange={(e) => setHubForm((f) => ({ ...f, address: e.target.value }))} />
|
</VStack>
|
||||||
<TextField fullWidth label="Pincode" value={hubForm.pincode}
|
</LayoutContent>
|
||||||
onChange={(e) => setHubForm((f) => ({ ...f, pincode: e.target.value }))} />
|
}
|
||||||
</Stack>
|
footer={
|
||||||
</DialogContent>
|
<LayoutFooter hasDivider>
|
||||||
<DialogActions>
|
<HStack justify="end" gap={2} style={{ width: '100%', padding: '16px 24px' }}>
|
||||||
<Button onClick={() => setHubDialog(false)} disabled={savingHub}>Cancel</Button>
|
<Button variant="ghost" onClick={() => setHubDialog(false)} disabled={savingHub}>Cancel</Button>
|
||||||
<Button variant="contained" onClick={submitHub} disabled={savingHub}
|
<Button variant="primary" type="submit" disabled={savingHub}>{savingHub ? 'Creating…' : 'Create Hub'}</Button>
|
||||||
startIcon={savingHub ? <CircularProgress size={16} color="inherit" /> : null}
|
</HStack>
|
||||||
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
|
</LayoutFooter>
|
||||||
{savingHub ? 'Creating…' : 'Create Hub'}
|
}
|
||||||
</Button>
|
/>
|
||||||
</DialogActions>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Create Staff dialog */}
|
{/* Create Staff dialog */}
|
||||||
<Dialog open={staffDialog} onClose={() => setStaffDialog(false)} fullWidth maxWidth="sm">
|
<Dialog isOpen={staffDialog} onOpenChange={setStaffDialog} width={480} purpose="form">
|
||||||
<DialogTitle sx={{ fontWeight: 700 }}>Add a Hub Staff Login</DialogTitle>
|
<form onSubmit={submitStaff}>
|
||||||
<DialogContent>
|
<Layout
|
||||||
<Stack spacing={2.5} sx={{ mt: 1 }}>
|
header={<DialogHeader title="Add a Hub Staff Login" onOpenChange={setStaffDialog} />}
|
||||||
<TextField select fullWidth label="Hub" value={staffForm.hubid} required
|
content={
|
||||||
onChange={(e) => setStaffForm((f) => ({ ...f, hubid: e.target.value }))}>
|
<LayoutContent>
|
||||||
{hubs.map((h) => <MenuItem key={h.hubid} value={h.hubid}>{h.hubname}</MenuItem>)}
|
<VStack gap={4} style={{ padding: '24px' }}>
|
||||||
</TextField>
|
<Selector
|
||||||
<TextField fullWidth label="Display name" value={staffForm.displayname}
|
label="Hub"
|
||||||
onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} />
|
isRequired
|
||||||
<TextField fullWidth type="email" label="Email" value={staffForm.email} required
|
options={hubs.map((h) => ({ value: String(h.hubid), label: h.hubname }))}
|
||||||
onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} />
|
value={String(staffForm.hubid)}
|
||||||
<TextField fullWidth type={showPwd ? 'text' : 'password'} label="Password" value={staffForm.password} required
|
onChange={(v) => setStaffForm((f) => ({ ...f, hubid: v }))}
|
||||||
onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))}
|
/>
|
||||||
InputProps={{
|
<TextInput label="Display name" value={staffForm.displayname} onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} />
|
||||||
endAdornment: (
|
<TextInput type="email" label="Email" value={staffForm.email} isRequired onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} />
|
||||||
<InputAdornment position="end">
|
<HStack gap={2} align="end">
|
||||||
<IconButton onClick={() => setShowPwd((s) => !s)} edge="end" size="small">
|
<div style={{ flex: 1 }}>
|
||||||
{showPwd ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
<TextInput
|
||||||
</IconButton>
|
type={showPwd ? 'text' : 'password'}
|
||||||
</InputAdornment>
|
label="Password"
|
||||||
)
|
value={staffForm.password}
|
||||||
}} />
|
isRequired
|
||||||
</Stack>
|
onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
</DialogContent>
|
/>
|
||||||
<DialogActions>
|
</div>
|
||||||
<Button onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button>
|
<IconButton
|
||||||
<Button variant="contained" onClick={submitStaff} disabled={savingStaff}
|
label={showPwd ? 'Hide password' : 'Show password'}
|
||||||
startIcon={savingStaff ? <CircularProgress size={16} color="inherit" /> : null}
|
tooltip={showPwd ? 'Hide password' : 'Show password'}
|
||||||
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}>
|
icon={showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
{savingStaff ? 'Creating…' : 'Create Login'}
|
variant="secondary"
|
||||||
</Button>
|
onClick={() => setShowPwd((s) => !s)}
|
||||||
</DialogActions>
|
/>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<LayoutFooter hasDivider>
|
||||||
|
<HStack justify="end" gap={2} style={{ width: '100%', padding: '16px 24px' }}>
|
||||||
|
<Button variant="ghost" onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button>
|
||||||
|
<Button variant="primary" type="submit" disabled={savingStaff}>{savingStaff ? 'Creating…' : 'Create Login'}</Button>
|
||||||
|
</HStack>
|
||||||
|
</LayoutFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
|
||||||
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}>
|
|
||||||
{toast.msg}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Typography, Card, CardContent, Grid, TextField, Button, Stack,
|
QrCode, Truck, CheckCircle2, ArrowRight, Inbox,
|
||||||
MenuItem, Table, TableBody, TableCell, TableContainer, TableHead,
|
Package, AlertTriangle, Snowflake,
|
||||||
TableRow, Chip, Alert, Snackbar, InputAdornment, Avatar, Divider,
|
Warehouse, MapPin, Loader2, Flag
|
||||||
useMediaQuery, CircularProgress
|
} from 'lucide-react';
|
||||||
} from '@mui/material';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import QrCodeScannerOutlinedIcon from '@mui/icons-material/QrCodeScannerOutlined';
|
|
||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
|
||||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
|
||||||
import EastOutlinedIcon from '@mui/icons-material/EastOutlined';
|
|
||||||
import MoveToInboxOutlinedIcon from '@mui/icons-material/MoveToInboxOutlined';
|
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|
||||||
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
|
||||||
import AcUnitOutlinedIcon from '@mui/icons-material/AcUnitOutlined';
|
|
||||||
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
|
|
||||||
import ThermostatOutlinedIcon from '@mui/icons-material/ThermostatOutlined';
|
|
||||||
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
|
|
||||||
import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
|
|
||||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
|
||||||
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
||||||
|
import { Field } from '@astryxdesign/core/Field';
|
||||||
|
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { useToast } from '@astryxdesign/core/Toast';
|
||||||
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
|
import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
|
||||||
import StatCard from '@/components/StatCard';
|
import StatCard from '@/components/StatCard';
|
||||||
@@ -37,11 +32,10 @@ const ORIGINS = [
|
|||||||
{ value: 'Client Pickup', label: 'Direct Client Pickup (Local)' }
|
{ value: 'Client Pickup', label: 'Direct Client Pickup (Local)' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Turn an ISO timestamp into a friendly "10 min ago" label.
|
|
||||||
const timeAgo = (iso) => {
|
const timeAgo = (iso) => {
|
||||||
if (!iso) return 'Recently';
|
if (!iso) return 'Recently';
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
if (Number.isNaN(then) || then < 1420070400000) return 'Recently'; // guard zero/0001 dates
|
if (Number.isNaN(then) || then < 1420070400000) return 'Recently';
|
||||||
const mins = Math.round((Date.now() - then) / 60000);
|
const mins = Math.round((Date.now() - then) / 60000);
|
||||||
if (mins < 1) return 'Just now';
|
if (mins < 1) return 'Just now';
|
||||||
if (mins < 60) return `${mins} min ago`;
|
if (mins < 60) return `${mins} min ago`;
|
||||||
@@ -50,11 +44,7 @@ const timeAgo = (iso) => {
|
|||||||
return `${Math.round(hrs / 24)} d ago`;
|
return `${Math.round(hrs / 24)} d ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map an API inbound record onto the shape this page renders.
|
|
||||||
// Real backend uses consignmentid / trackingno / chargeableweight / deliverypincode.
|
|
||||||
const mapInbound = (row, hubName) => {
|
const mapInbound = (row, hubName) => {
|
||||||
// Backend GET /hub/inbound/today sends: originname, destinationname, updatedat.
|
|
||||||
// (chargeableweight is NOT sent by this endpoint yet — see the note to backend.)
|
|
||||||
const w = row.chargeableweight ?? row.deadweight ?? row.weight;
|
const w = row.chargeableweight ?? row.deadweight ?? row.weight;
|
||||||
return {
|
return {
|
||||||
bookingid: row.consignmentid ?? row.bookingid,
|
bookingid: row.consignmentid ?? row.bookingid,
|
||||||
@@ -62,8 +52,7 @@ const mapInbound = (row, hubName) => {
|
|||||||
sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'),
|
sender: row.sendername || (row.senderid ? `Sender #${row.senderid}` : '—'),
|
||||||
origin: row.originname || row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'),
|
origin: row.originname || row.origin || (row.originhubid ? `Hub ${row.originhubid}` : '—'),
|
||||||
currentLoc: hubName,
|
currentLoc: hubName,
|
||||||
destination:
|
destination: row.destinationname || row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
|
||||||
row.destinationname || row.destination || row.deliverypincode || (row.destinationhubid ? `Hub ${row.destinationhubid}` : '—'),
|
|
||||||
weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—',
|
weight: typeof w === 'string' ? w : w != null ? `${w} kg` : '—',
|
||||||
condition: row.condition || 'Good',
|
condition: row.condition || 'Good',
|
||||||
temp: row.temperature || 'N/A',
|
temp: row.temperature || 'N/A',
|
||||||
@@ -72,18 +61,12 @@ const mapInbound = (row, hubName) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const shelfStyle = (shelf) => {
|
|
||||||
if (shelf === 'Exception Area') return { color: '#D93025', bg: '#FCE8E6' };
|
|
||||||
if (shelf.includes('Cold')) return { color: '#00838F', bg: '#E0F7FA' };
|
|
||||||
return { color: '#1A73E8', bg: '#E8F0FE' };
|
|
||||||
};
|
|
||||||
const isGood = (c) => c === 'Good';
|
const isGood = (c) => c === 'Good';
|
||||||
|
|
||||||
export default function Inbound() {
|
export default function Inbound() {
|
||||||
const theme = useTheme();
|
|
||||||
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
const hubName = hub.hubname || 'this hub';
|
const hubName = hub.hubname || 'this hub';
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const [bookingId, setBookingId] = useState('');
|
const [bookingId, setBookingId] = useState('');
|
||||||
const [trackingId, setTrackingId] = useState('');
|
const [trackingId, setTrackingId] = useState('');
|
||||||
@@ -99,9 +82,7 @@ export default function Inbound() {
|
|||||||
const [inboundLogs, setInboundLogs] = useState([]);
|
const [inboundLogs, setInboundLogs] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
|
||||||
|
|
||||||
// Date range for the "Recently Received" history. Defaults to today.
|
|
||||||
const today = dayjs().format(DATE_FMT);
|
const today = dayjs().format(DATE_FMT);
|
||||||
const [range, setRange] = useState({ from: today, to: today });
|
const [range, setRange] = useState({ from: today, to: today });
|
||||||
const isToday = range.from === today && range.to === today;
|
const isToday = range.from === today && range.to === today;
|
||||||
@@ -122,9 +103,7 @@ export default function Inbound() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
// hubName is derived from a stable localStorage read; safe to omit.
|
}, [range.from, range.to, hubName]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [range.from, range.to]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInbound();
|
loadInbound();
|
||||||
@@ -138,7 +117,6 @@ export default function Inbound() {
|
|||||||
return { received, exceptions, coldChain, pendingSort };
|
return { received, exceptions, coldChain, pendingSort };
|
||||||
}, [inboundLogs]);
|
}, [inboundLogs]);
|
||||||
|
|
||||||
// Local preview of the shelf the backend is likely to recommend (it decides for real).
|
|
||||||
const recommendShelf = () => {
|
const recommendShelf = () => {
|
||||||
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area';
|
if (condition.includes('Damaged') || condition.includes('Wet') || condition.includes('Missing')) return 'Exception Area';
|
||||||
if (temp !== 'N/A' && temp !== '') return 'Zone C (Cold Room)';
|
if (temp !== 'N/A' && temp !== '') return 'Zone C (Cold Room)';
|
||||||
@@ -149,7 +127,7 @@ export default function Inbound() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
if (!bookingId.trim() || !trackingId.trim()) {
|
if (!bookingId.trim() || !trackingId.trim()) {
|
||||||
setToast({ open: true, msg: 'Enter the booking ID and tracking ID to scan a parcel in.', severity: 'warning' });
|
toast({ body: 'Enter the booking ID and tracking ID to scan a parcel in.', type: 'warning' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +143,6 @@ export default function Inbound() {
|
|||||||
const data = res?.data || {};
|
const data = res?.data || {};
|
||||||
const shelf = data.recommended_shelf || recommendShelf();
|
const shelf = data.recommended_shelf || recommendShelf();
|
||||||
|
|
||||||
// Optimistically prepend, then refresh from the server for the source of truth.
|
|
||||||
setInboundLogs((prev) => [
|
setInboundLogs((prev) => [
|
||||||
{
|
{
|
||||||
bookingid: data.bookingid ?? bookingId,
|
bookingid: data.bookingid ?? bookingId,
|
||||||
@@ -182,284 +159,288 @@ export default function Inbound() {
|
|||||||
},
|
},
|
||||||
...prev
|
...prev
|
||||||
]);
|
]);
|
||||||
setToast({ open: true, msg: `${data.trackingnumber || trackingId.trim()} received · routed to ${shelf}`, severity: 'success' });
|
toast({ body: `${data.trackingnumber || trackingId.trim()} received · routed to ${shelf}`, type: 'success' });
|
||||||
setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
|
setBookingId(''); setTrackingId(''); setCustomer(''); setSenderAddress(''); setDestination(''); setWeight(''); setCondition('Good'); setTemp('');
|
||||||
loadInbound();
|
loadInbound();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 404 = the booking ID doesn't exist. This screen receives an EXISTING
|
|
||||||
// booking into the hub; it does not create a new parcel. Make that clear.
|
|
||||||
const notFound = err?.status === 404 || /not found/i.test(err?.message || '');
|
const notFound = err?.status === 404 || /not found/i.test(err?.message || '');
|
||||||
setToast({
|
toast({
|
||||||
open: true,
|
body: notFound
|
||||||
msg: notFound
|
|
||||||
? `No booking found with ID "${bookingId.trim()}". This screen receives a booking that already exists — enter a Booking ID from the system (e.g. an unassigned pickup).`
|
? `No booking found with ID "${bookingId.trim()}". This screen receives a booking that already exists — enter a Booking ID from the system (e.g. an unassigned pickup).`
|
||||||
: err?.message || 'Could not scan this parcel in.',
|
: err?.message || 'Could not scan this parcel in.',
|
||||||
severity: notFound ? 'warning' : 'error'
|
type: notFound ? 'warning' : 'error'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fieldSx = { '& .MuiOutlinedInput-root': { borderRadius: 2 } };
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
// Reusable journey block
|
key: 'parcel',
|
||||||
const Journey = ({ log }) => (
|
header: <div style={{ paddingLeft: '24px', whiteSpace: 'nowrap' }}>Parcel Info</div>,
|
||||||
<Stack spacing={0.5}>
|
width: pixel(224),
|
||||||
<Stack direction="row" alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75 }}>
|
renderCell: (row) => (
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.origin}</Typography>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingLeft: '24px' }}>
|
||||||
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
|
<div style={{ fontWeight: 800, fontFamily: 'monospace', color: '#0f172a', fontSize: '1rem' }}>{row.trackingId}</div>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#C01227' }}>{log.currentLoc}</Typography>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.75rem', color: '#64748b' }}>
|
||||||
<EastOutlinedIcon sx={{ fontSize: 14, color: '#ADB5BD' }} />
|
<span style={{ fontWeight: 600 }}>{row.weight}</span>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#343A40' }}>{log.destination.split(',')[0]}</Typography>
|
<span style={{ color: '#cbd5e1' }}>•</span>
|
||||||
</Stack>
|
<span>{row.time}</span>
|
||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
</div>
|
||||||
<PlaceOutlinedIcon sx={{ fontSize: 13, color: '#9AA0A6' }} />
|
</div>
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>{log.destination}</Typography>
|
)
|
||||||
</Stack>
|
},
|
||||||
</Stack>
|
{
|
||||||
);
|
key: 'journey',
|
||||||
|
width: proportional(2),
|
||||||
|
header: <div style={{ whiteSpace: 'nowrap' }}>Journey</div>,
|
||||||
|
renderCell: (row) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '4px 0' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<div style={{ width: 24, height: 24, borderRadius: 12, backgroundColor: '#d1fae5', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<MapPin size={12} color="#059669" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: '#0f172a', lineHeight: 1.2 }}>{row.origin}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '2px' }}>Origin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<div style={{ width: 24, height: 24, borderRadius: 12, backgroundColor: '#fee2e2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Flag size={12} color="#dc2626" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.875rem', color: '#334155', lineHeight: 1.2 }}>{row.destination.split(',')[0]}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '200px' }}>{row.destination}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'condition',
|
||||||
|
header: <div style={{ whiteSpace: 'nowrap' }}>Condition</div>,
|
||||||
|
width: pixel(140),
|
||||||
|
renderCell: (row) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
|
||||||
|
<Badge variant={isGood(row.condition) ? 'success' : 'error'} label={row.condition} />
|
||||||
|
{row.temp !== 'N/A' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', color: '#0d9488', fontWeight: 600 }}>
|
||||||
|
<Snowflake size={12} /> {row.temp}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shelf',
|
||||||
|
header: <div style={{ whiteSpace: 'nowrap' }}>Placement</div>,
|
||||||
|
width: pixel(180),
|
||||||
|
renderCell: (row) => {
|
||||||
|
let variant = 'info';
|
||||||
|
if (row.shelf === 'Exception Area') variant = 'error';
|
||||||
|
if (row.shelf.includes('Cold')) variant = 'neutral';
|
||||||
|
return (
|
||||||
|
<div style={{ paddingRight: '24px' }}>
|
||||||
|
<Badge variant={variant} label={row.shelf} icon={Warehouse} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<div style={{ paddingBottom: '32px' }}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={MoveToInboxOutlinedIcon}
|
icon={Inbox}
|
||||||
title="Receive Parcels"
|
title="Receive Parcels"
|
||||||
subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on."
|
subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on."
|
||||||
action={<DateRangePicker value={range} onChange={setRange} />}
|
action={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── KPI strip ── */}
|
{/* KPI strip - Ultra Compact */}
|
||||||
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mb: 3 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '24px', flexWrap: 'wrap', padding: '16px 20px', background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', marginBottom: '24px', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||||
{[
|
|
||||||
{ icon: MoveToInboxOutlinedIcon, label: isToday ? 'Received Today' : 'Received', value: stats.received, color: '#1A73E8', bg: '#E8F0FE' },
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
|
||||||
{ icon: Inventory2OutlinedIcon, label: 'To Sort', value: stats.pendingSort, color: '#B06000', bg: '#FEF7E0' },
|
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Inbox size={18} /></div>
|
||||||
{ icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' },
|
<div>
|
||||||
{ icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' },
|
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{isToday ? 'Received Today' : 'Received'}</div>
|
||||||
].map((s, i) => (
|
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.received}</div>
|
||||||
<Grid size={{ xs: 6, md: 3 }} key={i}>
|
</div>
|
||||||
<StatCard icon={s.icon} label={s.label} value={s.value} color={s.color} bg={s.bg} loading={loading} />
|
</div>
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* ── Main ── */}
|
<div style={{ width: 1, height: 32, background: '#e2e8f0', display: 'none', '@media (min-width: 640px)': { display: 'block' } }} />
|
||||||
<Grid container spacing={{ xs: 2, md: 2.5 }} alignItems="stretch">
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#fff7ed', color: '#f97316', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Package size={18} /></div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>To Sort</div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.pendingSort}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 1, height: 32, background: '#e2e8f0', display: 'none', '@media (min-width: 640px)': { display: 'block' } }} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#fef2f2', color: '#ef4444', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><AlertTriangle size={18} /></div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Needs Checking</div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.exceptions}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 1, height: 32, background: '#e2e8f0', display: 'none', '@media (min-width: 640px)': { display: 'block' } }} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: '8px', background: '#f0fdfa', color: '#14b8a6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Snowflake size={18} /></div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Cold Items</div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.coldChain}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '24px', alignItems: 'stretch' }}>
|
||||||
|
|
||||||
{/* Scanner Panel */}
|
{/* Scanner Panel */}
|
||||||
<Grid size={{ xs: 12, lg: 4 }}>
|
<div style={{ flex: '1 1 350px', display: 'flex' }}>
|
||||||
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
|
<Card style={{ width: '100%', padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box sx={{ p: 2.5, pb: 2 }}>
|
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<Stack
|
<div style={{ width: 40, height: 40, borderRadius: '8px', backgroundColor: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
direction="row"
|
<QrCode size={20} />
|
||||||
alignItems="center"
|
</div>
|
||||||
spacing={2} // Increase this value
|
<div>
|
||||||
>
|
<Heading level={4} style={{ margin: 0 }}>Add a Parcel</Heading>
|
||||||
<Avatar
|
<Text type="supporting" color="secondary">Record a parcel arriving at {hubName}</Text>
|
||||||
variant="rounded"
|
</div>
|
||||||
sx={{
|
</div>
|
||||||
bgcolor: "#C0122710",
|
|
||||||
color: "#C01227",
|
|
||||||
borderRadius: 1,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QrCodeScannerOutlinedIcon />
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<Box>
|
<form onSubmit={handleSubmit} style={{ padding: '20px' }}>
|
||||||
<Typography
|
<FormLayout>
|
||||||
variant="h5"
|
<Field label="Booking ID" description="Must be an existing booking in the system">
|
||||||
sx={{ fontWeight: 700, color: "#1A1A2E" }}
|
<TextInput required placeholder="e.g. 15" value={bookingId} onChange={setBookingId} />
|
||||||
>
|
</Field>
|
||||||
Add a Parcel
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Field label="Tracking ID">
|
||||||
Record a parcel arriving at {hubName}
|
<TextInput placeholder="e.g. DM-882204" value={trackingId} onChange={setTrackingId} />
|
||||||
</Typography>
|
</Field>
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
<CardContent sx={{ pt: 3 }}>
|
|
||||||
<Box component="form" onSubmit={handleSubmit}>
|
|
||||||
<Stack spacing={2.5}>
|
|
||||||
<TextField fullWidth label="Booking ID" placeholder="e.g. 15" value={bookingId}
|
|
||||||
onChange={(e) => setBookingId(e.target.value)} sx={fieldSx} required
|
|
||||||
helperText="Must be an existing booking in the system (this receives it — it doesn't create a new one)"
|
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><Inventory2OutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
|
||||||
|
|
||||||
<TextField fullWidth label="Tracking ID" placeholder="e.g. DM-882204" value={trackingId}
|
<Field label="Where it came from">
|
||||||
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx}
|
<select
|
||||||
InputProps={{
|
value={origin}
|
||||||
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment>
|
onChange={(e) => setOrigin(e.target.value)}
|
||||||
}} />
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #cbd5e1', background: '#fff', color: '#0f172a', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
{ORIGINS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<TextField select fullWidth label="Where it came from" value={origin}
|
<Field label="Sender Address">
|
||||||
onChange={(e) => setOrigin(e.target.value)} sx={fieldSx}
|
<TextInput placeholder="e.g. Andheri East, Mumbai" value={senderAddress} onChange={setSenderAddress} />
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><HomeWorkOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }}>
|
</Field>
|
||||||
{ORIGINS.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField fullWidth label="Sender Address" placeholder="e.g. Andheri East, Mumbai" value={senderAddress}
|
<Field label="Where it's going (delivery address)">
|
||||||
onChange={(e) => setSenderAddress(e.target.value)} sx={fieldSx} />
|
<TextInput required placeholder="e.g. Rohini Sec 9, Delhi" value={destination} onChange={setDestination} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<TextField fullWidth label="Where it's going (delivery address)" placeholder="e.g. Rohini Sec 9, Delhi" value={destination}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
onChange={(e) => setDestination(e.target.value)} required sx={fieldSx}
|
<Field label="Weight">
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><PlaceOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
<TextInput placeholder="2.4 kg" value={weight} onChange={setWeight} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Condition">
|
||||||
|
<select
|
||||||
|
value={condition}
|
||||||
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #cbd5e1', background: '#fff', color: '#0f172a', fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
|
<option value="Good">Good</option>
|
||||||
|
<option value="Damaged Box">Damaged Box</option>
|
||||||
|
<option value="Wet / Crushed">Wet / Crushed</option>
|
||||||
|
<option value="Missing Label">Missing Label</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Stack direction="row" spacing={2}>
|
<Field label="Temperature (Cold Chain)">
|
||||||
<TextField fullWidth label="Weight" placeholder="2.4 kg" value={weight}
|
<TextInput placeholder="4.0°C — or N/A if dry" value={temp} onChange={setTemp} />
|
||||||
onChange={(e) => setWeight(e.target.value)} sx={fieldSx}
|
</Field>
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><ScaleOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 18 }} /></InputAdornment> }} />
|
|
||||||
<TextField fullWidth select label="Condition" value={condition}
|
|
||||||
onChange={(e) => setCondition(e.target.value)} sx={fieldSx}>
|
|
||||||
<MenuItem value="Good">Good</MenuItem>
|
|
||||||
<MenuItem value="Damaged Box">Damaged Box</MenuItem>
|
|
||||||
<MenuItem value="Wet / Crushed">Wet / Crushed</MenuItem>
|
|
||||||
<MenuItem value="Missing Label">Missing Label</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<TextField fullWidth label="Temperature (Cold Chain)" placeholder="4.0°C — or N/A if dry" value={temp}
|
<div style={{ marginTop: '16px' }}>
|
||||||
onChange={(e) => setTemp(e.target.value)} sx={fieldSx}
|
<Button
|
||||||
InputProps={{ startAdornment: <InputAdornment position="start"><ThermostatOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
<Button fullWidth size="large" variant="contained" type="submit" disabled={submitting}
|
disabled={submitting}
|
||||||
startIcon={submitting ? <CircularProgress size={18} color="inherit" /> : <CheckCircleOutlinedIcon />}
|
icon={submitting ? <Loader2 size={16} className="spin" /> : <CheckCircle2 size={16} />}
|
||||||
sx={{ mt: 0.5, py: 1.3, borderRadius: 2, bgcolor: '#C01227', fontWeight: 700,
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
boxShadow: '0 4px 14px rgba(192,18,39,0.30)', '&:hover': { bgcolor: '#9E0E20' } }}>
|
>
|
||||||
{submitting ? 'Scanning in…' : 'Mark Received at Hub'}
|
{submitting ? 'Scanning in…' : 'Mark Received at Hub'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</FormLayout>
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</div>
|
||||||
|
|
||||||
{/* Ledger Panel */}
|
{/* Ledger Panel */}
|
||||||
<Grid size={{ xs: 12, lg: 8 }}>
|
<div style={{ flex: '2 1 600px', display: 'flex' }}>
|
||||||
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Card style={{ width: '100%', padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box sx={{ p: 2.5, pb: 2 }}>
|
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<Stack direction="row" alignItems="center" gap={2} spacing={2}>
|
<div style={{ width: 40, height: 40, borderRadius: '8px', backgroundColor: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Avatar variant="rounded" sx={{ bgcolor: '#E8F0FE', color: '#1A73E8', borderRadius: 2, width: 40, height: 40 }}>
|
<Truck size={20} />
|
||||||
<LocalShippingOutlinedIcon />
|
</div>
|
||||||
</Avatar>
|
<div>
|
||||||
<Box>
|
<Heading level={4} style={{ margin: 0 }}>Recently Received</Heading>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1A1A2E' }}>Recently Received</Typography>
|
<Text type="supporting" color="secondary">Parcels logged at the hub {rangeLabel}</Text>
|
||||||
<Typography variant="caption" color="text.secondary">Parcels logged at the hub {rangeLabel}</Typography>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
<div style={{ padding: '4px 12px', borderRadius: '12px', background: '#f1f5f9', color: '#475569', fontSize: '0.75rem', fontWeight: 700 }}>
|
||||||
<Chip label={`${inboundLogs.length} total`} size="small"
|
{inboundLogs.length} total
|
||||||
sx={{ fontWeight: 700, bgcolor: '#F1F3F5', color: '#5F6368', borderRadius: 2 }} />
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
|
<div style={{ padding: '16px' }}>
|
||||||
{loadError}
|
<Banner status="error" title="Error" description={loadError} />
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
|
||||||
<CircularProgress />
|
<Loader2 size={32} className="spin" color="#2563eb" />
|
||||||
</Box>
|
</div>
|
||||||
) : inboundLogs.length === 0 && !loadError ? (
|
|
||||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
|
||||||
<MoveToInboxOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{isToday ? 'No parcels received yet today.' : `No parcels received ${rangeLabel}.`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : isMdDown ? (
|
|
||||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
||||||
{inboundLogs.map((log, i) => {
|
|
||||||
const ss = shelfStyle(log.shelf);
|
|
||||||
return (
|
|
||||||
<Card key={i} elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1' }}>
|
|
||||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E' }}>{log.trackingId}</Typography>
|
|
||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
|
||||||
<AccessTimeOutlinedIcon sx={{ fontSize: 13, color: '#9AA0A6' }} />
|
|
||||||
<Typography variant="caption" color="text.secondary">{log.time}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Journey log={log} />
|
|
||||||
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mt: 1.5 }}>
|
|
||||||
<Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.weight}
|
|
||||||
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} />
|
|
||||||
<Chip size="small" label={log.condition}
|
|
||||||
sx={{ fontWeight: 700, bgcolor: isGood(log.condition) ? '#E6F4EA' : '#FCE8E6', color: isGood(log.condition) ? '#1E8E3E' : '#D93025' }} />
|
|
||||||
{log.temp !== 'N/A' && (
|
|
||||||
<Chip size="small" icon={<ThermostatOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.temp}
|
|
||||||
sx={{ bgcolor: '#E0F7FA', color: '#00838F', fontWeight: 600, '& .MuiChip-icon': { color: '#00838F' } }} />
|
|
||||||
)}
|
|
||||||
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.shelf}
|
|
||||||
sx={{ bgcolor: ss.bg, color: ss.color, fontWeight: 600, '& .MuiChip-icon': { color: ss.color } }} />
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
/* ── DESKTOP: table with horizontal scroll guard ── */
|
<div className="hide-scrollbar" style={{ flex: 1, padding: '16px 0', overflowX: 'auto', overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||||
<TableContainer sx={{ flexGrow: 1 }}>
|
<Table
|
||||||
<Table sx={{ minWidth: 820 }}>
|
data={inboundLogs}
|
||||||
<TableHead>
|
columns={columns}
|
||||||
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
idKey="trackingId"
|
||||||
{['Parcel No.', 'Journey', 'Weight', 'Condition', 'Temp', 'Goes On Shelf', 'Time'].map((h, i) => (
|
density="balanced"
|
||||||
<TableCell key={h} align={i >= 2 && i <= 4 ? 'center' : i === 6 ? 'right' : 'left'}
|
dividers="rows"
|
||||||
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, borderBottom: '1px solid #ECEEF1', py: 1.5, whiteSpace: 'nowrap' }}>
|
emptyState={
|
||||||
{h}
|
!loadError && (
|
||||||
</TableCell>
|
<EmptyState
|
||||||
))}
|
icon={<Inbox size={48} color="#cbd5e1" />}
|
||||||
</TableRow>
|
title={isToday ? 'No parcels received yet today.' : `No parcels received ${rangeLabel}.`}
|
||||||
</TableHead>
|
description="Waiting for incoming shipments."
|
||||||
<TableBody>
|
style={{ padding: '64px 0' }}
|
||||||
{inboundLogs.map((log, index) => {
|
/>
|
||||||
const ss = shelfStyle(log.shelf);
|
)
|
||||||
return (
|
}
|
||||||
<TableRow key={index} hover sx={{ '& td': { borderBottom: '1px solid #F4F6F8' }, '&:last-child td': { border: 0 } }}>
|
/>
|
||||||
<TableCell sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', whiteSpace: 'nowrap' }}>{log.trackingId}</TableCell>
|
</div>
|
||||||
<TableCell sx={{ minWidth: 240 }}><Journey log={log} /></TableCell>
|
|
||||||
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 600, color: '#495057' }}>{log.weight}</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Chip size="small" label={log.condition}
|
|
||||||
sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: isGood(log.condition) ? '#E6F4EA' : '#FCE8E6', color: isGood(log.condition) ? '#1E8E3E' : '#D93025' }} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center" sx={{ whiteSpace: 'nowrap', fontWeight: 600, color: log.temp !== 'N/A' ? '#00838F' : '#9AA0A6' }}>{log.temp}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" icon={<WarehouseOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={log.shelf}
|
|
||||||
sx={{ fontWeight: 600, whiteSpace: 'nowrap', bgcolor: ss.bg, color: ss.color, '& .MuiChip-icon': { color: ss.color } }} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right" sx={{ whiteSpace: 'nowrap', color: '#9AA0A6', fontSize: '0.78rem' }}>{log.time}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
|
|
||||||
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
|
<style>{`
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
.spin { animation: spin 1s linear infinite; }
|
||||||
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })}
|
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||||
sx={{ borderRadius: 2, fontWeight: 600, boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}>
|
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
{toast.msg}
|
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
</Alert>
|
`}</style>
|
||||||
</Snackbar>
|
</div>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,54 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import { UserCheck, Bike, Sparkles, Star } from 'lucide-react';
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Avatar,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
List,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemText,
|
|
||||||
ListItemAvatar,
|
|
||||||
Radio,
|
|
||||||
Badge,
|
|
||||||
Checkbox,
|
|
||||||
useMediaQuery,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
|
||||||
Snackbar
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
|
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
|
||||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
|
||||||
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|
||||||
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
|
import { Table, proportional, pixel, useTableSelection, useTableSelectionState } from '@astryxdesign/core/Table';
|
||||||
|
import { HStack, VStack, Card } from '@astryxdesign/core/Layout';
|
||||||
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
|
import { useToast } from '@astryxdesign/core/Toast';
|
||||||
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
|
||||||
|
import Panel from '@/components/Panel';
|
||||||
|
import Button from '@/components/Button';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
|
import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker';
|
||||||
import { getBookingsRange, getMilers, assignMiler, autoAssignBooking } from '@/api/hub';
|
import { getBookingsRange, getMilers, assignMiler, autoAssignBooking } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
|
function useMediaQuery(query) {
|
||||||
|
const [matches, setMatches] = useState(window.matchMedia(query).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
if (media.matches !== matches) {
|
||||||
|
setMatches(media.matches);
|
||||||
|
}
|
||||||
|
const listener = () => setMatches(media.matches);
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, [matches, query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
const PENDING = 'Pending Assignment';
|
const PENDING = 'Pending Assignment';
|
||||||
|
|
||||||
// Treat any status that isn't explicitly pending/unassigned as already handled.
|
|
||||||
const isPendingStatus = (s) => {
|
const isPendingStatus = (s) => {
|
||||||
const v = (s || '').toLowerCase();
|
const v = (s || '').toLowerCase();
|
||||||
return !v || v === 'pending' || v === 'unassigned' || v === 'pending assignment';
|
return !v || v === 'pending' || v === 'unassigned' || v === 'pending assignment';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Backend normalizes booking status to pending|assigned|picked_up|delivered|cancelled.
|
|
||||||
// Map the page's display status → the chip label + colours (and whether it's "done").
|
|
||||||
const statusChip = (status) => {
|
const statusChip = (status) => {
|
||||||
if (status === PENDING) return { label: 'Needs a miler', bg: '#FEF7E0', color: '#B06000', done: false };
|
if (status === PENDING) return { label: 'Needs a miler', variant: 'warning' };
|
||||||
if (/^cancel/i.test(status)) return { label: 'Cancelled', bg: '#FCE8E6', color: '#D93025', done: true };
|
if (/^cancel/i.test(status)) return { label: 'Cancelled', variant: 'error' };
|
||||||
if (/deliver/i.test(status)) return { label: 'Delivered', bg: '#E6F4EA', color: '#1E8E3E', done: true };
|
if (/deliver/i.test(status)) return { label: 'Delivered', variant: 'success' };
|
||||||
if (/picked/i.test(status)) return { label: 'Picked up', bg: '#E8F0FE', color: '#1A73E8', done: true };
|
if (/picked/i.test(status)) return { label: 'Picked up', variant: 'info' };
|
||||||
if (/no miler/i.test(status)) return { label: 'No miler in range', bg: '#FCE8E6', color: '#D93025', done: false };
|
if (/no miler/i.test(status)) return { label: 'No miler in range', variant: 'error' };
|
||||||
if (/assigning/i.test(status)) return { label: 'Assigning…', bg: '#E8F0FE', color: '#1A73E8', done: true };
|
if (/assigning/i.test(status)) return { label: 'Assigning…', variant: 'info' };
|
||||||
return { label: 'Assigned', bg: '#E6F4EA', color: '#1E8E3E', done: true }; // "Assigned to X"
|
return { label: 'Assigned', variant: 'success' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeAgo = (iso) => {
|
const timeAgo = (iso) => {
|
||||||
@@ -81,14 +63,12 @@ const timeAgo = (iso) => {
|
|||||||
return `${Math.round(hrs / 24)} d ago`;
|
return `${Math.round(hrs / 24)} d ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real backend: snake_case fields + a parcels[] array (no packagedescription).
|
|
||||||
const mapOrder = (b) => {
|
const mapOrder = (b) => {
|
||||||
const parcels = b.parcels || [];
|
const parcels = b.parcels || [];
|
||||||
const totalWeight = parcels.reduce((s, p) => s + (p.weight || 0), 0);
|
const totalWeight = parcels.reduce((s, p) => s + (p.weight || 0), 0);
|
||||||
const pkg = parcels.length
|
const pkg = parcels.length
|
||||||
? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}`
|
? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}`
|
||||||
: b.packagedescription || '—';
|
: b.packagedescription || '—';
|
||||||
// Ranged results carry a real status; the live "unassigned" fallback has none → PENDING.
|
|
||||||
const raw = (b.status || '').toLowerCase();
|
const raw = (b.status || '').toLowerCase();
|
||||||
let status;
|
let status;
|
||||||
if (isPendingStatus(raw)) status = PENDING;
|
if (isPendingStatus(raw)) status = PENDING;
|
||||||
@@ -97,7 +77,7 @@ const mapOrder = (b) => {
|
|||||||
else if (raw === 'picked_up') status = 'Picked up';
|
else if (raw === 'picked_up') status = 'Picked up';
|
||||||
else status = b.milername ? `Assigned to ${b.milername}` : 'Assigned';
|
else status = b.milername ? `Assigned to ${b.milername}` : 'Assigned';
|
||||||
return {
|
return {
|
||||||
id: b.bookingid,
|
id: b.bookingid ?? b.consignmentid ?? b.booking_id ?? b.id,
|
||||||
customer: b.customer_name || b.customerName || 'Customer',
|
customer: b.customer_name || b.customerName || 'Customer',
|
||||||
pickup: b.pickup_address || b.pickupaddress || '—',
|
pickup: b.pickup_address || b.pickupaddress || '—',
|
||||||
drop: b.delivery_address || b.deliveryaddress || '—',
|
drop: b.delivery_address || b.deliveryaddress || '—',
|
||||||
@@ -116,8 +96,7 @@ const mapMiler = (m) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function OrderAssignment() {
|
export default function OrderAssignment() {
|
||||||
const theme = useTheme();
|
const isMdDown = useMediaQuery('(max-width: 900px)');
|
||||||
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
|
|
||||||
const [orders, setOrders] = useState([]);
|
const [orders, setOrders] = useState([]);
|
||||||
@@ -125,11 +104,10 @@ export default function OrderAssignment() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' });
|
const toast = useToast();
|
||||||
|
|
||||||
const [selectedOrders, setSelectedOrders] = useState([]);
|
const [selectedOrders, setSelectedOrders] = useState(() => new Set());
|
||||||
|
|
||||||
// Date range for the pickup-request history. Defaults to today.
|
|
||||||
const today = dayjs().format(DATE_FMT);
|
const today = dayjs().format(DATE_FMT);
|
||||||
const [range, setRange] = useState({ from: today, to: today });
|
const [range, setRange] = useState({ from: today, to: today });
|
||||||
const isToday = range.from === today && range.to === today;
|
const isToday = range.from === today && range.to === today;
|
||||||
@@ -139,12 +117,11 @@ export default function OrderAssignment() {
|
|||||||
? dayjs(range.from).format('DD MMM')
|
? dayjs(range.from).format('DD MMM')
|
||||||
: `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`;
|
: `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`;
|
||||||
|
|
||||||
// Single Assign Dialog State
|
|
||||||
const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null);
|
const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null);
|
||||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
||||||
const [selectedMiler, setSelectedMiler] = useState('');
|
const [selectedMiler, setSelectedMiler] = useState('');
|
||||||
|
|
||||||
const notify = (msg, severity = 'success') => setToast({ open: true, msg, severity });
|
const notify = (msg, status = 'success') => toast({ body: msg, type: status });
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -164,24 +141,15 @@ export default function OrderAssignment() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Milers that can take a new pickup right now.
|
|
||||||
const availableMilers = milers.filter((m) => ['Available', 'Assigned'].includes(m.status));
|
const availableMilers = milers.filter((m) => ['Available', 'Assigned'].includes(m.status));
|
||||||
|
|
||||||
const handleSelectAll = (event) => {
|
const handleSelectOne = (id, isSelected) => {
|
||||||
if (event.target.checked) {
|
setSelectedOrders((prev) => {
|
||||||
const pendingIds = orders.filter(o => o.status === PENDING).map(o => o.id);
|
const next = new Set(prev);
|
||||||
setSelectedOrders(pendingIds);
|
if (isSelected) next.add(String(id));
|
||||||
} else {
|
else next.delete(String(id));
|
||||||
setSelectedOrders([]);
|
return next;
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectOne = (id) => {
|
|
||||||
if (selectedOrders.includes(id)) {
|
|
||||||
setSelectedOrders(selectedOrders.filter(selectedId => selectedId !== id));
|
|
||||||
} else {
|
|
||||||
setSelectedOrders([...selectedOrders, id]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenAssign = (order) => {
|
const handleOpenAssign = (order) => {
|
||||||
@@ -190,7 +158,6 @@ export default function OrderAssignment() {
|
|||||||
setAssignDialogOpen(true);
|
setAssignDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assign a specific miler to a single booking.
|
|
||||||
const handleAssign = async () => {
|
const handleAssign = async () => {
|
||||||
if (!selectedMiler || busy) return;
|
if (!selectedMiler || busy) return;
|
||||||
const miler = availableMilers.find((m) => m.id === selectedMiler);
|
const miler = availableMilers.find((m) => m.id === selectedMiler);
|
||||||
@@ -200,7 +167,11 @@ export default function OrderAssignment() {
|
|||||||
const res = await assignMiler(order.id, selectedMiler);
|
const res = await assignMiler(order.id, selectedMiler);
|
||||||
const name = res?.data?.milername || miler?.name || 'miler';
|
const name = res?.data?.milername || miler?.name || 'miler';
|
||||||
setOrders((prev) => prev.map((o) => (o.id === order.id ? { ...o, status: `Assigned to ${name}` } : o)));
|
setOrders((prev) => prev.map((o) => (o.id === order.id ? { ...o, status: `Assigned to ${name}` } : o)));
|
||||||
setSelectedOrders((prev) => prev.filter((id) => id !== order.id));
|
setSelectedOrders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(String(order.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setAssignDialogOpen(false);
|
setAssignDialogOpen(false);
|
||||||
notify(`Pickup #${order.id} assigned to ${name}.`);
|
notify(`Pickup #${order.id} assigned to ${name}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -210,10 +181,9 @@ export default function OrderAssignment() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Let the AI engine assign every selected booking (POST /hub/bookings/:id/auto-assign).
|
|
||||||
const handleAutoAssignAll = async () => {
|
const handleAutoAssignAll = async () => {
|
||||||
if (selectedOrders.length === 0 || busy) return;
|
if (selectedOrders.size === 0 || busy) return;
|
||||||
const ids = orders.filter((o) => selectedOrders.includes(o.id) && o.status === PENDING).map((o) => o.id);
|
const ids = orders.filter((o) => selectedOrders.has(String(o.id)) && o.status === PENDING).map((o) => o.id);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
let ok = 0;
|
let ok = 0;
|
||||||
let pending = 0;
|
let pending = 0;
|
||||||
@@ -221,7 +191,6 @@ export default function OrderAssignment() {
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
const res = await autoAssignBooking(id);
|
const res = await autoAssignBooking(id);
|
||||||
// 202 → engine still working: success:true but no miler yet.
|
|
||||||
const name = res?.data?.milername;
|
const name = res?.data?.milername;
|
||||||
if (name) {
|
if (name) {
|
||||||
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: `Assigned to ${name}` } : o)));
|
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: `Assigned to ${name}` } : o)));
|
||||||
@@ -231,272 +200,351 @@ export default function OrderAssignment() {
|
|||||||
pending += 1;
|
pending += 1;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 422 → no eligible miler in range; other codes → generic failure.
|
|
||||||
failed += 1;
|
failed += 1;
|
||||||
if (err?.status === 422) {
|
if (err?.status === 422) {
|
||||||
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'No miler in range' } : o)));
|
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status: 'No miler in range' } : o)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSelectedOrders([]);
|
setSelectedOrders(new Set());
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (ok) parts.push(`assigned ${ok}`);
|
if (ok) parts.push(`assigned ${ok}`);
|
||||||
if (pending) parts.push(`${pending} in progress`);
|
if (pending) parts.push(`${pending} in progress`);
|
||||||
if (failed) parts.push(`${failed} failed`);
|
if (failed) parts.push(`${failed} failed`);
|
||||||
notify(`Auto-assign: ${parts.join(', ')}.`, failed ? 'warning' : 'success');
|
notify(`Auto-assign: ${parts.join(', ')}.`, failed ? 'warning' : 'success');
|
||||||
load(); // refresh from server for the true state
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingCount = orders.filter((o) => o.status === PENDING).length;
|
const { selectionConfig } = useTableSelectionState({
|
||||||
const isAllSelected = selectedOrders.length > 0 && selectedOrders.length === pendingCount;
|
data: orders,
|
||||||
|
idKey: 'id',
|
||||||
|
getIsItemSelectable: (o) => o.status === PENDING,
|
||||||
|
selectedKeys: selectedOrders,
|
||||||
|
setSelectedKeys: setSelectedOrders
|
||||||
|
});
|
||||||
|
const selectionPlugin = useTableSelection(selectionConfig);
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'request',
|
||||||
|
header: 'Request Details',
|
||||||
|
width: pixel(160),
|
||||||
|
renderCell: (row) => (
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1} style={{ maxWidth: 150 }}>{row.package}</Text>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'journey',
|
||||||
|
header: 'Journey',
|
||||||
|
width: proportional(2),
|
||||||
|
renderCell: (row) => (
|
||||||
|
<VStack gap={2}>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="supporting" color="secondary">{row.customer}</Text>
|
||||||
|
<Text type="body">{row.pickup}</Text>
|
||||||
|
</VStack>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="supporting" color="secondary">Going to</Text>
|
||||||
|
<Text type="body">{row.drop}</Text>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
width: pixel(140),
|
||||||
|
renderCell: (row) => {
|
||||||
|
const c = statusChip(row.status);
|
||||||
|
return <Badge variant={c.variant} label={c.label} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
header: 'Assignment',
|
||||||
|
width: pixel(180),
|
||||||
|
renderCell: (row) => {
|
||||||
|
const isAssigned = row.status !== PENDING;
|
||||||
|
const c = statusChip(row.status);
|
||||||
|
return !isAssigned ? (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleOpenAssign(row)} style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
Choose Miler
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text type="body" weight="semibold" style={{ color: c.variant === 'success' ? 'var(--color-icon-green)' : 'var(--color-text-secondary)' }}>
|
||||||
|
{row.status}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<div style={{ paddingBottom: '32px' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={AssignmentIndIcon}
|
icon={UserCheck}
|
||||||
title="Pickup Requests"
|
title="Pickup Requests"
|
||||||
subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once."
|
subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once."
|
||||||
action={<DateRangePicker value={range} onChange={setRange} />}
|
action={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Panel>
|
||||||
<CardHeader
|
<div
|
||||||
title={isToday ? 'Waiting for a Miler' : `Pickup requests · ${rangeLabel}`}
|
style={{
|
||||||
subheader={
|
padding: '20px 24px',
|
||||||
isToday
|
borderBottom: '1px solid var(--color-border)',
|
||||||
? `New pickup requests around ${hub.city || 'your city'}`
|
display: 'flex',
|
||||||
: `${orders.length} pickup request${orders.length === 1 ? '' : 's'} in this range around ${hub.city || 'your city'}`
|
alignItems: 'center',
|
||||||
}
|
justifyContent: 'space-between',
|
||||||
avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>}
|
flexWrap: 'wrap',
|
||||||
action={
|
gap: '16px'
|
||||||
<Button
|
}}
|
||||||
variant={selectedOrders.length === 0 ? 'outlined' : 'contained'}
|
>
|
||||||
color="primary"
|
<HStack gap={4} align="center">
|
||||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
|
<span
|
||||||
onClick={handleAutoAssignAll}
|
style={{
|
||||||
disabled={selectedOrders.length === 0 || busy}
|
background: 'var(--color-background-blue)',
|
||||||
sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }}
|
color: 'var(--color-icon-blue)',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`}
|
<UserCheck size={20} />
|
||||||
</Button>
|
</span>
|
||||||
}
|
<VStack gap={0.5}>
|
||||||
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
|
<Heading level={4} style={{ margin: 0 }}>{isToday ? 'Waiting for a Miler' : `Pickup requests · ${rangeLabel}`}</Heading>
|
||||||
/>
|
<Text type="supporting" color="secondary">
|
||||||
<Divider />
|
{isToday
|
||||||
|
? `New pickup requests around ${hub.city || 'your city'}`
|
||||||
|
: `${orders.length} pickup request${orders.length === 1 ? '' : 's'} in this range around ${hub.city || 'your city'}`}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
variant={selectedOrders.size === 0 ? 'secondary' : 'primary'}
|
||||||
|
onClick={handleAutoAssignAll}
|
||||||
|
disabled={selectedOrders.size === 0 || busy}
|
||||||
|
icon={!busy ? <Sparkles size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
{busy ? 'Working...' : `Auto-Assign ${selectedOrders.size > 0 ? `(${selectedOrders.size})` : ''}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
|
<div style={{ padding: '16px' }}>
|
||||||
{loadError}
|
<Banner status="error" title="Error" description={loadError} />
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Box sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
|
<div style={{ padding: '64px', textAlign: 'center' }}>
|
||||||
<CircularProgress />
|
<Text type="body" color="secondary">Loading...</Text>
|
||||||
</Box>
|
</div>
|
||||||
) : orders.length === 0 && !loadError ? (
|
|
||||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
|
||||||
<AssignmentIndIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : isMdDown ? (
|
) : isMdDown ? (
|
||||||
/* ── MOBILE / TABLET: cards ── */
|
/* ── MOBILE / TABLET: cards ── */
|
||||||
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
orders.length === 0 && !loadError ? (
|
||||||
{orders.map((row) => {
|
<EmptyState
|
||||||
const isSelected = selectedOrders.includes(row.id);
|
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><UserCheck size={48} /></span>}
|
||||||
const isAssigned = row.status !== 'Pending Assignment';
|
title={isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
|
||||||
return (
|
description="There is no data available."
|
||||||
<Card key={row.id} elevation={0}
|
style={{ padding: '64px 0' }}
|
||||||
sx={{ borderRadius: 2, border: '1px solid', borderColor: isSelected ? 'primary.main' : '#ECEEF1', bgcolor: isAssigned ? '#FAFBFC' : '#fff' }}>
|
/>
|
||||||
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
|
) : (
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1} sx={{ mb: 1.5 }}>
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
|
{orders.map((row) => {
|
||||||
|
const isSelected = selectedOrders.has(String(row.id));
|
||||||
|
const isAssigned = row.status !== PENDING;
|
||||||
|
const c = statusChip(row.status);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={row.id}
|
||||||
|
padding={4}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${isSelected ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||||
|
background: isAssigned ? 'var(--color-background-muted)' : 'var(--color-background-surface)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack gap={2} justify="between" align="center" style={{ marginBottom: '12px' }}>
|
||||||
|
<HStack gap={2} align="center">
|
||||||
{!isAssigned && (
|
{!isAssigned && (
|
||||||
<Checkbox size="small" sx={{ p: 0 }} checked={isSelected} onChange={() => handleSelectOne(row.id)} />
|
<CheckboxInput
|
||||||
|
label={`Select ${row.id}`}
|
||||||
|
isLabelHidden
|
||||||
|
value={isSelected}
|
||||||
|
onChange={(checked) => handleSelectOne(row.id, checked)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<VStack gap={0}>
|
||||||
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{row.id}</Typography>
|
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
|
||||||
<Typography variant="caption" color="text.secondary">{row.time}</Typography>
|
<Text type="supporting" color="secondary">{row.time}</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
</Stack>
|
</HStack>
|
||||||
{(() => {
|
<Badge variant={c.variant} label={c.label} />
|
||||||
const c = statusChip(row.status);
|
</HStack>
|
||||||
return (
|
|
||||||
<Chip size="small" icon={c.done && c.color === '#1E8E3E' ? <CheckCircleIcon sx={{ fontSize: '15px !important' }} /> : undefined}
|
|
||||||
label={c.label} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} />
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack spacing={1} sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
|
<VStack gap={2} style={{ padding: '12px', background: 'var(--color-background-muted)', borderRadius: 'var(--radius-inner)', marginBottom: '16px' }}>
|
||||||
<Stack direction="row" alignItems="center" gap={1}>
|
<Text type="body" weight="semibold">{row.customer}</Text>
|
||||||
<PersonOutlineRoundedIcon sx={{ fontSize: 17, color: '#9AA0A6' }} />
|
<Text type="supporting" color="secondary"><b>Pick up:</b> {row.pickup}</Text>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.customer}</Typography>
|
<Text type="supporting" color="secondary"><b>Going to:</b> {row.drop}</Text>
|
||||||
</Stack>
|
<Text type="supporting" color="secondary">{row.package}</Text>
|
||||||
<Stack direction="row" alignItems="center" gap={1}>
|
</VStack>
|
||||||
<PlaceOutlinedIcon sx={{ fontSize: 17, color: '#1E8E3E' }} />
|
|
||||||
<Typography variant="body2" color="text.secondary"><b>Pick up:</b> {row.pickup}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" alignItems="center" gap={1}>
|
|
||||||
<FlagOutlinedIcon sx={{ fontSize: 17, color: '#C01227' }} />
|
|
||||||
<Typography variant="body2" color="text.secondary"><b>Going to:</b> {row.drop}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" alignItems="center" gap={1}>
|
|
||||||
<Inventory2OutlinedIcon sx={{ fontSize: 17, color: '#9AA0A6' }} />
|
|
||||||
<Typography variant="body2" color="text.secondary">{row.package}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{!isAssigned ? (
|
{!isAssigned ? (
|
||||||
<Button fullWidth size="small" variant="contained" startIcon={<TwoWheelerIcon sx={{ fontSize: 16 }} />}
|
<Button style={{ width: '100%', justifyContent: 'center' }} variant="primary" onClick={() => handleOpenAssign(row)}>
|
||||||
onClick={() => handleOpenAssign(row)} sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none' }}>
|
|
||||||
Choose a Miler
|
Choose a Miler
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" sx={{ textAlign: 'center', fontWeight: 700, color: statusChip(row.status).color }}>{row.status}</Typography>
|
<Text
|
||||||
|
type="body"
|
||||||
|
weight="bold"
|
||||||
|
style={{ textAlign: 'center', display: 'block', color: c.variant === 'success' ? 'var(--color-icon-green)' : 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</Box>
|
)
|
||||||
) : (
|
) : (
|
||||||
/* ── DESKTOP: spacious table ── */
|
/* ── DESKTOP: spacious table ── */
|
||||||
<TableContainer sx={{ overflowX: 'auto' }}>
|
/* ── DESKTOP: spacious table ── */
|
||||||
<Table sx={{ minWidth: 820 }}>
|
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||||
<TableHead>
|
<Table
|
||||||
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
data={orders}
|
||||||
<TableCell padding="checkbox">
|
columns={columns}
|
||||||
<Checkbox
|
idKey="id"
|
||||||
indeterminate={selectedOrders.length > 0 && selectedOrders.length < pendingCount}
|
density="balanced"
|
||||||
checked={isAllSelected && pendingCount > 0}
|
dividers="rows"
|
||||||
onChange={handleSelectAll}
|
hasHover
|
||||||
disabled={pendingCount === 0}
|
plugins={{ selection: selectionPlugin }}
|
||||||
/>
|
emptyState={
|
||||||
</TableCell>
|
!loadError && (
|
||||||
{['Request', 'Pick Up From', 'Going To', 'Parcel', 'Status', 'Action'].map((h, i) => (
|
<EmptyState
|
||||||
<TableCell key={h} align={i === 5 ? 'right' : 'left'}
|
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><UserCheck size={48} /></span>}
|
||||||
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}>
|
title={isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
|
||||||
{h}
|
description="There is no data available."
|
||||||
</TableCell>
|
style={{ padding: '64px 0' }}
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{orders.map((row) => {
|
|
||||||
const isSelected = selectedOrders.includes(row.id);
|
|
||||||
const isAssigned = row.status !== 'Pending Assignment';
|
|
||||||
return (
|
|
||||||
<TableRow key={row.id} selected={isSelected} hover
|
|
||||||
sx={{ bgcolor: isAssigned ? '#FAFBFC' : 'inherit', '& td': { borderBottom: '1px solid #F4F6F8', py: 2 }, '&:last-child td': { border: 0 } }}>
|
|
||||||
<TableCell padding="checkbox">
|
|
||||||
<Checkbox checked={isSelected} onChange={() => handleSelectOne(row.id)} disabled={isAssigned} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', whiteSpace: 'nowrap' }}>{row.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2" fontWeight={700}>{row.customer}</Typography>
|
|
||||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
|
||||||
<PlaceOutlinedIcon sx={{ fontSize: 14, color: '#1E8E3E' }} />
|
|
||||||
<Typography variant="caption" color="text.secondary">{row.pickup}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ fontWeight: 600, color: '#495057' }}>{row.drop}</TableCell>
|
|
||||||
<TableCell sx={{ color: '#495057' }}>{row.package}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{(() => {
|
|
||||||
const c = statusChip(row.status);
|
|
||||||
return (
|
|
||||||
<Chip size="small" icon={c.done && c.color === '#1E8E3E' ? <CheckCircleIcon sx={{ fontSize: '15px !important' }} /> : undefined}
|
|
||||||
label={c.label} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} />
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
{!isAssigned ? (
|
|
||||||
<Button variant="outlined" size="small" startIcon={<TwoWheelerIcon sx={{ fontSize: 16 }} />}
|
|
||||||
onClick={() => handleOpenAssign(row)} sx={{ borderRadius: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
|
||||||
Choose Miler
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: statusChip(row.status).color, whiteSpace: 'nowrap' }}>{row.status}</Typography>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={assignDialogOpen} onClose={() => setAssignDialogOpen(false)} maxWidth="sm" fullWidth>
|
|
||||||
<DialogTitle>Choose a Miler for pickup #{selectedOrderForAssign?.id}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>Milers at {hub.hubname || 'this hub'}:</Typography>
|
|
||||||
{availableMilers.length === 0 ? (
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
|
||||||
No available milers right now. Try Auto-Assign, or check the Milers page.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<List>
|
|
||||||
{availableMilers.map((miler) => (
|
|
||||||
<ListItemButton
|
|
||||||
key={miler.id}
|
|
||||||
onClick={() => setSelectedMiler(miler.id)}
|
|
||||||
sx={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: selectedMiler === miler.id ? 'primary.main' : 'divider',
|
|
||||||
borderRadius: 2,
|
|
||||||
mb: 1,
|
|
||||||
bgcolor: selectedMiler === miler.id ? 'primary.lighter' : 'transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Badge color="success" variant="dot" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
|
||||||
<Avatar sx={{ bgcolor: 'grey.200', color: 'grey.700' }}><TwoWheelerIcon /></Avatar>
|
|
||||||
</Badge>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={miler.name}
|
|
||||||
secondary={
|
|
||||||
<Stack direction="row" alignItems="center" gap={0.5} component="span">
|
|
||||||
<span>{miler.status}</span>
|
|
||||||
{miler.rating != null && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<StarRoundedIcon sx={{ fontSize: 14, color: '#F5A623' }} />
|
|
||||||
<span>{miler.rating}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
primaryTypographyProps={{ fontWeight: 600 }}
|
|
||||||
/>
|
/>
|
||||||
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} />
|
)
|
||||||
</ListItemButton>
|
}
|
||||||
))}
|
/>
|
||||||
</List>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</Panel>
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setAssignDialogOpen(false)} color="inherit">Cancel</Button>
|
|
||||||
<Button onClick={handleAssign} variant="contained" disabled={!selectedMiler || busy}
|
|
||||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}>
|
|
||||||
Confirm Assignment
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Snackbar open={toast.open} autoHideDuration={4000} onClose={() => setToast({ ...toast, open: false })}
|
{/* Assign Dialog Overlay */}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
<Dialog isOpen={assignDialogOpen} onOpenChange={setAssignDialogOpen} width={500}>
|
||||||
<Alert severity={toast.severity} variant="filled" onClose={() => setToast({ ...toast, open: false })} sx={{ borderRadius: 2, fontWeight: 600 }}>
|
<Layout
|
||||||
{toast.msg}
|
header={<DialogHeader title={`Choose a Miler for pickup #${selectedOrderForAssign?.id}`} onOpenChange={setAssignDialogOpen} />}
|
||||||
</Alert>
|
content={
|
||||||
</Snackbar>
|
<LayoutContent>
|
||||||
</Box>
|
<div style={{ padding: '24px', maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
<Text type="supporting" weight="semibold" style={{ marginBottom: '16px', display: 'block' }}>Milers at {hub.hubname || 'this hub'}:</Text>
|
||||||
|
|
||||||
|
{availableMilers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No milers available"
|
||||||
|
description="No available milers right now. Try Auto-Assign, or check the Milers page."
|
||||||
|
isCompact
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VStack gap={2}>
|
||||||
|
{availableMilers.map((miler) => (
|
||||||
|
<div
|
||||||
|
key={miler.id}
|
||||||
|
onClick={() => setSelectedMiler(miler.id)}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${selectedMiler === miler.id ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||||
|
borderRadius: 'var(--radius-inner)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
background: selectedMiler === miler.id ? 'var(--color-background-muted)' : 'transparent',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack gap={4} align="center">
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
background: 'var(--color-neutral)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bike size={20} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
borderRadius: 'var(--radius-full)',
|
||||||
|
background: 'var(--color-icon-green)',
|
||||||
|
border: '2px solid var(--color-background-surface)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<VStack gap={0}>
|
||||||
|
<Text type="body" weight="semibold">{miler.name}</Text>
|
||||||
|
<HStack gap={1} align="center">
|
||||||
|
<Text type="supporting" color="secondary">{miler.status}</Text>
|
||||||
|
{miler.rating != null && (
|
||||||
|
<>
|
||||||
|
<Text type="supporting" color="secondary">•</Text>
|
||||||
|
<span style={{ color: 'var(--color-icon-orange)', display: 'flex' }}>
|
||||||
|
<Star size={12} fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
<Text type="supporting" color="secondary">{miler.rating}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={selectedMiler === miler.id}
|
||||||
|
readOnly
|
||||||
|
style={{ width: '18px', height: '18px', accentColor: 'var(--color-accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutContent>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<LayoutFooter hasDivider>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', width: '100%', padding: '16px 24px' }}>
|
||||||
|
<Button variant="ghost" onClick={() => setAssignDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={handleAssign} disabled={!selectedMiler || busy}>
|
||||||
|
{busy ? 'Working...' : 'Confirm Assignment'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</LayoutFooter>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import {
|
|
||||||
Box, Typography, Card, Avatar, Chip, Stack, Button, Grid,
|
|
||||||
IconButton, List, ListItemButton, ListItemText, Collapse, Tooltip, Divider,
|
|
||||||
LinearProgress, Menu, MenuItem, Drawer, Paper
|
|
||||||
} from '@mui/material';
|
|
||||||
import { alpha } from '@mui/material/styles';
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import {
|
||||||
|
Route, Play, Pause, RotateCcw, Gauge, Eye, EyeOff, ChevronDown, ChevronUp,
|
||||||
|
Warehouse, Flag, Package, CheckCircle2, XCircle, Bike, Store, Users, Ruler,
|
||||||
|
Wallet, Phone, MapPin, Clock, User, ArrowLeft, Scale, CalendarClock, FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
|
import { HStack, VStack } from '@astryxdesign/core/Layout';
|
||||||
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
|
import { Avatar } from '@astryxdesign/core/Avatar';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||||
|
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||||
|
import { Layout, LayoutContent } from '@astryxdesign/core/Layout';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
|
||||||
|
import Panel from '@/components/Panel';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
import StatCard from '@/components/StatCard';
|
import StatCard from '@/components/StatCard';
|
||||||
import { getRiderRoutes } from '@/api/hub';
|
import { getRiderRoutes } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
function useMediaQuery(query) {
|
||||||
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
const [matches, setMatches] = useState(window.matchMedia(query).matches);
|
||||||
import PauseRoundedIcon from '@mui/icons-material/PauseRounded';
|
useEffect(() => {
|
||||||
import ReplayRoundedIcon from '@mui/icons-material/ReplayRounded';
|
const media = window.matchMedia(query);
|
||||||
import SpeedRoundedIcon from '@mui/icons-material/SpeedRounded';
|
if (media.matches !== matches) {
|
||||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
setMatches(media.matches);
|
||||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
}
|
||||||
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
|
const listener = () => setMatches(media.matches);
|
||||||
import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded';
|
media.addEventListener('change', listener);
|
||||||
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded';
|
return () => media.removeEventListener('change', listener);
|
||||||
import FlagRoundedIcon from '@mui/icons-material/FlagRounded';
|
}, [matches, query]);
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
return matches;
|
||||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
}
|
||||||
import CancelRoundedIcon from '@mui/icons-material/CancelRounded';
|
|
||||||
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
|
|
||||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
|
||||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
|
||||||
import StraightenRoundedIcon from '@mui/icons-material/StraightenRounded';
|
|
||||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
|
||||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
|
||||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
|
||||||
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
|
|
||||||
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
|
||||||
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
|
|
||||||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
|
||||||
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
|
|
||||||
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
|
||||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
// Leaflet base-icon fix (same approach as TrackingMap.jsx)
|
// Leaflet base-icon fix (same approach as TrackingMap.jsx)
|
||||||
@@ -139,8 +139,20 @@ const moverIcon = (color) => new L.DivIcon({
|
|||||||
const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' };
|
const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' };
|
||||||
|
|
||||||
// Palette assigned to milers round-robin so each route line is a distinct colour.
|
// Palette assigned to milers round-robin so each route line is a distinct colour.
|
||||||
|
// These are intentionally literal (not design tokens) — they exist purely to tell
|
||||||
|
// routes apart at a glance, the same way a legend uses arbitrary swatch colours.
|
||||||
const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
|
const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
|
||||||
|
|
||||||
|
// Mixes a hex colour with white/alpha for tints — a tiny stand-in for the
|
||||||
|
// alpha() helper MUI provided, kept local since these are per-rider swatch
|
||||||
|
// colours rather than themed tokens.
|
||||||
|
const hexAlpha = (hex, a) => {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
||||||
|
const n = parseInt(full, 16);
|
||||||
|
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
||||||
|
};
|
||||||
|
|
||||||
// Map an API rider-route (from GET /hub/rider-routes) into the structure this
|
// Map an API rider-route (from GET /hub/rider-routes) into the structure this
|
||||||
// page renders: a `pickup` trip with a list of order stops. Fields the API does
|
// page renders: a `pickup` trip with a list of order stops. Fields the API does
|
||||||
// not provide (customer, weight, COD, slot, instructions) default gracefully.
|
// not provide (customer, weight, COD, slot, instructions) default gracefully.
|
||||||
@@ -194,19 +206,18 @@ const mapRoute = (r, i) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
|
||||||
const inr = (n) => `₹${(Number(n) || 0).toLocaleString('en-IN')}`;
|
const inr = (n) => `₹${(Number(n) || 0).toLocaleString('en-IN')}`;
|
||||||
const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
|
const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
|
||||||
|
|
||||||
const STATUS_META = {
|
const STATUS_META = {
|
||||||
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' },
|
Picked: { variant: 'success', icon: CheckCircle2, label: 'Picked up' },
|
||||||
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' },
|
Missed: { variant: 'error', icon: XCircle, label: 'Missed' },
|
||||||
'In progress': { color: '#F29900', icon: DeliveryDiningRoundedIcon, label: 'In progress' },
|
'In progress': { variant: 'warning', icon: Bike, label: 'In progress' },
|
||||||
Pending: { color: '#80868B', icon: ScheduleOutlinedIcon, label: 'Pending' },
|
Pending: { variant: 'neutral', icon: CalendarClock, label: 'Pending' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODES = {
|
const MODES = {
|
||||||
pickup: { label: 'Pickups', icon: StorefrontOutlinedIcon, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' },
|
pickup: { label: 'Pickups', icon: Store, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -226,128 +237,107 @@ async function fetchRoadRoute(stops) {
|
|||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
// Small presentational pieces
|
// Small presentational pieces
|
||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
// KPI card — aliased to the shared StatCard so this strip matches every other page.
|
|
||||||
const KpiCard = (props) => <StatCard {...props} />;
|
|
||||||
|
|
||||||
function DetailRow({ icon: Icon, label, value, valueColor }) {
|
function DetailRow({ icon: Icon, label, value, valueColor }) {
|
||||||
if (value === undefined || value === null || value === '') return null;
|
if (value === undefined || value === null || value === '') return null;
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
<HStack gap={2} align="start">
|
||||||
<Icon sx={{ fontSize: 20, color: '#9AA0A6', mt: 0.25 }} />
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex', marginTop: 2 }}><Icon size={16} /></span>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<VStack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{label}</Typography>
|
<Text type="supporting" color="secondary">{label}</Text>
|
||||||
<Typography sx={{ fontWeight: 600, color: valueColor || '#343A40', wordBreak: 'break-word' }}>{value}</Typography>
|
<Text type="body" weight="semibold" style={{ color: valueColor, wordBreak: 'break-word' }}>{value}</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
</Stack>
|
</HStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full order/pickup detail panel — rendered INLINE in the left column (not a modal)
|
// Full order/pickup detail panel — rendered in the right-side sheet so the map
|
||||||
// so the map stays visible and zoomed to the stop while these details are read.
|
// stays visible and zoomed to the stop while these details are read.
|
||||||
function OrderDetailPanel({ data, onBack }) {
|
function OrderDetailPanel({ data, onBack }) {
|
||||||
const { order, rider, mode, index } = data;
|
const { order, rider, mode, index } = data;
|
||||||
const meta = STATUS_META[order.status] || {};
|
const meta = STATUS_META[order.status] || {};
|
||||||
const StatusIcon = meta.icon || CheckCircleRoundedIcon;
|
|
||||||
const isPickup = mode === 'pickup';
|
const isPickup = mode === 'pickup';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: '#fff' }}>
|
<Layout
|
||||||
{/* Header */}
|
height="fill"
|
||||||
<Box sx={{ background: `linear-gradient(135deg, ${rider.color} 0%, ${alpha(rider.color, 0.82)} 100%)`, px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}>
|
header={
|
||||||
<Button onClick={onBack} startIcon={<ArrowBackRoundedIcon />} size="small"
|
<div style={{ background: rider.color, color: '#fff', padding: '20px 24px' }}>
|
||||||
sx={{ color: 'rgba(255,255,255,0.95)', textTransform: 'none', fontWeight: 600, mb: 1.25, ml: -0.5, '&:hover': { bgcolor: 'rgba(255,255,255,0.12)' } }}>
|
<Button variant="ghost" onClick={onBack} icon={<ArrowLeft size={16} />} style={{ color: '#fff', marginBottom: '12px', marginLeft: '-8px' }}>Close</Button>
|
||||||
Close
|
<HStack gap={1.5} wrap="wrap" style={{ marginBottom: '10px' }}>
|
||||||
</Button>
|
<Badge variant="neutral" label={isPickup ? 'Pickup' : 'Delivery'} style={{ background: 'rgba(255,255,255,0.2)', color: '#fff' }} />
|
||||||
<Stack direction="row" alignItems="center" spacing={0.75} useFlexGap sx={{ flexWrap: 'wrap', mb: 1 }}>
|
<Badge variant="neutral" label={meta.label || order.status} style={{ background: 'rgba(255,255,255,0.2)', color: '#fff' }} />
|
||||||
<Chip size="small" icon={(isPickup ? <StorefrontOutlinedIcon /> : <DeliveryDiningRoundedIcon />)} label={isPickup ? 'Pickup' : 'Delivery'}
|
</HStack>
|
||||||
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700, '& .MuiChip-icon': { color: '#fff' } }} />
|
<Heading level={4} style={{ color: '#fff', margin: 0 }}>{order.customer}</Heading>
|
||||||
<Chip size="small" icon={<StatusIcon sx={{ color: '#fff !important' }} />} label={meta.label || order.status}
|
<Text type="supporting" style={{ color: '#fff', opacity: 0.9, fontFamily: 'monospace' }}>#{order.orderId}</Text>
|
||||||
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
</div>
|
||||||
</Stack>
|
}
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25 }}>{order.customer}</Typography>
|
content={
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mt: 0.25 }}>#{order.orderId}</Typography>
|
<LayoutContent isScrollable padding={0}>
|
||||||
</Box>
|
<VStack gap={3} style={{ padding: '20px', background: 'var(--color-background-muted)' }}>
|
||||||
|
<Card padding={3}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>
|
||||||
|
{isPickup ? 'Merchant / Sender' : 'Customer'}
|
||||||
|
</Text>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<DetailRow icon={isPickup ? Store : User} label="Name" value={order.customer} />
|
||||||
|
<DetailRow icon={Phone} label="Phone" value={order.phone} />
|
||||||
|
<DetailRow icon={MapPin} label="Pickup address" value={order.address} />
|
||||||
|
</VStack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Body */}
|
<Card padding={3}>
|
||||||
<Box sx={{ p: 2, bgcolor: '#F8F9FB', flex: 1, overflow: 'auto' }}>
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Pickup Summary</Text>
|
||||||
<Stack spacing={1.75}>
|
<Grid columns={2} gap={3}>
|
||||||
|
<DetailRow icon={Clock} label="Picked at" value={order.time} />
|
||||||
|
<DetailRow icon={CalendarClock} label="Time slot" value={order.slot} />
|
||||||
|
<DetailRow icon={Package} label="Items" value={order.items != null ? `${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}` : ''} />
|
||||||
|
<DetailRow icon={Scale} label="Weight" value={order.weight} />
|
||||||
|
<DetailRow icon={Ruler} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} />
|
||||||
|
<DetailRow icon={Wallet} label="Payment" value={order.payment} />
|
||||||
|
</Grid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Contact */}
|
{order.cod > 0 && (
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
<div style={{ padding: '14px', borderRadius: 'var(--radius-element)', background: 'var(--color-background-orange)', border: '1px solid var(--color-icon-orange)' }}>
|
||||||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
<HStack gap={2} align="center">
|
||||||
{isPickup ? 'MERCHANT / SENDER' : 'CUSTOMER'}
|
<span style={{ color: 'var(--color-icon-orange)', display: 'flex' }}><Wallet size={20} /></span>
|
||||||
</Typography>
|
<VStack gap={0}>
|
||||||
<Stack spacing={1.75}>
|
<Text type="supporting" weight="bold" style={{ color: 'var(--color-text-orange)' }}>CASH ON PICKUP</Text>
|
||||||
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : PersonOutlineOutlinedIcon} label="Name" value={order.customer} />
|
<Text type="body" weight="bold" style={{ color: 'var(--color-text-orange)' }}>{inr(order.cod)}</Text>
|
||||||
<DetailRow icon={PhoneOutlinedIcon} label="Phone" value={order.phone} />
|
</VStack>
|
||||||
<DetailRow icon={PlaceOutlinedIcon} label="Pickup address" value={order.address} />
|
</HStack>
|
||||||
</Stack>
|
</div>
|
||||||
</Paper>
|
)}
|
||||||
|
|
||||||
{/* Pickup summary */}
|
<Card padding={3}>
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Route & Location</Text>
|
||||||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
<VStack gap={3}>
|
||||||
PICKUP SUMMARY
|
<DetailRow icon={isPickup ? Store : Warehouse} label="Route leg" value={isPickup ? `${order.customer} → ${HUB.label}` : `${HUB.label} → ${order.customer}`} />
|
||||||
</Typography>
|
<DetailRow icon={MapPin} label="Coordinates" value={order.lat != null && order.lng != null ? `${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}` : ''} />
|
||||||
<Grid container rowSpacing={2} columnSpacing={1.5}>
|
{order.instructions && <DetailRow icon={FileText} label="Instructions" value={order.instructions} valueColor="var(--color-text-secondary)" />}
|
||||||
<Grid item xs={6}><DetailRow icon={AccessTimeOutlinedIcon} label="Picked at" value={order.time} /></Grid>
|
</VStack>
|
||||||
<Grid item xs={6}><DetailRow icon={ScheduleOutlinedIcon} label="Time slot" value={order.slot} /></Grid>
|
</Card>
|
||||||
<Grid item xs={6}><DetailRow icon={Inventory2OutlinedIcon} label="Items" value={order.items != null ? `${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}` : ''} /></Grid>
|
|
||||||
<Grid item xs={6}><DetailRow icon={ScaleOutlinedIcon} label="Weight" value={order.weight} /></Grid>
|
|
||||||
<Grid item xs={6}><DetailRow icon={StraightenRoundedIcon} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} /></Grid>
|
|
||||||
<Grid item xs={6}><DetailRow icon={PaymentsOutlinedIcon} label="Payment" value={order.payment} /></Grid>
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{order.cod > 0 && (
|
<Card padding={3}>
|
||||||
<Box sx={{ p: 2, borderRadius: 2, bgcolor: alpha('#F29900', 0.1), border: `1px solid ${alpha('#F29900', 0.3)}` }}>
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Assigned Miler</Text>
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
<HStack gap={3} align="center">
|
||||||
<PaymentsOutlinedIcon sx={{ color: '#B06000' }} />
|
<Avatar name={rider.name} size={40} style={{ backgroundColor: hexAlpha(rider.color, 0.14), color: rider.color }} />
|
||||||
<Box>
|
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||||
<Typography variant="caption" sx={{ color: '#B06000', fontWeight: 700 }}>CASH ON PICKUP</Typography>
|
<Text type="body" weight="bold">{rider.name}</Text>
|
||||||
<Typography sx={{ fontWeight: 800, color: '#B06000' }}>{inr(order.cod)}</Typography>
|
<Text type="supporting" color="secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
</Stack>
|
</HStack>
|
||||||
</Box>
|
</Card>
|
||||||
)}
|
</VStack>
|
||||||
|
</LayoutContent>
|
||||||
{/* Route & location */}
|
}
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
footer={
|
||||||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
<div style={{ padding: '16px', borderTop: '1px solid var(--color-border)', display: 'flex', gap: '10px' }}>
|
||||||
ROUTE & LOCATION
|
<Button variant="secondary" style={{ flex: 1, justifyContent: 'center' }} icon={<Phone size={16} />} onClick={() => window.open(`tel:${order.phone}`, '_self')}>Call</Button>
|
||||||
</Typography>
|
<Button variant="primary" style={{ flex: 1, justifyContent: 'center', background: rider.color }} icon={<CheckCircle2 size={16} />} onClick={onBack}>Done</Button>
|
||||||
<Stack spacing={1.75}>
|
</div>
|
||||||
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg"
|
}
|
||||||
value={isPickup ? `${order.customer} → ${HUB.label}` : `${HUB.label} → ${order.customer}`} />
|
/>
|
||||||
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates"
|
|
||||||
value={order.lat != null && order.lng != null ? `${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}` : ''} />
|
|
||||||
{order.instructions && <DetailRow icon={NotesOutlinedIcon} label="Instructions" value={order.instructions} valueColor="#5F6368" />}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Assigned miler */}
|
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
|
||||||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
|
||||||
ASSIGNED MILER
|
|
||||||
</Typography>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
|
||||||
<Avatar sx={{ width: 40, height: 40, bgcolor: alpha(rider.color, 0.14), color: rider.color, fontWeight: 700 }}>{initials(rider.name)}</Avatar>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700 }} noWrap>{rider.name}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Box sx={{ p: 2, borderTop: '1px solid #E9ECEF', bgcolor: '#fff', display: 'flex', gap: 1.25, flexShrink: 0 }}>
|
|
||||||
<Button fullWidth variant="outlined" startIcon={<CallOutlinedIcon />} href={`tel:${order.phone}`} sx={{ borderRadius: 2 }}>Call</Button>
|
|
||||||
<Button fullWidth variant="contained" startIcon={<CheckCircleRoundedIcon />} onClick={onBack} sx={{ borderRadius: 2, bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }}>Done</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +345,7 @@ function OrderDetailPanel({ data, onBack }) {
|
|||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// ════════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════════
|
||||||
export default function RiderRoutes() {
|
export default function RiderRoutes() {
|
||||||
|
const isMdDown = useMediaQuery('(max-width: 900px)');
|
||||||
const [mode] = useState('pickup'); // pickups only
|
const [mode] = useState('pickup'); // pickups only
|
||||||
const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes
|
const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -369,7 +360,6 @@ export default function RiderRoutes() {
|
|||||||
const [playing, setPlaying] = useState(null);
|
const [playing, setPlaying] = useState(null);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [speed, setSpeed] = useState(1);
|
const [speed, setSpeed] = useState(1);
|
||||||
const [speedAnchor, setSpeedAnchor] = useState(null);
|
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
const lastTsRef = useRef(0);
|
const lastTsRef = useRef(0);
|
||||||
const resolvedRef = useRef({}); // keys we've already fetched/attempted
|
const resolvedRef = useRef({}); // keys we've already fetched/attempted
|
||||||
@@ -504,59 +494,50 @@ export default function RiderRoutes() {
|
|||||||
return { rider, path, travelled, pos };
|
return { rider, path, travelled, pos };
|
||||||
}, [playing, progress, pathFor, riders]);
|
}, [playing, progress, pathFor, riders]);
|
||||||
|
|
||||||
|
const speedMenuItems = [0.5, 1, 2, 4].map((s) => ({
|
||||||
|
label: `${s}× speed`,
|
||||||
|
icon: speed === s ? <CheckCircle2 size={14} /> : undefined,
|
||||||
|
onClick: () => setSpeed(s)
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}>
|
<div style={{ paddingBottom: '32px' }}>
|
||||||
{/* ── Header ── */}
|
<PageHeader
|
||||||
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems={{ md: 'center' }} gap={1} mb={2} sx={{ mb: 2 }}>
|
icon={Route}
|
||||||
<Stack direction="row" alignItems="center" spacing={2.5}>
|
title="Rider Routes"
|
||||||
<Avatar sx={{ bgcolor: alpha('#C01227', 0.1), color: '#C01227', width: 56, height: 56, borderRadius: 2 }}>
|
subtitle="Pickups each miler covered today — open any order for full details, or press play to replay the trip."
|
||||||
<RouteOutlinedIcon sx={{ fontSize: 30 }} />
|
/>
|
||||||
</Avatar>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: '-0.4px', lineHeight: 1.2 }}>Rider Routes</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
|
||||||
Pickups each miler covered today open any order for full details, or press play to replay the trip.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{!loading && riders.length === 0 && (
|
{!loading && riders.length === 0 && (
|
||||||
<Card elevation={0} sx={{ borderRadius: 2, border: '1px dashed #CED4DA', py: 8, textAlign: 'center', mt: 3 }}>
|
<EmptyState
|
||||||
<RouteOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
|
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Route size={48} /></span>}
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#495057' }}>No rider routes today</Typography>
|
title="No rider routes today"
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
description="Once milers are assigned pickups, their planned stops will show up here."
|
||||||
Once milers are assigned pickups, their planned stops will show up here.
|
style={{ padding: '48px 0', marginBottom: '20px' }}
|
||||||
</Typography>
|
/>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Analysis KPI strip ── */}
|
{/* KPI strip */}
|
||||||
<Grid container spacing={{ xs: 1.5, sm: 2 }} mt={4} sx={{ mb: 3 }}>
|
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={1.5} style={{ marginBottom: '16px' }}>
|
||||||
{[
|
<StatCard size="sm" icon={Users} label="Active Milers" value={kpi.activeRiders} tone="blue" />
|
||||||
{ icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${riders.length}`, color: '#1A73E8', bg: '#E8F0FE' },
|
<StatCard size="sm" icon={Store} label={modeCfg.label} value={kpi.orders} tone="red" />
|
||||||
{ icon: modeCfg.icon, label: modeCfg.label, value: kpi.orders, sub: 'pickups covered', color: '#C01227', bg: alpha('#C01227', 0.1) },
|
<StatCard size="sm" icon={CheckCircle2} label={modeCfg.doneLabel} value={kpi.done} tone="green" />
|
||||||
{ icon: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' },
|
<StatCard size="sm" icon={Ruler} label="Distance" value={`${kpi.km} km`} tone="purple" />
|
||||||
{ icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
|
|
||||||
].map((k, i) => (
|
|
||||||
<Grid size={{ xs: 6, md: 3 }} key={i}><KpiCard {...k} /></Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, pb: 4 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px' }}>
|
||||||
{/* ── Left control panel — the milers list; clicking an order opens the
|
{/* ── Left control panel — the milers list; clicking an order opens the
|
||||||
full detail in a right-side drawer (below). ── */}
|
full detail in a right-side sheet (below). ── */}
|
||||||
<Box sx={{ width: { xs: '100%', lg: 380 }, flexShrink: 0 }}>
|
<div style={{ flex: '1 1 340px', maxWidth: 400 }}>
|
||||||
<Card sx={{ borderRadius: 2, border: '1px solid #ECEEF1', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
<Panel>
|
||||||
<Box sx={{ px: 3, py: 2.25, borderBottom: '1px solid #F1F3F5', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<HStack justify="between" align="center" style={{ padding: '14px 20px', borderBottom: '1px solid var(--color-border)' }}>
|
||||||
<Typography sx={{ fontWeight: 700 }}>Milers & their {modeCfg.label.toLowerCase()}</Typography>
|
<Text type="body" weight="bold">Milers & their {modeCfg.label.toLowerCase()}</Text>
|
||||||
{(playing || progress > 0) && (
|
{(playing || progress > 0) && (
|
||||||
<Button size="small" startIcon={<ReplayRoundedIcon />} onClick={resetAnim} sx={{ textTransform: 'none', color: '#5F6368' }}>Reset</Button>
|
<Button variant="ghost" size="sm" icon={<RotateCcw size={14} />} onClick={resetAnim}>Reset</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</HStack>
|
||||||
|
|
||||||
<List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}>
|
<div style={{ maxHeight: 640, overflowY: 'auto' }}>
|
||||||
{riders.map((rider) => {
|
{riders.map((rider) => {
|
||||||
const trip = rider[mode];
|
const trip = rider[mode];
|
||||||
const orders = trip.stops.filter((s) => s.kind === 'order');
|
const orders = trip.stops.filter((s) => s.kind === 'order');
|
||||||
@@ -565,174 +546,150 @@ export default function RiderRoutes() {
|
|||||||
const isPlaying = playing === rider.id;
|
const isPlaying = playing === rider.id;
|
||||||
const isVisible = visible[rider.id];
|
const isVisible = visible[rider.id];
|
||||||
return (
|
return (
|
||||||
<Box key={rider.id} sx={{ borderBottom: '1px solid #F4F5F7' }}>
|
<div key={rider.id} style={{ borderBottom: '1px solid var(--color-border)', opacity: isVisible ? 1 : 0.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5, gap: 1, opacity: isVisible ? 1 : 0.5 }}>
|
<HStack gap={2} align="center" style={{ padding: '14px 16px 8px' }}>
|
||||||
<Box sx={{ width: 6, height: 40, borderRadius: 2, bgcolor: rider.color, flexShrink: 0 }} />
|
<span style={{ width: 5, height: 36, borderRadius: 'var(--radius-full)', background: rider.color, flexShrink: 0 }} />
|
||||||
<Avatar sx={{ width: 38, height: 38, bgcolor: alpha(rider.color, 0.12), color: rider.color, fontWeight: 700, fontSize: 14 }}>{initials(rider.name)}</Avatar>
|
<Avatar name={rider.name} size={36} style={{ backgroundColor: hexAlpha(rider.color, 0.14), color: rider.color, borderRadius: '50%' }} />
|
||||||
<ListItemButton disableGutters onClick={() => setExpanded(isOpen ? null : rider.id)} sx={{ flex: 1, borderRadius: 2, px: 1, py: 0.5, minWidth: 0 }}>
|
<button
|
||||||
<ListItemText
|
type="button"
|
||||||
primary={<Typography sx={{ fontWeight: 700, fontSize: '0.92rem' }} noWrap>{rider.name}</Typography>}
|
onClick={() => setExpanded(isOpen ? null : rider.id)}
|
||||||
secondary={<Typography variant="caption" color="text.secondary" noWrap>{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km</Typography>}
|
style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', textAlign: 'left' }}
|
||||||
/>
|
>
|
||||||
{isOpen ? <ExpandLessRoundedIcon sx={{ color: '#9AA0A6' }} /> : <ExpandMoreRoundedIcon sx={{ color: '#9AA0A6' }} />}
|
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||||
</ListItemButton>
|
<Text type="body" weight="bold" maxLines={1}>{rider.name}</Text>
|
||||||
</Box>
|
<Text type="supporting" color="secondary" maxLines={1}>{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km</Text>
|
||||||
|
</VStack>
|
||||||
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex', flexShrink: 0 }}>
|
||||||
|
{isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
{/* per-rider stat chips (analysis) */}
|
{/* per-rider stat badges (analysis) */}
|
||||||
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ px: 2, pb: 1.5 }}>
|
<HStack gap={1} wrap="wrap" style={{ padding: '0 16px 10px' }}>
|
||||||
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${orders.length} ${modeCfg.label.toLowerCase()}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
<Badge variant="neutral" label={`${orders.length} ${modeCfg.label.toLowerCase()}`} />
|
||||||
<Chip size="small" icon={<CheckCircleRoundedIcon sx={{ fontSize: '14px !important', color: '#1E8E3E !important' }} />} label={done} sx={{ height: 24, fontWeight: 600, bgcolor: alpha('#1E8E3E', 0.1), color: '#1E8E3E' }} />
|
<Badge variant="green" label={String(done)} />
|
||||||
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.distanceKm} km`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
<Badge variant="neutral" label={`${trip.distanceKm} km`} />
|
||||||
<Chip size="small" icon={<AccessTimeOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.startTime}–${trip.endTime}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
</HStack>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* action row */}
|
{/* action row */}
|
||||||
<Stack direction="row" gap={1.25} sx={{ px: 2, pb: 2 }}>
|
<HStack gap={2} style={{ padding: '0 16px 14px' }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="sm"
|
||||||
variant={isPlaying ? 'contained' : 'outlined'}
|
variant={isPlaying ? 'primary' : 'secondary'}
|
||||||
startIcon={isPlaying ? <PauseRoundedIcon /> : <PlayArrowRoundedIcon />}
|
icon={isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||||
onClick={() => startAnim(rider.id)}
|
onClick={() => startAnim(rider.id)}
|
||||||
sx={{
|
style={{ flex: 1, justifyContent: 'center', ...(isPlaying ? { background: rider.color } : { color: rider.color, borderColor: hexAlpha(rider.color, 0.5) }) }}
|
||||||
textTransform: 'none', fontWeight: 600, borderRadius: 2, flex: 1,
|
|
||||||
...(isPlaying
|
|
||||||
? { bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }
|
|
||||||
: { color: rider.color, borderColor: alpha(rider.color, 0.5), '&:hover': { borderColor: rider.color, bgcolor: alpha(rider.color, 0.06) } }),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? 'Playing…' : 'Animate route'}
|
{isPlaying ? 'Playing…' : 'Animate route'}
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip title={isVisible ? 'Hide route' : 'Show route'}>
|
<IconButton
|
||||||
<IconButton size="small" onClick={() => toggleVisible(rider.id)} sx={{ border: '1px solid #E9ECEF', borderRadius: 2 }}>
|
size="sm"
|
||||||
{isVisible ? <VisibilityOutlinedIcon fontSize="small" /> : <VisibilityOffOutlinedIcon fontSize="small" />}
|
variant="secondary"
|
||||||
</IconButton>
|
label={isVisible ? 'Hide route' : 'Show route'}
|
||||||
</Tooltip>
|
tooltip={isVisible ? 'Hide route' : 'Show route'}
|
||||||
</Stack>
|
icon={isVisible ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||||
|
onClick={() => toggleVisible(rider.id)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
{isPlaying && (
|
{isPlaying && (
|
||||||
<Box sx={{ px: 2, pb: 1.5 }}>
|
<div style={{ padding: '0 16px 14px' }}>
|
||||||
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: rider.color, borderRadius: 2 } }} />
|
<ProgressBar label={`${rider.name} playback`} isLabelHidden value={progress * 100} variant="accent" />
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* expandable stops — rich order cards; click to open detail */}
|
{/* expandable stops — rich order cards; click to open detail */}
|
||||||
<Collapse in={isOpen} unmountOnExit>
|
{isOpen && (
|
||||||
<Box sx={{ px: 2, pb: 2, pt: 0.5 }}>
|
<VStack gap={1.5} style={{ padding: '0 16px 16px' }}>
|
||||||
<Stack spacing={1.25}>
|
{trip.stops.map((s, i) => {
|
||||||
{trip.stops.map((s, i) => {
|
const key = `${rider.id}-${i}`;
|
||||||
const key = `${rider.id}-${i}`;
|
const meta = STATUS_META[s.status];
|
||||||
const isHub = s.kind === 'hub';
|
const StatusIcon = meta?.icon;
|
||||||
const meta = STATUS_META[s.status];
|
return (
|
||||||
const StatusIcon = meta?.icon;
|
<div
|
||||||
|
key={key}
|
||||||
|
onMouseEnter={() => setFocusedStop(key)}
|
||||||
|
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
|
||||||
|
onClick={() => openDetail(rider, s, i)}
|
||||||
|
style={{
|
||||||
|
padding: '12px', borderRadius: 'var(--radius-element)', cursor: 'pointer',
|
||||||
|
border: `1px solid ${focusedStop === key ? hexAlpha(rider.color, 0.55) : 'var(--color-border)'}`,
|
||||||
|
background: focusedStop === key ? hexAlpha(rider.color, 0.05) : 'var(--color-background-surface)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack justify="between" align="center" gap={1}>
|
||||||
|
<HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
|
||||||
|
<span style={{ width: 24, height: 24, borderRadius: 'var(--radius-inner)', flexShrink: 0, background: hexAlpha(rider.color, 0.14), color: rider.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 12 }}>{i}</span>
|
||||||
|
<Text type="body" weight="bold" maxLines={1}>
|
||||||
|
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 600 }}>Order </span>#{s.orderId}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{meta && <Badge variant={meta.variant} label={meta.label} icon={StatusIcon ? <StatusIcon size={12} /> : undefined} />}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
// Hub return — compact row, not a card.
|
<HStack justify="between" align="center" gap={1} style={{ marginTop: 8 }}>
|
||||||
if (isHub) {
|
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
|
||||||
return (
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Bike size={14} /></span>
|
||||||
<Stack key={key} direction="row" alignItems="center" gap={1.25} sx={{ px: 0.5, py: 0.5 }}>
|
<Text type="supporting" color="secondary" maxLines={1}>{rider.name}</Text>
|
||||||
<Box sx={{ width: 28, height: 28, borderRadius: 2, flexShrink: 0, bgcolor: alpha('#C01227', 0.12), color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
</HStack>
|
||||||
<WarehouseRoundedIcon sx={{ fontSize: 16 }} />
|
<HStack gap={1} align="center" style={{ flexShrink: 0 }}>
|
||||||
</Box>
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Clock size={13} /></span>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Text type="supporting" color="secondary">{s.time}</Text>
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.label}</Typography>
|
</HStack>
|
||||||
<Typography variant="caption" color="text.secondary">Back to hub · {s.time}</Typography>
|
</HStack>
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
<Box
|
|
||||||
key={key}
|
|
||||||
onMouseEnter={() => setFocusedStop(key)}
|
|
||||||
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
|
|
||||||
onClick={() => openDetail(rider, s, i)}
|
|
||||||
sx={{
|
|
||||||
p: 1.5, borderRadius: 2, cursor: 'pointer', transition: 'all .15s',
|
|
||||||
border: '1px solid', borderColor: focusedStop === key ? alpha(rider.color, 0.55) : '#ECEEF1',
|
|
||||||
bgcolor: focusedStop === key ? alpha(rider.color, 0.04) : '#fff',
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.03)',
|
|
||||||
'&:hover': { borderColor: alpha(rider.color, 0.55), boxShadow: '0 4px 14px rgba(0,0,0,0.07)' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* order id + status */}
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
|
|
||||||
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
|
|
||||||
<Box sx={{ width: 26, height: 26, borderRadius: 2, flexShrink: 0, bgcolor: alpha(rider.color, 0.14), color: rider.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 12 }}>{i}</Box>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>
|
|
||||||
<Box component="span" sx={{ color: '#9AA0A6', fontWeight: 600 }}>Order </Box>#{s.orderId}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
{meta && (
|
|
||||||
<Chip size="small" icon={<StatusIcon sx={{ fontSize: '14px !important', color: `${meta.color} !important` }} />} label={meta.label}
|
|
||||||
sx={{ height: 22, fontSize: '0.68rem', fontWeight: 700, color: meta.color, bgcolor: alpha(meta.color, 0.1), flexShrink: 0 }} />
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* rider + time */}
|
<HStack gap={1} align="center">
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mt: 1 }}>
|
<span style={{ color: rider.color, display: 'flex' }}><Store size={14} /></span>
|
||||||
<Stack direction="row" alignItems="center" gap={0.75} sx={{ minWidth: 0 }}>
|
<Text type="body" weight="bold" maxLines={1}>{s.customer}</Text>
|
||||||
<DeliveryDiningRoundedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0 }} />
|
</HStack>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }} noWrap>{rider.name}</Typography>
|
<HStack gap={1} align="start" style={{ marginTop: 4 }}>
|
||||||
</Stack>
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex', marginTop: 1 }}><MapPin size={14} /></span>
|
||||||
<Stack direction="row" alignItems="center" gap={0.5} sx={{ flexShrink: 0 }}>
|
<Text type="supporting" color="secondary" maxLines={1} style={{ flex: 1 }}>{s.address}</Text>
|
||||||
<AccessTimeOutlinedIcon sx={{ fontSize: 14, color: '#9AA0A6' }} />
|
</HStack>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{s.time}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 1.25 }} />
|
{(s.legKm != null || s.weight || s.items != null) && (
|
||||||
|
<HStack gap={1} wrap="wrap" style={{ marginTop: 10 }}>
|
||||||
{/* merchant + address */}
|
{s.legKm != null && <Badge variant="neutral" label={`${s.legKm} km`} />}
|
||||||
<Stack direction="row" alignItems="center" gap={0.75}>
|
{s.weight && <Badge variant="neutral" label={s.weight} />}
|
||||||
<StorefrontOutlinedIcon sx={{ fontSize: 16, color: rider.color, flexShrink: 0 }} />
|
{s.items != null && <Badge variant="neutral" label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} />}
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.customer}</Typography>
|
</HStack>
|
||||||
</Stack>
|
)}
|
||||||
<Stack direction="row" alignItems="flex-start" gap={0.75} sx={{ mt: 0.5 }}>
|
</div>
|
||||||
<PlaceOutlinedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0, mt: '1px' }} />
|
);
|
||||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography>
|
})}
|
||||||
</Stack>
|
</VStack>
|
||||||
|
)}
|
||||||
{/* metric chips — only render fields the API actually returned */}
|
</div>
|
||||||
{(s.legKm != null || s.weight || s.items != null) && (
|
|
||||||
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.75, mt: 1.25 }}>
|
|
||||||
{s.legKm != null && <Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
|
||||||
{s.weight && <Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
|
||||||
{s.items != null && <Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</div>
|
||||||
</Card>
|
</Panel>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{/* ── Map ── */}
|
{/* ── Map ── */}
|
||||||
<Box ref={mapRef} sx={{ flex: 1, minWidth: 0, scrollMarginTop: 72 }}>
|
<div ref={mapRef} style={{ flex: '2 1 480px', minWidth: 0, scrollMarginTop: 72 }}>
|
||||||
<Card sx={{ height: { xs: 460, sm: 560, lg: 700 }, borderRadius: 2, border: '1px solid #ECEEF1', overflow: 'hidden', position: 'relative', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
<Card padding={0} style={{ height: isMdDown ? 400 : 560, border: '1px solid var(--color-border)', overflow: 'hidden', position: 'relative' }}>
|
||||||
{playState && (
|
{playState && (
|
||||||
<Box sx={{ position: 'absolute', top: 14, left: 14, zIndex: 1000, minWidth: 230, bgcolor: 'rgba(255,255,255,0.97)', borderRadius: 2, p: 1.75, boxShadow: '0 8px 28px rgba(0,0,0,0.14)', border: `1px solid ${alpha(playState.rider.color, 0.3)}` }}>
|
<div style={{ position: 'absolute', top: 14, left: 14, zIndex: 1000, minWidth: 220, background: 'var(--color-background-surface)', borderRadius: 'var(--radius-element)', padding: '14px', boxShadow: 'var(--shadow-high, 0 8px 28px rgba(0,0,0,0.14))', border: `1px solid ${hexAlpha(playState.rider.color, 0.3)}` }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={1.25} mb={1}>
|
<HStack gap={2} align="center" style={{ marginBottom: 8 }}>
|
||||||
<Avatar sx={{ width: 30, height: 30, bgcolor: playState.rider.color, fontSize: 12, fontWeight: 700 }}>{initials(playState.rider.name)}</Avatar>
|
<Avatar name={playState.rider.name} size={28} style={{ backgroundColor: playState.rider.color, color: '#fff' }} />
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<VStack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem' }} noWrap>{playState.rider.name}</Typography>
|
<Text type="supporting" weight="bold" maxLines={1}>{playState.rider.name}</Text>
|
||||||
<Typography variant="caption" color="text.secondary">Replaying {modeCfg.label.toLowerCase().replace(/s$/, '')} · {Math.round(progress * 100)}%</Typography>
|
<Text type="supporting" color="secondary">Replaying pickup · {Math.round(progress * 100)}%</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
<IconButton size="small" onClick={() => setPlaying(null)}><PauseRoundedIcon fontSize="small" /></IconButton>
|
<IconButton size="sm" variant="ghost" label="Pause" icon={<Pause size={14} />} onClick={() => setPlaying(null)} />
|
||||||
<Tooltip title="Speed"><IconButton size="small" onClick={(e) => setSpeedAnchor(e.currentTarget)}><SpeedRoundedIcon fontSize="small" /></IconButton></Tooltip>
|
<DropdownMenu
|
||||||
</Stack>
|
button={{ icon: <Gauge size={14} />, variant: 'ghost', size: 'sm', isIconOnly: true, label: 'Change speed' }}
|
||||||
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: playState.rider.color, borderRadius: 2 } }} />
|
hasChevron={false}
|
||||||
</Box>
|
items={speedMenuItems}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<ProgressBar label="Playback progress" isLabelHidden value={progress * 100} variant="accent" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Menu anchorEl={speedAnchor} open={Boolean(speedAnchor)} onClose={() => setSpeedAnchor(null)}>
|
|
||||||
{[0.5, 1, 2, 4].map((s) => (<MenuItem key={s} selected={speed === s} onClick={() => { setSpeed(s); setSpeedAnchor(null); }}>{s}× speed</MenuItem>))}
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<MapContainer center={[HUB.lat, HUB.lng]} zoom={11} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
<MapContainer center={[HUB.lat, HUB.lng]} zoom={11} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
||||||
<MapResizeHandler />
|
<MapResizeHandler />
|
||||||
@@ -769,17 +726,17 @@ export default function RiderRoutes() {
|
|||||||
eventHandlers={{ click: () => openDetail(rider, s, i) }}
|
eventHandlers={{ click: () => openDetail(rider, s, i) }}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{modeCfg.pointLabel} {i} · {s.customer}</Typography>
|
<strong>{modeCfg.pointLabel} {i} · {s.customer}</strong>
|
||||||
<Typography variant="caption" display="block">{s.address}</Typography>
|
<div>{s.address}</div>
|
||||||
<Typography variant="caption" display="block">#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label}</Typography>
|
<div>#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label}</div>
|
||||||
<Typography variant="caption" display="block" sx={{ color: rider.color, fontWeight: 700, cursor: 'pointer' }} onClick={() => openDetail(rider, s, i)}>View full details →</Typography>
|
<div style={{ color: rider.color, fontWeight: 700, cursor: 'pointer' }} onClick={() => openDetail(rider, s, i)}>View full details →</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!dimmed && endStop && (
|
{!dimmed && endStop && (
|
||||||
<Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}>
|
<Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}>
|
||||||
<LTooltip direction="top" offset={[0, -14]}>{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}</LTooltip>
|
<LTooltip direction="top" offset={[0, -14]}>Returned to hub · {rider[mode].endTime}</LTooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -798,49 +755,38 @@ export default function RiderRoutes() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<Stack direction="row" flexWrap="wrap" gap={2} sx={{ mt: 2 }}>
|
<HStack gap={3} wrap="wrap" style={{ marginTop: '14px' }}>
|
||||||
{riders.map((r) => (
|
{riders.map((r) => (
|
||||||
<Stack key={r.id} direction="row" alignItems="center" gap={0.75} sx={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}>
|
<HStack key={r.id} gap={1.5} align="center" style={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}>
|
||||||
<Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} />
|
<span style={{ width: 18, height: 4, borderRadius: 'var(--radius-full)', background: r.color, display: 'inline-block' }} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography>
|
<Text type="supporting" weight="semibold" color="secondary">{r.name}</Text>
|
||||||
</Stack>
|
</HStack>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<Stack direction="row" alignItems="center" gap={0.75}>
|
<HStack gap={1} align="center">
|
||||||
<FlagRoundedIcon sx={{ fontSize: 16, color: '#1E8E3E' }} />
|
<span style={{ color: 'var(--color-icon-green)', display: 'flex' }}><Flag size={16} /></span>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{mode === 'pickup' ? 'Hub return' : 'Trip end'}</Typography>
|
<Text type="supporting" weight="semibold" color="secondary">Hub return</Text>
|
||||||
</Stack>
|
</HStack>
|
||||||
<Stack direction="row" alignItems="center" gap={0.75}>
|
<HStack gap={1} align="center">
|
||||||
<WarehouseRoundedIcon sx={{ fontSize: 16, color: '#C01227' }} />
|
<span style={{ color: 'var(--color-brand)', display: 'flex' }}><Warehouse size={16} /></span>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>Hub</Typography>
|
<Text type="supporting" weight="semibold" color="secondary">Hub</Text>
|
||||||
</Stack>
|
</HStack>
|
||||||
</Stack>
|
</HStack>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{/* ── Order detail — right-side drawer ── */}
|
{/* ── Order detail — right-side sheet ── */}
|
||||||
<Drawer
|
<Dialog
|
||||||
anchor="right"
|
isOpen={Boolean(detail)}
|
||||||
open={Boolean(detail)}
|
onOpenChange={(o) => { if (!o) closeDetail(); }}
|
||||||
onClose={closeDetail}
|
width={380}
|
||||||
sx={{
|
maxHeight="100vh"
|
||||||
// Temporary drawers render at zIndex.drawer (1200) here, below the app bar
|
style={{ height: '100vh' }}
|
||||||
// (drawer + 1). On mobile the sheet starts at top:0, so its Close button
|
position={{ top: 0, right: 0, bottom: 0 }}
|
||||||
// would hide under the app bar. Lift the whole modal above it.
|
purpose="info"
|
||||||
zIndex: (t) => t.zIndex.modal,
|
|
||||||
'& .MuiDrawer-paper': {
|
|
||||||
width: { xs: '100%', sm: 360 },
|
|
||||||
maxWidth: '100%',
|
|
||||||
top: { xs: 0, sm: 64 },
|
|
||||||
height: { xs: '100%', sm: 'calc(100% - 64px)' },
|
|
||||||
borderTopLeftRadius: { sm: 16 },
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '-8px 0 30px rgba(0,0,0,0.12)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{detail && <OrderDetailPanel data={detail} onBack={closeDetail} />}
|
{detail && <OrderDetailPanel data={detail} onBack={closeDetail} />}
|
||||||
</Drawer>
|
</Dialog>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,17 @@
|
|||||||
|
/* eslint-disable react/prop-types */
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import { Box, QrCode, ArrowRight, HelpCircle, AlertTriangle, Truck, MapPin, Snowflake, Tag } from 'lucide-react';
|
||||||
Box, Typography, Card, CardContent, CardHeader, Grid, TextField, Button,
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
Stack, Divider, Alert, AlertTitle, Avatar, List, ListItemButton, ListItemText, Chip, CircularProgress
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
} from '@mui/material';
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
import HelpIcon from '@mui/icons-material/Help';
|
import { HStack, VStack } from '@astryxdesign/core/Layout';
|
||||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
|
||||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
|
||||||
import HubIcon from '@mui/icons-material/Hub';
|
|
||||||
import AcUnitIcon from '@mui/icons-material/AcUnit';
|
|
||||||
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
|
||||||
|
|
||||||
|
import Panel from '@/components/Panel';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
import { getRouting, getInboundToday } from '@/api/hub';
|
import { getRouting, getInboundToday } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
@@ -20,6 +19,56 @@ import { getHubContext } from '@/auth/session';
|
|||||||
const isException = (condition) =>
|
const isException = (condition) =>
|
||||||
/damag|wet|crush|missing|broken/i.test(condition || '');
|
/damag|wet|crush|missing|broken/i.test(condition || '');
|
||||||
|
|
||||||
|
// Classify the next action from the API's nexthop + condition. Colors map to
|
||||||
|
// Astryx's categorical tokens so each queue type reads as part of the same
|
||||||
|
// status language as Badge/StatCard, not one-off hex values.
|
||||||
|
const determineNextAction = (pkg) => {
|
||||||
|
if (isException(pkg.condition)) {
|
||||||
|
return { queue: 'Needs Checking', action: 'Set aside for a supervisor', tone: 'red', icon: <AlertTriangle size={22} /> };
|
||||||
|
}
|
||||||
|
if (pkg.iscoldchain) {
|
||||||
|
return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', tone: 'cyan', icon: <Snowflake size={22} /> };
|
||||||
|
}
|
||||||
|
if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) {
|
||||||
|
return { queue: 'Transfer to Another City', action: pkg.nexthop, tone: 'purple', icon: <Truck size={22} /> };
|
||||||
|
}
|
||||||
|
if ((pkg.nexthop || '').toLowerCase().includes('local')) {
|
||||||
|
return { queue: 'Local Delivery', action: `Send to ${pkg.destination || 'the delivery lane'}`, tone: 'green', icon: <MapPin size={22} /> };
|
||||||
|
}
|
||||||
|
return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', tone: 'gray', icon: <HelpCircle size={22} /> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_VARS = {
|
||||||
|
red: { icon: 'var(--color-icon-red)', bg: 'var(--color-background-red)' },
|
||||||
|
cyan: { icon: 'var(--color-icon-cyan)', bg: 'var(--color-background-cyan)' },
|
||||||
|
purple: { icon: 'var(--color-icon-purple)', bg: 'var(--color-background-purple)' },
|
||||||
|
green: { icon: 'var(--color-icon-green)', bg: 'var(--color-background-green)' },
|
||||||
|
gray: { icon: 'var(--color-icon-secondary)', bg: 'var(--color-background-muted)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function IconTile({ tone = 'blue', size = 32, children }) {
|
||||||
|
const { icon, bg } = tone === 'blue'
|
||||||
|
? { icon: 'var(--color-icon-blue)', bg: 'var(--color-background-blue)' }
|
||||||
|
: TONE_VARS[tone] || TONE_VARS.gray;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
background: bg,
|
||||||
|
color: icon,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Routing() {
|
export default function Routing() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
@@ -68,230 +117,189 @@ export default function Routing() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, handleSearch]);
|
}, [searchParams, handleSearch]);
|
||||||
|
|
||||||
// Classify the next action from the API's nexthop + condition.
|
|
||||||
const determineNextAction = (pkg) => {
|
|
||||||
if (isException(pkg.condition)) {
|
|
||||||
return { queue: 'Needs Checking', action: 'Set aside for a supervisor', color: '#D93025', bg: '#FCE8E6', icon: <WarningAmberIcon /> };
|
|
||||||
}
|
|
||||||
if (pkg.iscoldchain) {
|
|
||||||
return { queue: 'Cold Chain', action: 'Put in the cold room (Zone C)', color: '#00838F', bg: '#E0F7FA', icon: <AcUnitIcon /> };
|
|
||||||
}
|
|
||||||
if ((pkg.nexthop || '').toLowerCase().startsWith('transfer')) {
|
|
||||||
return { queue: 'Transfer to Another City', action: pkg.nexthop, color: '#8E24AA', bg: '#F3E5F5', icon: <LocalShippingIcon /> };
|
|
||||||
}
|
|
||||||
if ((pkg.nexthop || '').toLowerCase().includes('local')) {
|
|
||||||
return { queue: 'Local Delivery', action: `Send to ${pkg.destination || 'the delivery lane'}`, color: '#1E8E3E', bg: '#E6F4EA', icon: <HubIcon /> };
|
|
||||||
}
|
|
||||||
return { queue: pkg.nexthop || 'Check the address', action: pkg.nexthop || 'Check the address', color: '#5F6368', bg: '#F1F3F4', icon: <HelpIcon /> };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<div style={{ paddingBottom: '32px', display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||||
<Box sx={{ mb: 4 }}>
|
<PageHeader
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography>
|
icon={Box}
|
||||||
<Typography variant="body1" color="text.secondary">
|
title="Where Does It Go?"
|
||||||
Scan a parcel and we'll tell you exactly what to do with it next deliver locally, transfer to another city, or set it aside.
|
subtitle="Scan a parcel and we'll tell you exactly what to do with it next — deliver locally, transfer to another city, or set it aside."
|
||||||
</Typography>
|
/>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Grid container spacing={3.5}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', flex: 1, alignItems: 'stretch' }}>
|
||||||
{/* Search Panel */}
|
{/* Search Panel */}
|
||||||
<Grid size={{ xs: 12, md: 5, lg: 4 }} >
|
<VStack gap={2} style={{ flex: '1 1 300px', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Stack spacing={3}>
|
<Panel style={{ flex: 3, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}>
|
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
<CardHeader
|
<IconTile size={30}><QrCode size={16} /></IconTile>
|
||||||
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Scan a Parcel</Typography>}
|
<Text type="body" weight="bold">Scan a Parcel</Text>
|
||||||
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><QrCodeScannerIcon /></Avatar>}
|
</div>
|
||||||
|
<VStack gap={3} style={{ padding: '16px', flex: 1 }}>
|
||||||
|
<TextInput
|
||||||
|
label="Parcel tracking number"
|
||||||
|
placeholder="e.g. DM-1001"
|
||||||
|
value={searchId}
|
||||||
|
onChange={setSearchId}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Button
|
||||||
<CardContent sx={{ pt: 3 }}>
|
onClick={() => handleSearch()}
|
||||||
<Stack spacing={2}>
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
<TextField
|
disabled={loading}
|
||||||
fullWidth
|
variant="primary"
|
||||||
label="Parcel tracking number"
|
>
|
||||||
placeholder="e.g. DM-1001"
|
{loading ? 'Checking…' : 'Tell Me What To Do'}
|
||||||
value={searchId}
|
</Button>
|
||||||
onChange={(e) => setSearchId(e.target.value)}
|
</VStack>
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
</Panel>
|
||||||
InputProps={{
|
|
||||||
sx: { borderRadius: 2, bgcolor: '#fff' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="large"
|
|
||||||
onClick={() => handleSearch()}
|
|
||||||
fullWidth
|
|
||||||
disabled={loading}
|
|
||||||
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null}
|
|
||||||
sx={{ bgcolor: '#C01227', py: 1.5, borderRadius: 2, boxShadow: '0px 6px 16px rgba(192, 18, 39, 0.28)', '&:hover': { bgcolor: '#9E0E20' } }}
|
|
||||||
>
|
|
||||||
{loading ? 'Checking…' : 'Tell Me What To Do'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}>
|
<Panel style={{ flex: 7, display: 'flex', flexDirection: 'column' }}>
|
||||||
<CardHeader
|
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Parcels Waiting</Typography>}
|
<IconTile size={30}><Tag size={16} /></IconTile>
|
||||||
subheader={<Typography variant="caption" sx={{ color: 'text.secondary' }}>Tap a parcel to check it</Typography>}
|
<VStack gap={0}>
|
||||||
avatar={<Avatar sx={{ bgcolor: '#E8F0FE', color: '#1A73E8', borderRadius: 2 }}><LocalOfferIcon /></Avatar>}
|
<Text type="body" weight="bold">Parcels Waiting</Text>
|
||||||
/>
|
<Text type="supporting" color="secondary">Tap a parcel to check it</Text>
|
||||||
<Divider />
|
</VStack>
|
||||||
<CardContent sx={{ pt: 2, p: 1 }}>
|
</div>
|
||||||
<List disablePadding>
|
<div style={{ padding: '8px', display: 'flex', flexDirection: 'column', gap: '6px', flex: 1, overflowY: 'auto' }}>
|
||||||
{waiting.length === 0 && (
|
{waiting.length === 0 && (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
<div style={{ padding: '16px', textAlign: 'center' }}>
|
||||||
No parcels inbounded today.
|
<Text type="supporting" color="secondary">No parcels inbounded today.</Text>
|
||||||
</Typography>
|
</div>
|
||||||
)}
|
)}
|
||||||
{waiting.map((pkg) => (
|
{waiting.map((pkg) => {
|
||||||
<ListItemButton
|
const selected = searchId === pkg.trackingno;
|
||||||
key={pkg.trackingno}
|
return (
|
||||||
onClick={() => {
|
<div
|
||||||
setSearchId(pkg.trackingno);
|
key={pkg.trackingno}
|
||||||
handleSearch(pkg.trackingno);
|
onClick={() => {
|
||||||
}}
|
setSearchId(pkg.trackingno);
|
||||||
selected={searchId === pkg.trackingno}
|
handleSearch(pkg.trackingno);
|
||||||
sx={{
|
}}
|
||||||
borderRadius: 2, mb: 1, p: 2,
|
style={{
|
||||||
border: '1px solid',
|
padding: '10px 12px',
|
||||||
borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea',
|
borderRadius: 'var(--radius-inner)',
|
||||||
bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff',
|
cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: '#f8f9fa' }
|
border: `1px solid ${selected ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||||
}}
|
backgroundColor: selected ? 'var(--color-accent-muted)' : 'var(--color-background-surface)',
|
||||||
>
|
transition: 'all 0.15s ease'
|
||||||
<ListItemText
|
}}
|
||||||
primary={pkg.trackingno}
|
>
|
||||||
secondary={`Going to ${pkg.dest}`}
|
<Text type="body" weight="bold" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>{pkg.trackingno}</Text>
|
||||||
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === pkg.trackingno ? '#C01227' : '#212529' }}
|
<Text type="supporting" color="secondary" style={{ display: 'block', marginTop: 2 }}>Going to {pkg.dest}</Text>
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }}
|
</div>
|
||||||
/>
|
);
|
||||||
</ListItemButton>
|
})}
|
||||||
))}
|
</div>
|
||||||
</List>
|
</Panel>
|
||||||
</CardContent>
|
</VStack>
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Visual Route Guideline Panel */}
|
{/* Visual Route Guideline Panel */}
|
||||||
<Grid size={{ xs: 12, md: 7, lg: 8 }} >
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 2, minWidth: '320px' }}>
|
||||||
{!searched ? (
|
{!searched ? (
|
||||||
<Card sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: { xs: 8, sm: 15 }, px: 2, border: '2px dashed #CED4DA', bgcolor: '#fff', borderRadius: 2, height: '100%', boxShadow: 'none' }}>
|
<Card style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '64px 24px', border: '2px dashed #cbd5e1', backgroundColor: '#f8fafc', boxShadow: 'none', height: '100%' }}>
|
||||||
<Stack alignItems="center" spacing={3}>
|
<div style={{ background: '#e2e8f0', color: '#94a3b8', width: '80px', height: '80px', borderRadius: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '24px' }}>
|
||||||
<Avatar sx={{ bgcolor: '#F8F9FA', color: '#ADB5BD', width: 80, height: 80 }}>
|
<QrCode size={40} />
|
||||||
<QrCodeScannerIcon sx={{ fontSize: 40 }} />
|
</div>
|
||||||
</Avatar>
|
<Heading level={4} style={{ color: '#475569', marginBottom: '8px', textAlign: 'center' }}>Scan a parcel to begin</Heading>
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
<Text type="body" color="secondary" style={{ textAlign: 'center' }}>We'll show you where it needs to go.</Text>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 800, color: '#495057' }}>Scan a parcel to begin</Typography>
|
|
||||||
<Typography variant="body2" sx={{ mt: 1, color: '#868E96' }}>We'll show you where it needs to go.</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
</Card>
|
||||||
) : matchedPkg ? (
|
) : matchedPkg ? (
|
||||||
<Card sx={{ height: '100%', borderRadius: 2, border: '1px solid #eaeaea', boxShadow: '0px 4px 20px rgba(0,0,0,0.06)' }}>
|
<Panel style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<CardHeader
|
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
title={<Typography variant="h5" sx={{ fontWeight: 800 }}>{matchedPkg.trackingno}</Typography>}
|
<VStack gap={0}>
|
||||||
subheader={<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>{matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}</Typography>}
|
<Text type="large" weight="bold" style={{ fontFamily: 'monospace' }}>{matchedPkg.trackingno}</Text>
|
||||||
action={
|
<Text type="supporting" color="secondary">
|
||||||
<Chip
|
{matchedPkg.customername ? `For ${matchedPkg.customername}` : `Shelf: ${matchedPkg.recommendedshelf || '—'}`}
|
||||||
label={matchedPkg.condition || 'Good'}
|
</Text>
|
||||||
sx={{ fontWeight: 700, bgcolor: isException(matchedPkg.condition) ? '#FCE8E6' : '#F1F3F5', color: isException(matchedPkg.condition) ? '#D93025' : '#495057', borderRadius: 2 }}
|
</VStack>
|
||||||
/>
|
<Badge variant={isException(matchedPkg.condition) ? 'error' : 'neutral'} label={matchedPkg.condition || 'Good'} />
|
||||||
}
|
</div>
|
||||||
sx={{ px: { xs: 2.5, sm: 4 }, pt: { xs: 3, sm: 4 }, pb: 2 }}
|
<VStack gap={3} style={{ padding: '16px' }}>
|
||||||
/>
|
|
||||||
<Divider />
|
|
||||||
<CardContent sx={{ p: { xs: 2.5, sm: 4 } }}>
|
|
||||||
|
|
||||||
{/* Action Banner */}
|
{/* Action Banner */}
|
||||||
<Box sx={{ mb: { xs: 3, sm: 5 }, p: { xs: 2, sm: 3 }, bgcolor: '#F8F9FB', borderRadius: 2, display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, border: '1px solid #E9ECEF' }}>
|
<HStack
|
||||||
<Avatar sx={{ bgcolor: '#212529', color: '#fff', width: { xs: 50, sm: 64 }, height: { xs: 50, sm: 64 }, borderRadius: 2, flexShrink: 0 }}>
|
gap={4}
|
||||||
{determineNextAction(matchedPkg).icon}
|
align="center"
|
||||||
</Avatar>
|
style={{ padding: '16px', backgroundColor: TONE_VARS[determineNextAction(matchedPkg).tone]?.bg || 'var(--color-background-muted)', borderRadius: 'var(--radius-element)' }}
|
||||||
<Box sx={{ minWidth: 0 }}>
|
>
|
||||||
<Typography variant="overline" sx={{ display: 'block', color: '#6c757d', fontWeight: 700, letterSpacing: 1, lineHeight: 1.3 }}>
|
<IconTile tone={determineNextAction(matchedPkg).tone} size={52}>{determineNextAction(matchedPkg).icon}</IconTile>
|
||||||
|
<VStack gap={0.5}>
|
||||||
|
<Text type="supporting" weight="bold" style={{ letterSpacing: '0.06em', textTransform: 'uppercase' }} color="secondary">
|
||||||
{determineNextAction(matchedPkg).queue}
|
{determineNextAction(matchedPkg).queue}
|
||||||
</Typography>
|
</Text>
|
||||||
<Typography sx={{ fontWeight: 800, color: '#212529', mt: 0.5, fontSize: { xs: '1.3rem', sm: '2.125rem' }, lineHeight: 1.15 }}>
|
<Text type="large" weight="bold" style={{ lineHeight: 1.2 }}>
|
||||||
{determineNextAction(matchedPkg).action}
|
{determineNextAction(matchedPkg).action}
|
||||||
</Typography>
|
</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
</Box>
|
</HStack>
|
||||||
|
|
||||||
{/* Parcel Journey */}
|
{/* Parcel Journey */}
|
||||||
<Box sx={{ mb: { xs: 3, sm: 5 }, p: { xs: 2, sm: 3 }, bgcolor: '#f8f9fa', borderRadius: 2, border: '1px solid #eaeaea' }}>
|
<div style={{ padding: '16px', backgroundColor: 'var(--color-background-surface)', borderRadius: 'var(--radius-element)', border: '1px solid var(--color-border)' }}>
|
||||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mb: { xs: 2, sm: 3 }, letterSpacing: '0.08em', fontWeight: 700 }}>
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '12px' }}>
|
||||||
The Parcel's Journey
|
The Parcel's Journey
|
||||||
</Typography>
|
</Text>
|
||||||
<Stack
|
<HStack align="center" justify="between" wrap="wrap" gap={2}>
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
<VStack gap={0.5} style={{ flex: 1, minWidth: 100 }}>
|
||||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
<Text type="supporting" color="secondary">Right Now (Here)</Text>
|
||||||
justifyContent="space-between"
|
<Text type="body" weight="bold">{HUB_NAME}</Text>
|
||||||
spacing={2}
|
</VStack>
|
||||||
>
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><ArrowRight size={16} /></span>
|
||||||
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
|
<VStack gap={0.5} style={{ flex: 1, minWidth: 100, textAlign: 'center' }}>
|
||||||
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Right Now (Here)</Typography>
|
<Text type="supporting" color="secondary">Put On Shelf</Text>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 800, color: '#C01227', mt: 0.5 }}>{HUB_NAME}</Typography>
|
<Text type="body" weight="semibold">{matchedPkg.recommendedshelf || '—'}</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><ArrowRight size={16} /></span>
|
||||||
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
|
<VStack gap={0.5} style={{ flex: 1, minWidth: 100, textAlign: 'right' }}>
|
||||||
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Put On Shelf</Typography>
|
<Text type="supporting" color="secondary">Going To</Text>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.recommendedshelf || '—'}</Typography>
|
<Text type="body" weight="semibold">{matchedPkg.destination || '—'}</Text>
|
||||||
</Box>
|
</VStack>
|
||||||
<ArrowForwardIcon sx={{ color: '#CED4DA', transform: { xs: 'rotate(90deg)', sm: 'none' }, alignSelf: 'center' }} />
|
</HStack>
|
||||||
<Box sx={{ textAlign: { xs: 'left', sm: 'center' }, flex: 1 }}>
|
</div>
|
||||||
<Typography variant="subtitle2" sx={{ color: '#868E96' }}>Going To</Typography>
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, color: '#212529', mt: 0.5 }}>{matchedPkg.destination || '—'}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Handling Alerts */}
|
{/* Handling Alerts */}
|
||||||
{isException(matchedPkg.condition) && (
|
{isException(matchedPkg.condition) && (
|
||||||
<Alert severity="error" variant="filled" sx={{ borderRadius: 2, mb: 2 }}>
|
<Banner
|
||||||
<AlertTitle sx={{ fontWeight: 700 }}>Something's Wrong</AlertTitle>
|
status="error"
|
||||||
Condition reported as <strong>{matchedPkg.condition}</strong>. Set it aside in the Exception Area for a supervisor.
|
title="Something's Wrong"
|
||||||
</Alert>
|
description={`Condition reported as ${matchedPkg.condition}. Set it aside in the Exception Area for a supervisor.`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{matchedPkg.iscoldchain && (
|
{matchedPkg.iscoldchain && (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #b2ebf2', bgcolor: '#e0f7fa', mb: 2 }}>
|
<Banner
|
||||||
<AlertTitle sx={{ fontWeight: 700, color: '#00838F' }}>Cold chain parcel</AlertTitle>
|
status="info"
|
||||||
Move this parcel to the <strong>Cold Room (Zone C)</strong> right away.
|
title="Cold chain parcel"
|
||||||
</Alert>
|
description="Move this parcel to the Cold Room (Zone C) right away."
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{determineNextAction(matchedPkg).queue === 'Transfer to Another City' && (
|
{determineNextAction(matchedPkg).queue === 'Transfer to Another City' && (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2, border: '1px solid #bae1ff', bgcolor: '#e6f2ff' }}>
|
<Banner
|
||||||
<AlertTitle sx={{ fontWeight: 700, color: '#0055b3' }}>Put in the transfer bin</AlertTitle>
|
status="info"
|
||||||
<strong>{matchedPkg.nexthop}</strong>. It will go out with the next city transfer.
|
title="Put in the transfer bin"
|
||||||
</Alert>
|
description={`${matchedPkg.nexthop}. It will go out with the next city transfer.`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{determineNextAction(matchedPkg).queue === 'Local Delivery' && (
|
{determineNextAction(matchedPkg).queue === 'Local Delivery' && (
|
||||||
<Alert severity="success" sx={{ borderRadius: 2, border: '1px solid #c3e6cb', bgcolor: '#d4edda' }}>
|
<Banner
|
||||||
<AlertTitle sx={{ fontWeight: 700, color: '#155724' }}>Ready for local delivery</AlertTitle>
|
status="success"
|
||||||
Place this parcel in the <strong>{matchedPkg.destination}</strong> lane so a miler can take it out.
|
title="Ready for local delivery"
|
||||||
</Alert>
|
description={`Place this parcel in the ${matchedPkg.destination} lane so a miler can take it out.`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</VStack>
|
||||||
</CardContent>
|
</Panel>
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card sx={{ height: '100%', borderRadius: 2 }}>
|
<Card style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 24px' }}>
|
||||||
<CardContent sx={{ py: { xs: 8, sm: 15 }, textAlign: 'center' }}>
|
<Banner
|
||||||
<Alert severity="error" sx={{ justifyContent: 'center', borderRadius: 2 }}>
|
status="error"
|
||||||
<AlertTitle sx={{ fontWeight: 700 }}>Package Not Found</AlertTitle>
|
title="Package Not Found"
|
||||||
The package code <strong>{searchId}</strong> is not registered in the system.
|
description={`The package code ${searchId} is not registered in the system.`}
|
||||||
</Alert>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import { Bike, Truck, MapPin } from 'lucide-react';
|
||||||
Box, Typography, Card, CardHeader, Avatar, Stack, Chip, List, ListItem,
|
|
||||||
ListItemAvatar, ListItemText, Badge, Divider, Alert
|
|
||||||
} from '@mui/material';
|
|
||||||
import DeliveryDiningIcon from '@mui/icons-material/DeliveryDining';
|
|
||||||
import HubIcon from '@mui/icons-material/Hub';
|
|
||||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Heading, Text } from '@astryxdesign/core/Text';
|
||||||
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Avatar } from '@astryxdesign/core/Avatar';
|
||||||
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { StatusDot } from '@astryxdesign/core/StatusDot';
|
||||||
|
import { Collapsible } from '@astryxdesign/core/Collapsible';
|
||||||
|
|
||||||
import { getMilers, getMilerLocations, getHubs, getTripsheetsInTransit } from '@/api/hub';
|
import { getMilers, getMilerLocations, getHubs, getTripsheetsInTransit } from '@/api/hub';
|
||||||
import { getHubContext } from '@/auth/session';
|
import { getHubContext } from '@/auth/session';
|
||||||
|
|
||||||
// Keeps Leaflet's canvas sized correctly when the container resizes (sidebar
|
function useMediaQuery(query) {
|
||||||
// toggle, window resize, first paint inside a flex box). Without this the map
|
const [matches, setMatches] = useState(window.matchMedia(query).matches);
|
||||||
// renders grey/blank tiles — the #1 reason a real map "doesn't show".
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
if (media.matches !== matches) {
|
||||||
|
setMatches(media.matches);
|
||||||
|
}
|
||||||
|
const listener = () => setMatches(media.matches);
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, [matches, query]);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
function MapResizeHandler() {
|
function MapResizeHandler() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,7 +43,6 @@ function MapResizeHandler() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix leaflet default marker icons issue
|
|
||||||
delete L.Icon.Default.prototype._getIconUrl;
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
@@ -37,24 +50,23 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom HTML Icons for Leaflet
|
|
||||||
const createHubIcon = () => new L.DivIcon({
|
const createHubIcon = () => new L.DivIcon({
|
||||||
className: 'custom-leaflet-icon',
|
className: 'custom-leaflet-icon',
|
||||||
html: `<div style="background-color: #C01227; width: 36px; height: 36px; border-radius: 50%; border: 3px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="20" height="20" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></div>`,
|
html: `<div style="background-color: #ef4444; width: 36px; height: 36px; border-radius: 50%; border: 3px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="20" height="20" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>`,
|
||||||
iconSize: [36, 36],
|
iconSize: [36, 36],
|
||||||
iconAnchor: [18, 18],
|
iconAnchor: [18, 18],
|
||||||
});
|
});
|
||||||
|
|
||||||
const createRiderIcon = () => new L.DivIcon({
|
const createRiderIcon = () => new L.DivIcon({
|
||||||
className: 'custom-leaflet-icon',
|
className: 'custom-leaflet-icon',
|
||||||
html: `<div style="background-color: #0070f3; width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 10px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="16" height="16" viewBox="0 0 24 24"><path d="M19 7c0-1.1-.9-2-2-2h-3v2h3v2.65L13.52 14H10V9H6c-2.21 0-4 1.79-4 4v3h2c0 1.66 1.34 3 3 3s3-1.34 3-3h4.48L19 10.35V7zM7 17c-.55 0-1-.45-1-1h2c0 .55-.45 1-1 1z"/></svg></div>`,
|
html: `<div style="background-color: #3b82f6; width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 10px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center;"><svg fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" viewBox="0 0 24 24"><circle cx="5.5" cy="17.5" r="3.5"/><circle cx="18.5" cy="17.5" r="3.5"/><path d="M15 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-3 11.5V14l-3-3 4-3 2 3h2"/></svg></div>`,
|
||||||
iconSize: [30, 30],
|
iconSize: [30, 30],
|
||||||
iconAnchor: [15, 15],
|
iconAnchor: [15, 15],
|
||||||
});
|
});
|
||||||
|
|
||||||
const createLinehaulIcon = () => new L.DivIcon({
|
const createLinehaulIcon = () => new L.DivIcon({
|
||||||
className: 'custom-leaflet-icon',
|
className: 'custom-leaflet-icon',
|
||||||
html: `<div style="background-color: #ff9900; width: 34px; height: 34px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.18); display: flex; align-items: center; justify-content: center;"><svg fill="#ffffff" width="18" height="18" viewBox="0 0 24 24"><path d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg></div>`,
|
html: `<div style="background-color: #f59e0b; width: 34px; height: 34px; border-radius: 50%; border: 2px solid #ffffff; box-shadow: 0px 4px 12px rgba(0,0,0,0.18); display: flex; align-items: center; justify-content: center;"><svg fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" viewBox="0 0 24 24"><path d="M5 18H3c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1h10c.6 0 1 .4 1 1v11"/><path d="M14 9h4l4 4v5h-3"/><circle cx="7" cy="18" r="2"/><circle cx="17" cy="18" r="2"/></svg></div>`,
|
||||||
iconSize: [34, 34],
|
iconSize: [34, 34],
|
||||||
iconAnchor: [17, 17],
|
iconAnchor: [17, 17],
|
||||||
});
|
});
|
||||||
@@ -62,26 +74,26 @@ const createLinehaulIcon = () => new L.DivIcon({
|
|||||||
const prettyHubType = (t) =>
|
const prettyHubType = (t) =>
|
||||||
({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub');
|
({ sorting_center: 'Sorting Center', delivery_hub: 'Delivery Hub', spoke: 'Spoke', warehouse: 'Warehouse' }[t] || t || 'Hub');
|
||||||
|
|
||||||
// Colour-code a miler pin/list item by their live availability status.
|
|
||||||
const statusMeta = (status) => {
|
const statusMeta = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Assigned':
|
case 'Assigned':
|
||||||
case 'On Pickup':
|
case 'On Pickup':
|
||||||
return { color: '#1A73E8', label: status };
|
return { color: '#3b82f6', bg: '#eff6ff', label: status, variant: 'info' };
|
||||||
case 'Available':
|
case 'Available':
|
||||||
case 'Idle':
|
case 'Idle':
|
||||||
return { color: '#1E8E3E', label: status };
|
return { color: '#10b981', bg: '#ecfdf5', label: status, variant: 'success' };
|
||||||
case 'On_Break':
|
case 'On_Break':
|
||||||
case 'On Break':
|
case 'On Break':
|
||||||
return { color: '#8E24AA', label: 'On Break' };
|
return { color: '#a855f7', bg: '#faf5ff', label: 'On Break', variant: 'warning' };
|
||||||
case 'Offline':
|
case 'Offline':
|
||||||
return { color: '#80868B', label: 'Offline' };
|
return { color: '#64748b', bg: '#f8fafc', label: 'Offline', variant: 'neutral' };
|
||||||
default:
|
default:
|
||||||
return { color: '#0070f3', label: status || 'Active' };
|
return { color: '#3b82f6', bg: '#eff6ff', label: status || 'Active', variant: 'info' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TrackingMap() {
|
export default function TrackingMap() {
|
||||||
|
const isMdDown = useMediaQuery('(max-width: 900px)');
|
||||||
const hub = getHubContext();
|
const hub = getHubContext();
|
||||||
const [milers, setMilers] = useState([]);
|
const [milers, setMilers] = useState([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -89,7 +101,6 @@ export default function TrackingMap() {
|
|||||||
const [linehauls, setLinehauls] = useState([]);
|
const [linehauls, setLinehauls] = useState([]);
|
||||||
const didLoad = useRef(false);
|
const didLoad = useRef(false);
|
||||||
|
|
||||||
// Hub pins are static per session — load once from /hub/hubs (has lat/lon).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getHubs()
|
getHubs()
|
||||||
.then((res) =>
|
.then((res) =>
|
||||||
@@ -102,10 +113,6 @@ export default function TrackingMap() {
|
|||||||
.catch(() => setHubs([]));
|
.catch(() => setHubs([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll live miler GPS every 5 seconds. The guide's /admin/milers/locations
|
|
||||||
// (Redis GEO) returns nothing on this backend, so we read coordinates from
|
|
||||||
// /hub/milers (currentlatitude/currentlongitude) and fall back to the GEO
|
|
||||||
// endpoint if it ever starts returning data.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const normalize = (list) =>
|
const normalize = (list) =>
|
||||||
@@ -144,7 +151,6 @@ export default function TrackingMap() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll real transfer trucks in transit every 5 seconds.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@@ -164,8 +170,8 @@ export default function TrackingMap() {
|
|||||||
current: [t.currentlat, t.currentlon]
|
current: [t.currentlat, t.currentlon]
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* leave last-known trucks on transient failure */
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
poll();
|
poll();
|
||||||
@@ -176,7 +182,6 @@ export default function TrackingMap() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Centre on the milers when we have them, otherwise a sensible national view.
|
|
||||||
const mapCenter =
|
const mapCenter =
|
||||||
milers.length > 0
|
milers.length > 0
|
||||||
? [
|
? [
|
||||||
@@ -186,164 +191,169 @@ export default function TrackingMap() {
|
|||||||
: [22.0, 76.0];
|
: [22.0, 76.0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<div style={{ paddingBottom: '32px' }}>
|
||||||
<Box sx={{ mb: 4 }}>
|
<div style={{ marginBottom: '32px' }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#111' }}>Live Map</Typography>
|
<Heading level={2} style={{ color: '#0f172a', marginBottom: '8px' }}>Live Map</Heading>
|
||||||
<Typography variant="body1" color="text.secondary">See where your milers and transfer trucks are right now, on the map.</Typography>
|
<Text type="body" color="secondary">See where your milers and transfer trucks are right now, on the map.</Text>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" onClose={() => setError('')} sx={{ mb: 3, borderRadius: 2 }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
{error}
|
<Banner status="error" title="Error" description={error} />
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3.5 }}>
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '24px', flexWrap: 'wrap' }}>
|
||||||
|
|
||||||
{/* Map Canvas Frame */}
|
{/* Map Canvas Frame */}
|
||||||
<Box sx={{ flex: 2, minWidth: 0 }}>
|
<div style={{ flex: '2 1 0%', minWidth: '300px' }}>
|
||||||
<Card sx={{ height: { xs: 380, sm: 480, lg: 620 }, width: '100%', position: 'relative', overflow: 'hidden', border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 24px rgba(0,0,0,0.02)' }}>
|
<Card style={{ height: isMdDown ? '400px' : '700px', width: '100%', position: 'relative', overflow: 'hidden', padding: 0, borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
|
||||||
<MapContainer center={mapCenter} zoom={6} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
<MapContainer center={mapCenter} zoom={6} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
||||||
<MapResizeHandler />
|
<MapResizeHandler />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plot Hubs */}
|
|
||||||
{hubs.map((h) => (
|
{hubs.map((h) => (
|
||||||
<Marker key={h.id} position={h.position} icon={createHubIcon()}>
|
<Marker key={h.id} position={h.position} icon={createHubIcon()}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{h.name}</Typography>
|
<div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{h.name}</div>
|
||||||
<Typography variant="caption">{h.type}</Typography>
|
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{h.type}</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Plot live Milers */}
|
|
||||||
{milers.map((m) => (
|
{milers.map((m) => (
|
||||||
<Marker key={m.userid} position={[m.lat, m.lon]} icon={createRiderIcon()}>
|
<Marker key={m.userid} position={[m.lat, m.lon]} icon={createRiderIcon()}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{m.displayname || `Miler ${m.userid}`}</Typography>
|
<div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{m.displayname || `Miler ${m.userid}`}</div>
|
||||||
<Typography variant="caption" display="block">{statusMeta(m.status).label}</Typography>
|
<div style={{ fontSize: '0.75rem', color: statusMeta(m.status).color }}>{statusMeta(m.status).label}</div>
|
||||||
{m.bookingid && <Typography variant="caption" display="block">On booking #{m.bookingid}</Typography>}
|
{m.bookingid && <div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '4px' }}>On booking #{m.bookingid}</div>}
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Plot transfer trucks in transit (real positions) */}
|
|
||||||
{linehauls.map((lh) => (
|
{linehauls.map((lh) => (
|
||||||
<React.Fragment key={lh.id}>
|
<React.Fragment key={lh.id}>
|
||||||
<Polyline positions={[lh.start, lh.end]} color="#C01227" dashArray="10, 10" weight={3} opacity={0.5} />
|
<Polyline positions={[lh.start, lh.end]} color="#ef4444" dashArray="10, 10" weight={3} opacity={0.5} />
|
||||||
<Marker position={lh.current} icon={createLinehaulIcon()}>
|
<Marker position={lh.current} icon={createLinehaulIcon()}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{lh.name}</Typography>
|
<div style={{ fontWeight: 700, fontSize: '0.875rem' }}>{lh.name}</div>
|
||||||
<Typography variant="caption">Progress: {Math.round(lh.progress * 100)}%</Typography>
|
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>Progress: {Math.round(lh.progress * 100)}%</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Status Trackers */}
|
{/* Sidebar Status Trackers */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: '1 1 0%', minWidth: '300px', display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
<Stack spacing={3}>
|
|
||||||
|
<Card style={{ padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
|
||||||
{/* Active Network Hub Nodes Summary */}
|
<Collapsible
|
||||||
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
|
defaultIsOpen={true}
|
||||||
<CardHeader
|
style={{ paddingRight: '20px' }}
|
||||||
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Active Hub Nodes</Typography>}
|
trigger={
|
||||||
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><HubIcon fontSize="small" /></Avatar>}
|
<div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
/>
|
<div style={{ background: '#fef2f2', color: '#ef4444', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Divider />
|
<MapPin size={18} />
|
||||||
<List disablePadding>
|
</div>
|
||||||
|
<Heading level={5} style={{ margin: 0 }}>Active Hub Nodes</Heading>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
|
||||||
{hubs.length === 0 && (
|
{hubs.length === 0 && (
|
||||||
<ListItem sx={{ px: 3, py: 1.5 }}>
|
<EmptyState title="No hubs" description="No hubs to show" isCompact />
|
||||||
<ListItemText primary="No hubs to show" primaryTypographyProps={{ color: 'text.secondary', fontSize: '0.85rem' }} />
|
|
||||||
</ListItem>
|
|
||||||
)}
|
)}
|
||||||
{hubs.map((h) => (
|
{hubs.map((h) => (
|
||||||
<ListItem key={h.id} sx={{ px: 3, py: 1.5, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
<div key={h.id} style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #f1f5f9' }}>
|
||||||
<ListItemText
|
<div>
|
||||||
primary={h.name}
|
<div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{h.name}</div>
|
||||||
secondary={h.type}
|
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{h.type}</div>
|
||||||
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.9rem' }}
|
</div>
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem' }}
|
<Badge variant="success" label="Online" />
|
||||||
/>
|
</div>
|
||||||
<Chip size="small" label="Online" sx={{ bgcolor: '#00A85410', color: '#00A854', fontWeight: 600, fontSize: '0.75rem' }} />
|
|
||||||
</ListItem>
|
|
||||||
))}
|
))}
|
||||||
</List>
|
</div>
|
||||||
</Card>
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Real-time Last Mile Miler Logs */}
|
<Card style={{ padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
|
||||||
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 2, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
|
<Collapsible
|
||||||
<CardHeader
|
defaultIsOpen={true}
|
||||||
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Milers Out Now{hub.city ? ` (${hub.city})` : ''}</Typography>}
|
style={{ paddingRight: '20px' }}
|
||||||
avatar={<Avatar sx={{ bgcolor: '#0070f310', color: '#0070f3', borderRadius: 2 }}><DeliveryDiningIcon fontSize="small" /></Avatar>}
|
trigger={
|
||||||
/>
|
<div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<Divider />
|
<div style={{ background: '#eff6ff', color: '#3b82f6', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
{milers.length === 0 ? (
|
<Bike size={18} />
|
||||||
<Box sx={{ px: 3, py: 3 }}>
|
</div>
|
||||||
<Typography variant="body2" color="text.secondary">No milers reporting a location right now.</Typography>
|
<Heading level={5} style={{ margin: 0 }}>Milers Out Now {hub.city ? `(${hub.city})` : ''}</Heading>
|
||||||
</Box>
|
</div>
|
||||||
) : (
|
}
|
||||||
<List disablePadding>
|
>
|
||||||
{milers.map((m) => {
|
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
|
||||||
|
{milers.length === 0 ? (
|
||||||
|
<EmptyState title="No active milers" description="No milers reporting a location right now." isCompact />
|
||||||
|
) : (
|
||||||
|
milers.map((m) => {
|
||||||
const meta = statusMeta(m.status);
|
const meta = statusMeta(m.status);
|
||||||
const name = m.displayname || `Miler ${m.userid}`;
|
const name = m.displayname || `Miler ${m.userid}`;
|
||||||
return (
|
return (
|
||||||
<ListItem key={m.userid} sx={{ px: 3, py: 1.75, borderBottom: '1px solid #f4f4f4', '&:last-child': { border: 0 } }}>
|
<div key={m.userid} style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: '12px', borderBottom: '1px solid #f1f5f9' }}>
|
||||||
<ListItemAvatar>
|
<Avatar
|
||||||
<Badge color="success" variant="dot" overlap="circular" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
name={name}
|
||||||
<Avatar sx={{ width: 34, height: 34, bgcolor: meta.color, fontWeight: 700, fontSize: 13 }}>{name.charAt(0)}</Avatar>
|
status={<StatusDot variant={m.status === 'Offline' ? 'error' : 'success'} label={m.status === 'Offline' ? 'Offline' : 'Online'} />}
|
||||||
</Badge>
|
size="md"
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={name}
|
|
||||||
secondary={m.bookingid ? `On booking #${m.bookingid}` : 'No active booking'}
|
|
||||||
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
|
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem', noWrap: true }}
|
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" sx={{ color: meta.color, fontWeight: 700, ml: 1, bgcolor: `${meta.color}12`, px: 1, py: 0.5, borderRadius: 2, whiteSpace: 'nowrap' }}>
|
<div style={{ flex: 1 }}>
|
||||||
{meta.label}
|
<div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{name}</div>
|
||||||
</Typography>
|
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{m.bookingid ? `On booking #${m.bookingid}` : 'No active booking'}</div>
|
||||||
</ListItem>
|
</div>
|
||||||
|
{m.status !== 'Offline' && <Badge variant={meta.variant} label={meta.label} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</List>
|
)}
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Linehaul Fleet Shipments Tracker */}
|
<Card style={{ padding: '0', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 2px 14px rgba(0,0,0,0.02)' }}>
|
||||||
<Card sx={{ border: '1px solid #eaeaea', borderRadius: 3, boxShadow: '0px 4px 20px rgba(0,0,0,0.01)' }}>
|
<Collapsible
|
||||||
<CardHeader
|
defaultIsOpen={false}
|
||||||
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>City Transfers</Typography>}
|
style={{ paddingRight: '20px' }}
|
||||||
avatar={<Avatar sx={{ bgcolor: '#ff990010', color: '#ff9900', borderRadius: 2 }}><LocalShippingIcon fontSize="small" /></Avatar>}
|
trigger={
|
||||||
/>
|
<div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<Divider />
|
<div style={{ background: '#fffbeb', color: '#f59e0b', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<List disablePadding>
|
<Truck size={18} />
|
||||||
|
</div>
|
||||||
|
<Heading level={5} style={{ margin: 0 }}>City Transfers</Heading>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
|
||||||
|
{linehauls.length === 0 && (
|
||||||
|
<EmptyState title="No transfers" description="No trucks in transit." isCompact />
|
||||||
|
)}
|
||||||
{linehauls.map(lh => (
|
{linehauls.map(lh => (
|
||||||
<ListItem key={lh.id} sx={{ px: 3, py: 2 }}>
|
<div key={lh.id} style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #f1f5f9' }}>
|
||||||
<ListItemText
|
<div>
|
||||||
primary={lh.name}
|
<div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{lh.name}</div>
|
||||||
secondary={`Status: ${lh.status}`}
|
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>Status: {lh.status}</div>
|
||||||
primaryTypographyProps={{ fontWeight: 600, color: '#222', fontSize: '0.875rem' }}
|
</div>
|
||||||
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.25 }}
|
<Badge variant="warning" label={`${Math.round(lh.progress * 100)}% route`} />
|
||||||
/>
|
</div>
|
||||||
<Typography variant="caption" sx={{ color: '#e08500', fontWeight: 700, bgcolor: '#ff990008', px: 1, py: 0.5, borderRadius: 2 }}>
|
|
||||||
{Math.round(lh.progress * 100)}% route
|
|
||||||
</Typography>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
))}
|
||||||
</List>
|
</div>
|
||||||
</Card>
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import customShadows from './shadows';
|
|
||||||
|
|
||||||
// ==============================|| DOORMILE THEME - COMPONENT OVERRIDES ||============================== //
|
|
||||||
// Clean, corporate Material Design tuning for the whole console.
|
|
||||||
|
|
||||||
export default function componentsOverride(theme) {
|
|
||||||
const { palette } = theme;
|
|
||||||
|
|
||||||
return {
|
|
||||||
MuiCssBaseline: {
|
|
||||||
styleOverrides: {
|
|
||||||
html: { WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' },
|
|
||||||
body: { backgroundColor: palette.background.default },
|
|
||||||
'*::-webkit-scrollbar': { width: 8, height: 8 },
|
|
||||||
'*::-webkit-scrollbar-thumb': { background: palette.grey[300], borderRadius: 8 },
|
|
||||||
'*::-webkit-scrollbar-thumb:hover': { background: palette.grey[400] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiButton: {
|
|
||||||
defaultProps: { disableElevation: true, disableRipple: false },
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 8,
|
|
||||||
fontWeight: 600,
|
|
||||||
padding: '8px 18px',
|
|
||||||
transition: 'background-color .2s ease, box-shadow .2s ease, transform .12s ease, border-color .2s ease',
|
|
||||||
'&:active': { transform: 'translateY(0.5px)' },
|
|
||||||
'&.Mui-focusVisible': { boxShadow: `0 0 0 3px ${palette.primary.lighter}` }
|
|
||||||
},
|
|
||||||
containedPrimary: {
|
|
||||||
boxShadow: customShadows.primaryGlow,
|
|
||||||
'&:hover': { boxShadow: customShadows.primaryGlowHover, backgroundColor: palette.primary.dark }
|
|
||||||
},
|
|
||||||
outlined: {
|
|
||||||
borderColor: palette.grey[300],
|
|
||||||
'&:hover': { borderColor: palette.grey[400], backgroundColor: palette.grey[50] }
|
|
||||||
},
|
|
||||||
text: { '&:hover': { backgroundColor: palette.grey[100] } },
|
|
||||||
sizeLarge: { padding: '11px 24px', fontSize: '0.9375rem' },
|
|
||||||
sizeSmall: { padding: '5px 14px' },
|
|
||||||
// Give every button clear breathing room between its icon and label
|
|
||||||
startIcon: { marginRight: 10 },
|
|
||||||
endIcon: { marginLeft: 10 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiIconButton: {
|
|
||||||
styleOverrides: { root: { borderRadius: 8 } }
|
|
||||||
},
|
|
||||||
MuiCard: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 14,
|
|
||||||
border: `1px solid ${palette.grey[200]}`,
|
|
||||||
boxShadow: customShadows.card,
|
|
||||||
backgroundImage: 'none',
|
|
||||||
transition: 'box-shadow .22s ease, border-color .22s ease, transform .22s ease'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiCardHeader: {
|
|
||||||
defaultProps: { titleTypographyProps: { variant: 'h5' }, subheaderTypographyProps: { variant: 'caption' } },
|
|
||||||
styleOverrides: { root: { padding: 20 } }
|
|
||||||
},
|
|
||||||
MuiCardContent: {
|
|
||||||
styleOverrides: { root: { padding: 20, '&:last-child': { paddingBottom: 20 } } }
|
|
||||||
},
|
|
||||||
MuiPaper: {
|
|
||||||
defaultProps: { elevation: 0 },
|
|
||||||
styleOverrides: { rounded: { borderRadius: 14 } }
|
|
||||||
},
|
|
||||||
MuiToggleButtonGroup: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: { backgroundColor: palette.grey[100], borderRadius: 10, padding: 4, gap: 4 },
|
|
||||||
grouped: {
|
|
||||||
border: 0,
|
|
||||||
borderRadius: '8px !important',
|
|
||||||
margin: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiToggleButton: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
border: 0,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '6px 12px',
|
|
||||||
color: palette.grey[600],
|
|
||||||
transition: 'all .18s ease',
|
|
||||||
'&:hover': { backgroundColor: palette.grey[200] },
|
|
||||||
'&.Mui-selected': {
|
|
||||||
backgroundColor: palette.background.paper,
|
|
||||||
color: palette.primary.main,
|
|
||||||
boxShadow: customShadows.card,
|
|
||||||
'&:hover': { backgroundColor: palette.background.paper }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiChip: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: { borderRadius: 6, fontWeight: 600, fontSize: '0.75rem' },
|
|
||||||
sizeSmall: { height: 22 },
|
|
||||||
label: { paddingLeft: 8, paddingRight: 8 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiTableCell: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: { borderColor: palette.grey[200], padding: '12px 16px', fontSize: '0.8125rem' },
|
|
||||||
head: {
|
|
||||||
fontWeight: 600,
|
|
||||||
color: palette.grey[600],
|
|
||||||
backgroundColor: palette.grey[50],
|
|
||||||
textTransform: 'none',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiTableRow: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: { transition: 'background-color .15s ease', '&:hover': { backgroundColor: palette.grey[50] } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiOutlinedInput: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: palette.background.paper,
|
|
||||||
transition: 'box-shadow .2s ease, border-color .2s ease',
|
|
||||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[300], transition: 'border-color .2s ease' },
|
|
||||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[400] },
|
|
||||||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${palette.primary.lighter}` },
|
|
||||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: palette.primary.main, borderWidth: 1 }
|
|
||||||
},
|
|
||||||
input: { padding: '11px 14px' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiSelect: {
|
|
||||||
styleOverrides: {
|
|
||||||
select: {
|
|
||||||
paddingTop: '11px',
|
|
||||||
paddingBottom: '11px',
|
|
||||||
paddingLeft: '14px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiInputLabel: {
|
|
||||||
styleOverrides: { root: { color: palette.grey[600], fontSize: '0.875rem' } }
|
|
||||||
},
|
|
||||||
MuiTab: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: { textTransform: 'none', fontWeight: 600, minHeight: 46, fontSize: '0.875rem' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiTabs: {
|
|
||||||
styleOverrides: { indicator: { height: 3, borderRadius: 3 } }
|
|
||||||
},
|
|
||||||
MuiTooltip: {
|
|
||||||
styleOverrides: {
|
|
||||||
tooltip: { backgroundColor: palette.grey[800], borderRadius: 6, fontSize: '0.75rem', padding: '6px 10px' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MuiDialog: {
|
|
||||||
styleOverrides: { paper: { borderRadius: 16 } }
|
|
||||||
},
|
|
||||||
MuiAvatar: {
|
|
||||||
styleOverrides: { root: { fontWeight: 600, fontSize: '0.875rem' } }
|
|
||||||
},
|
|
||||||
MuiListItemButton: {
|
|
||||||
styleOverrides: { root: { borderRadius: 8 } }
|
|
||||||
},
|
|
||||||
MuiLinearProgress: {
|
|
||||||
styleOverrides: { root: { borderRadius: 8, height: 6, backgroundColor: palette.grey[200] } }
|
|
||||||
},
|
|
||||||
MuiMenu: {
|
|
||||||
styleOverrides: { paper: { borderRadius: 12, boxShadow: customShadows.dropdown, marginTop: 6, border: `1px solid ${palette.grey[200]}` } }
|
|
||||||
},
|
|
||||||
MuiMenuItem: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 8,
|
|
||||||
margin: '2px 6px',
|
|
||||||
padding: '8px 10px',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
'&:hover': { backgroundColor: palette.grey[100] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
import palette from './palette';
|
|
||||||
import typography from './typography';
|
|
||||||
import customShadows from './shadows';
|
|
||||||
import componentsOverride from './componentsOverride';
|
|
||||||
|
|
||||||
// ==============================|| DOORMILE THEME - ENTRY ||============================== //
|
|
||||||
|
|
||||||
let theme = createTheme({
|
|
||||||
palette,
|
|
||||||
typography,
|
|
||||||
shape: { borderRadius: 6 },
|
|
||||||
customShadows,
|
|
||||||
mixins: { toolbar: { minHeight: 64 } }
|
|
||||||
});
|
|
||||||
|
|
||||||
theme.components = componentsOverride(theme);
|
|
||||||
|
|
||||||
export default theme;
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
// ==============================|| DOORMILE THEME - PALETTE ||============================== //
|
|
||||||
// Corporate red brand palette. Brand red #C01227.
|
|
||||||
|
|
||||||
export const grey = {
|
|
||||||
0: '#FFFFFF',
|
|
||||||
50: '#F8F9FA',
|
|
||||||
100: '#F1F3F5',
|
|
||||||
200: '#E9ECEF',
|
|
||||||
300: '#DEE2E6',
|
|
||||||
400: '#CED4DA',
|
|
||||||
500: '#ADB5BD',
|
|
||||||
600: '#868E96',
|
|
||||||
700: '#495057',
|
|
||||||
800: '#343A40', // Slate
|
|
||||||
900: '#212529', // Graphite
|
|
||||||
A50: '#F8F9FA',
|
|
||||||
A100: '#E9ECEF'
|
|
||||||
};
|
|
||||||
|
|
||||||
const palette = {
|
|
||||||
mode: 'light',
|
|
||||||
common: { black: '#000000', white: '#FFFFFF' },
|
|
||||||
primary: {
|
|
||||||
lighter: '#F8E0E3',
|
|
||||||
100: '#EFBBC1',
|
|
||||||
200: '#E08A92',
|
|
||||||
light: '#D6515C',
|
|
||||||
400: '#CC2E3C',
|
|
||||||
main: '#C01227',
|
|
||||||
dark: '#9E0E20',
|
|
||||||
700: '#870C1B',
|
|
||||||
darker: '#7E0B17',
|
|
||||||
900: '#520710',
|
|
||||||
contrastText: '#FFFFFF'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
lighter: grey[100],
|
|
||||||
100: grey[100],
|
|
||||||
200: grey[200],
|
|
||||||
light: grey[300],
|
|
||||||
400: grey[400],
|
|
||||||
main: grey[500],
|
|
||||||
600: grey[600],
|
|
||||||
dark: grey[700],
|
|
||||||
800: grey[800],
|
|
||||||
darker: grey[900],
|
|
||||||
A100: grey[0],
|
|
||||||
A200: grey[400],
|
|
||||||
A300: grey[700],
|
|
||||||
contrastText: grey[0]
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
lighter: '#FEEAE9',
|
|
||||||
light: '#F88078',
|
|
||||||
main: '#F04134',
|
|
||||||
dark: '#A82216',
|
|
||||||
darker: '#7A150C',
|
|
||||||
contrastText: '#FFFFFF'
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
lighter: '#FFF7E0',
|
|
||||||
light: '#FFD666',
|
|
||||||
main: '#FFBF00',
|
|
||||||
dark: '#B38600',
|
|
||||||
darker: '#805F00',
|
|
||||||
contrastText: '#262626'
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
lighter: '#E0F7F8',
|
|
||||||
light: '#66CBD2',
|
|
||||||
main: '#00A2AE',
|
|
||||||
dark: '#00727B',
|
|
||||||
darker: '#005159',
|
|
||||||
contrastText: '#FFFFFF'
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
lighter: '#E3F6EC',
|
|
||||||
light: '#5CC98C',
|
|
||||||
main: '#00A854',
|
|
||||||
dark: '#00773B',
|
|
||||||
darker: '#00552A',
|
|
||||||
contrastText: '#FFFFFF'
|
|
||||||
},
|
|
||||||
grey,
|
|
||||||
text: {
|
|
||||||
primary: grey[800],
|
|
||||||
secondary: grey[600],
|
|
||||||
disabled: grey[400]
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
disabled: grey[300],
|
|
||||||
hover: 'rgba(192, 18, 39, 0.04)',
|
|
||||||
selected: 'rgba(192, 18, 39, 0.08)'
|
|
||||||
},
|
|
||||||
divider: grey[200],
|
|
||||||
background: {
|
|
||||||
paper: '#FFFFFF',
|
|
||||||
default: grey.A50
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default palette;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// ==============================|| DOORMILE THEME - CUSTOM SHADOWS ||============================== //
|
|
||||||
// Layered elevation (a tight contact shadow + a soft ambient shadow) reads far more
|
|
||||||
// premium than a single blurry drop shadow. Brand glow on CTAs is kept restrained.
|
|
||||||
|
|
||||||
const customShadows = {
|
|
||||||
card: '0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06)',
|
|
||||||
cardHover: '0 10px 28px -6px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.05)',
|
|
||||||
widget: '0 1px 3px rgba(16, 24, 40, 0.06)',
|
|
||||||
dropdown: '0 12px 32px -8px rgba(16, 24, 40, 0.18), 0 4px 10px rgba(16, 24, 40, 0.06)',
|
|
||||||
primaryGlow: '0 4px 12px -2px rgba(192, 18, 39, 0.22)',
|
|
||||||
primaryGlowHover: '0 6px 16px -2px rgba(192, 18, 39, 0.30)',
|
|
||||||
header: '0 1px 0 rgba(16, 24, 40, 0.06)'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default customShadows;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// ==============================|| DOORMILE THEME - TYPOGRAPHY ||============================== //
|
|
||||||
// A deliberate type scale with optical tracking: large display text gets tighter
|
|
||||||
// negative letter-spacing, small UI text stays legible. Optical tracking like this
|
|
||||||
// is what separates a designed interface from a default Material build.
|
|
||||||
|
|
||||||
const typography = {
|
|
||||||
fontFamily: '"Public Sans", "Inter", "Helvetica", "Arial", sans-serif',
|
|
||||||
htmlFontSize: 16,
|
|
||||||
fontWeightLight: 300,
|
|
||||||
fontWeightRegular: 400,
|
|
||||||
fontWeightMedium: 500,
|
|
||||||
fontWeightBold: 700,
|
|
||||||
h1: { fontWeight: 800, fontSize: '2.375rem', lineHeight: 1.15, letterSpacing: '-0.022em' },
|
|
||||||
h2: { fontWeight: 800, fontSize: '1.875rem', lineHeight: 1.2, letterSpacing: '-0.02em' },
|
|
||||||
h3: { fontWeight: 700, fontSize: '1.5rem', lineHeight: 1.28, letterSpacing: '-0.018em' },
|
|
||||||
h4: { fontWeight: 700, fontSize: '1.25rem', lineHeight: 1.35, letterSpacing: '-0.014em' },
|
|
||||||
h5: { fontWeight: 700, fontSize: '1rem', lineHeight: 1.5, letterSpacing: '-0.01em' },
|
|
||||||
h6: { fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.57, letterSpacing: '-0.006em' },
|
|
||||||
caption: { fontWeight: 400, fontSize: '0.75rem', lineHeight: 1.66 },
|
|
||||||
body1: { fontSize: '0.875rem', lineHeight: 1.6, letterSpacing: '-0.003em' },
|
|
||||||
body2: { fontSize: '0.75rem', lineHeight: 1.66 },
|
|
||||||
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.57, letterSpacing: '-0.006em' },
|
|
||||||
subtitle2: { fontSize: '0.75rem', fontWeight: 600, lineHeight: 1.66 },
|
|
||||||
overline: { fontSize: '0.6875rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' },
|
|
||||||
button: { textTransform: 'none', fontWeight: 600, letterSpacing: '-0.006em' }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default typography;
|
|
||||||
Reference in New Issue
Block a user