changes regarding the mui to astryx tempalate based design

This commit is contained in:
2026-07-23 18:35:49 +05:30
parent ef0b14d254
commit 428e336553
33 changed files with 4679 additions and 5875 deletions

1508
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -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
View 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} />;
}

View File

@@ -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'}
</Typography> </div>
</Box> </div>
<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 })} />
<Divider /> <div style={{ borderTop: '1px solid #EEF0F2', padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 2, py: 1.25 }}> <button onClick={() => applyPreset(1)} style={{ background: 'none', border: 'none', color: '#5F6368', fontWeight: 700, cursor: 'pointer', fontSize: '0.875rem' }}>
<Button size="small" onClick={() => applyPreset(1)} sx={{ textTransform: 'none', color: '#5F6368', fontWeight: 700 }}>
Reset to today Reset to today
</Button> </button>
<Button size="small" variant="contained" onClick={() => setAnchor(null)} <Button variant="primary" onClick={() => setOpen(false)}>
sx={{ textTransform: 'none', fontWeight: 700, bgcolor: BRAND, borderRadius: 2, '&:hover': { bgcolor: '#9E0E20' } }}>
Done Done
</Button> </Button>
</Stack> </div>
</Popover> </div>
</> </>
)}
</div>
); );
} }

View File

@@ -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={{
width: 34,
height: 34,
borderRadius: 2,
bgcolor: mark,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: 9,
flexShrink: 0, // Nudges the mark so its optical centre lines up with the collapsed
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39, 0.30)' // sidebar's icon column directly beneath it (measured ~2px apart —
// the two live in separate layout trees with their own padding, so
// this small correction keeps them reading as one straight column).
marginInlineStart: 2,
...style
}} }}
> >
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography> <img
</Box> src={NAVBAR_MARK_SRC}
</Box> // 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,
// 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'
}}
>
Doormile
</span>
</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>
); );
} }

View File

@@ -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',
<Stack marginBottom: '24px',
direction="row" flexWrap: 'wrap'
alignItems="center"
spacing={2.5} // Space between icon and title
sx={{ minWidth: 0 }}
>
{Icon && (
<Avatar
variant="rounded"
sx={{
bgcolor: 'primary.lighter',
color: 'primary.main',
width: 52,
height: 52,
borderRadius: 2.5,
flexShrink: 0
}} }}
> >
<Icon sx={{ fontSize: 28 }} /> <div style={{ display: 'flex', alignItems: 'center', gap: '16px', minWidth: 0 }}>
</Avatar> {Icon && (
<div
style={{
backgroundColor: 'var(--color-brand-muted)',
color: 'var(--color-brand)',
width: 44,
height: 44,
borderRadius: 'var(--radius-element)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Icon size={22} />
</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
View 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>
);
}

View File

@@ -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> }}
>
{/* Value sits under the icon: a box that is the icon's width (40px) so a short <Icon size={isCompact ? 60 : 88} />
number centers on the icon's axis, but grows with wider values ("0.0 km", </div>
"₹1,234") so they left-align under the icon instead of overflowing the card. */}
<Box sx={{ display: 'flex', justifyContent: 'center', width: 'fit-content', minWidth: 40, mb: sub ? 0.5 : 0 }}>
{loading ? (
<Skeleton variant="text" width={28} sx={{ fontSize: '1.6rem' }} />
) : (
<Typography sx={{ fontSize: '1.6rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, whiteSpace: 'nowrap' }}>
{value}
</Typography>
)} )}
</Box>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: isCompact ? '10px' : '14px',
position: 'relative',
zIndex: 2
}}
>
<span
style={{
fontWeight: 700,
fontSize: '0.72rem',
color: 'var(--color-text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
>
{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>
)}
</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 && ( {sub && (
<Stack direction="row" alignItems="center" spacing={0.75}> <div
{trend && <TrendingUpOutlinedIcon sx={{ fontSize: 15, color: '#1E8E3E', flexShrink: 0 }} />} style={{
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 500 }}>{sub}</Typography> paddingTop: isCompact ? '8px' : '10px',
</Stack> 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>
)} )}
</CardContent>
<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
View 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;
}

View File

@@ -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,7 +157,6 @@ 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);
} }
@@ -219,23 +168,19 @@ export default function Header({ onToggle }) {
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={
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}> <TopNavHeading
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}> logo={<Logo compact={isSidebarCollapsed} size={isSidebarCollapsed ? 36 : 32} height={28} />}
<MenuIcon /> headingHref="/dashboard"
</IconButton> />
}
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)
}))
]}
/>
{/* Brand wordmark — left side */} <DropdownMenu
<Box 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> }}
onClick={() => navigate('/dashboard')} items={[
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} { label: 'Notifications', type: 'label' },
> { label: 'Mark all read', icon: <CheckCircle2 size={16} />, onClick: markAllRead, disabled: unread === 0 },
<Logo height={22} /> { type: 'divider' },
</Box> ...(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)
})))
]}
/>
<Box sx={{ flexGrow: 1 }} /> <DropdownMenu
button={{
{/* Search */} variant: 'ghost',
<Box size: 'lg',
component="form" icon: <Avatar name={staffName} fallback={toInitials(staffName)} size="sm" />,
onSubmit={submitSearch} label: staffName
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' }
}} }}
> items={[
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} /> { label: 'Settings', icon: <Settings size={16} />, onClick: () => navigate('/hub-settings') },
<InputBase { type: 'divider' },
value={search} { label: 'Logout', icon: <LogOut size={16} />, onClick: handleLogout }
onChange={(e) => setSearch(e.target.value)} ]}
placeholder="Scan package, check destination…"
sx={{ fontSize: '0.875rem', flex: 1 }}
inputProps={{ 'aria-label': 'search' }}
/> />
</Box> </div>
}
<Tooltip title="Messages">
<IconButton color="inherit" onClick={openMessages}>
<Badge badgeContent={unreadMessages} color="error">
<ChatIcon />
</Badge>
</IconButton>
</Tooltip>
<Tooltip title="Notifications">
<IconButton color="inherit" onClick={openNotif}>
<Badge badgeContent={unread} color="error">
<NotificationsNoneIcon />
</Badge>
</IconButton>
</Tooltip>
<Box
onClick={(e) => setAccount(e.currentTarget)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
ml: 0.5,
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 2,
'&:hover': { bgcolor: 'grey.100' }
}}
>
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{toInitials(staffName)}</Avatar>
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{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 && ( {selectedNotif && (
<> <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%' }}>
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}> <h2 style={{ margin: '0 0 16px 0', fontSize: '1.25rem' }}>{selectedNotif.title}</h2>
{selectedNotif.title} <p style={{ margin: '0 0 16px 0', color: '#475569' }}>{selectedNotif.desc}</p>
</DialogTitle> <div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<Divider /> {selectedNotif.stats.map((s, i) => (
<DialogContent sx={{ py: 3 }}> <div key={i} style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px', flex: 1 }}>
<Typography variant="body1" sx={{ mb: 3 }}> <div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold' }}>{s.label}</div>
{selectedNotif.desc} <div style={{ fontSize: '1.125rem', fontWeight: 'bold', marginTop: '4px' }}>{s.value}</div>
</Typography> </div>
<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> </div>
</DialogContent> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Divider /> <Button variant="ghost" onClick={() => setSelectedNotif(null)}>Dismiss</Button>
<DialogActions sx={{ p: 2 }}> {selectedNotif.actionText && (
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button> <Button onClick={() => { setSelectedNotif(null); navigate(selectedNotif.to); }}>
<Button {selectedNotif.actionText} <ArrowRight size={16} />
variant="contained"
endIcon={<ArrowForwardIcon fontSize="small" />}
onClick={() => {
setSelectedNotif(null);
navigate(selectedNotif.to);
}}
>
{selectedNotif.actionText}
</Button> </Button>
</DialogActions>
</>
)} )}
</Dialog> </div>
</dialog>
)}
{/* High Fidelity Chat Message Dialog */}
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs">
{activeChat && ( {activeChat && (
<> <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' }}>
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}> <div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '12px', background: '#f8fafc' }}>
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}> <div style={{ width: 32, height: 32, borderRadius: '50%', background: RED, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }}>{activeChat.initials}</div>
{activeChat.initials} <h3 style={{ margin: 0, flex: 1 }}>{activeChat.name}</h3>
</Avatar> <Button variant="ghost" size="icon" onClick={closeChat}>×</Button>
<Typography variant="h5" sx={{ fontWeight: 700 }}> </div>
{activeChat.name} <div style={{ height: '300px', overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', background: '#fff' }}>
</Typography> {chatLoading ? (
</DialogTitle> <div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>Loading...</div>
<Divider /> ) : activeChat.messages.length === 0 ? (
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}> <div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>No messages yet.</div>
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}> ) : (
{chatLoading && activeChat.messages.length === 0 && ( activeChat.messages.map((m, i) => (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}> <div key={i} style={{ alignSelf: m.sender === 'me' ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
Loading <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> {m.text}
</div>
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textAlign: m.sender === 'me' ? 'right' : 'left', marginTop: '4px' }}>{m.time}</div>
</div>
))
)} )}
{!chatLoading && activeChat.messages.length === 0 && ( </div>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}> <div style={{ padding: '12px', borderTop: '1px solid #e2e8f0', background: '#fff', display: 'flex', gap: '8px' }}>
No messages yet. Say hello. <TextInput style={{ flex: 1 }} value={typedMessage} onChange={(e) => setTypedMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} placeholder="Type a message..." />
</Typography> <Button size="icon" onClick={handleSendMessage} disabled={!typedMessage.trim() || sending}><Send size={16} /></Button>
</div>
</dialog>
)} )}
{activeChat.messages.map((msg, idx) => (
<Box
key={idx}
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>
); );
} }

View File

@@ -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"
sx={{
px: 2.5,
color: 'text.secondary',
fontWeight: 800,
fontSize: '0.6875rem',
letterSpacing: '0.08em',
display: 'block'
}}
>
{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} key={item.id}
item={item} label={item.title}
open={expanded} // If you migrate icons to Lucide or similar, you pass it here.
active={isActive(item.url)} // Currently keeping the existing MUI icon or whatever item.icon provides.
onClick={() => go(item.url)} icon={item.icon}
href={item.url}
isSelected={isActive(item.url)}
/> />
);
})}
</List>
</Box>
))} ))}
</Box> </SideNavSection>
))}
{/* Bottom Footer Branding */} </SideNav>
{expanded && ( <style>{`
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.default' }}> .doormile-side-nav .astryx-side-nav-section > div:last-child {
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 600, display: 'block', lineHeight: 1.3 }}> gap: 6px !important;
Hub Control Panel
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Doormile Logistics · v1.0
</Typography>
</Box>
)}
</Box>
);
if (isMobile) {
return (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onMobileClose}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
>
{sidebarContent}
</Drawer>
);
} }
return ( .doormile-side-nav .astryx-side-nav-item[aria-label] {
<Drawer width: 40px;
variant="permanent" height: 40px;
sx={{ margin-inline: auto;
width: open ? DRAWER_WIDTH : MINI_WIDTH, border-radius: 10px;
flexShrink: 0, display: flex;
whiteSpace: 'nowrap', align-items: center;
'& .MuiDrawer-paper': { justify-content: center;
width: open ? DRAWER_WIDTH : MINI_WIDTH, transition: background-color 0.15s ease;
border: 'none', }
overflowX: 'hidden', .doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
transition: (theme) => theme.transitions.create('width', { width: 18px !important;
easing: theme.transitions.easing.sharp, height: 18px !important;
duration: theme.transitions.duration.standard, }
}), .doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
}, background-color: rgba(10, 19, 23, 0.08) !important;
}} }
open={open} .doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
> outline: 2px solid rgba(10, 19, 23, 0.4);
{sidebarContent} outline-offset: 2px;
</Drawer> }
.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;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
position: relative;
margin-inline: 2px;
height: auto;
padding-block: 12px !important;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
content: '';
position: absolute;
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;
}
.doormile-side-nav > div:last-child {
padding-block: 8px !important;
}
.doormile-side-nav button[aria-label*="sidebar"],
.doormile-side-nav button[aria-label*="navigation"] {
border-radius: 50% !important;
transition: background-color 0.15s ease !important;
}
.doormile-side-nav button[aria-label*="sidebar"]:hover,
.doormile-side-nav button[aria-label*="navigation"]:hover {
background-color: rgba(10, 19, 23, 0.08) !important;
}
`}</style>
</>
); );
} }

View File

@@ -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
component="main"
sx={{
flexGrow: 1,
minWidth: 0,
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
}}
>
<Box sx={{ height: 64, flexShrink: 0 }} />
<Box
sx={{
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 }
}}
> >
<div className="main-content-area" style={{ minHeight: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }}>
<Outlet /> <Outlet />
</Box> </div>
</Box> <style>{`
</Box> .main-content-area {
padding: 24px;
}
@media (max-width: 768px) {
.main-content-area {
padding: 16px;
}
}
`}</style>
</AppShell>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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 />
<ToastViewport />
</LinkProvider>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </Theme>
</React.StrictMode>
); );

View File

@@ -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

View File

@@ -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
style={{ width: '100%', paddingRight: '40px' }}
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
placeholder="Enter your password" placeholder="Enter your password"
value={pwd} value={pwd}
onChange={(e) => setPwd(e.target.value)} onChange={setPwd}
onKeyDown={(e) => e.key === 'Enter' && handleSignIn()} onKeyDown={(e) => e.key === 'Enter' && handleSignIn()}
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
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>
<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?{' '}
<Link <a
href="#" href="#"
onClick={(e) => { e.preventDefault(); navigate('/signup'); }} onClick={(e) => { e.preventDefault(); navigate('/signup'); }}
underline="hover" style={{ color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}
sx={{ color: '#C01227', fontWeight: 600 }}
> >
Create an account Create an account
</Link> </a>
</Typography> </div>
</Box> </div>
</Stack> </div>
</Card> </div>
</Box> <style>{`
</Box> .spin { animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
@media (max-width: 768px) {
.hide-on-mobile { display: none !important; }
}
@media (min-width: 769px) {
.show-on-mobile { display: none !important; }
}
`}</style>
</div>
); );
} }

View File

@@ -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}
onChange={(e) => setHub(e.target.value)}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.875rem' }}
>
{HUBS.map((h) => ( {HUBS.map((h) => (
<MenuItem key={h.value} value={h.value}>{h.label}</MenuItem> <option key={h.value} value={h.value}>{h.label}</option>
))} ))}
</Select> </select>
</FormControl> </div>
</Grid> <div>
<Grid size={{ xs: 12, sm: 6 }} > <div style={{ marginBottom: '8px', fontWeight: 600, color: '#444', fontSize: '0.875rem' }}>Select Role</div>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#444' }}>Select Role</Typography> <select
<FormControl fullWidth> value={role}
<Select value={role} onChange={(e) => setRole(e.target.value)}> onChange={(e) => setRole(e.target.value)}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.875rem' }}
>
{ROLES.map((r) => ( {ROLES.map((r) => (
<MenuItem key={r.value} value={r.value}>{r.label}</MenuItem> <option key={r.value} value={r.value}>{r.label}</option>
))} ))}
</Select> </select>
</FormControl> </div>
</Grid> </div>
</Grid>
<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{' '}
<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' }}>Terms of Service</a> and{' '}
<Link href="#" underline="hover" sx={{ color: '#C01227', fontWeight: 500 }}>Operations Guidelines</Link>. <a href="#" style={{ color: 'var(--color-brand)', fontWeight: 500, textDecoration: 'none' }}>Operations Guidelines</a>.
</Typography> </div>
} </div>
/>
<Button <Button
fullWidth variant="primary"
size="large"
variant="contained"
type="submit" type="submit"
sx={{ style={{ width: '100%', justifyContent: 'center' }}
bgcolor: '#C01227',
color: '#fff',
py: 1.5,
fontWeight: 600,
borderRadius: 2,
textTransform: 'none',
boxShadow: 'none',
'&:hover': {
bgcolor: '#9E0E20',
boxShadow: 'none'
}
}}
> >
Register Account Register Account
</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">
Already have an account?{' '} Already have an account?{' '}
<Link <a
href="#" href="#"
onClick={(e) => { e.preventDefault(); navigate('/login'); }} onClick={(e) => { e.preventDefault(); navigate('/login'); }}
underline="hover" style={{ color: 'var(--color-brand)', fontWeight: 600, textDecoration: 'none' }}
sx={{ color: '#C01227', fontWeight: 600 }}
> >
Sign In Sign In
</Link> </a>
</Typography> </div>
</Box> </form>
</Stack> </div>
</Box> </div>
</Card> <style>{`
</Box> @media (max-width: 768px) {
</Box> .hide-on-mobile { display: none !important; }
}
@media (min-width: 769px) {
.show-on-mobile { display: none !important; }
}
`}</style>
</div>
); );
} }

View File

@@ -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,198 +188,220 @@ 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 &amp; 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>
<Button variant="primary" onClick={() => setOpenModal(true)} icon={<Plus size={16} />}>
New Batch New Batch
</Button> </Button>
} </div>
/>
<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={{ padding: '48px', textAlign: 'center' }}>
<CircularProgress /> <Text type="body" color="secondary">Loading...</Text>
</Box> </div>
) : manifests.length === 0 && !loadError ? ( ) : manifests.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}> <div style={{ padding: '48px', textAlign: 'center' }}>
<LocalShippingIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> <Text type="body" color="secondary">
<Typography variant="body2" color="text.secondary">
{isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`} {isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`}
</Typography> </Text>
</Box> </div>
) : isMdDown ? ( ) : isMdDown ? (
/* ── MOBILE / TABLET: spacious cards ── */ /* ── MOBILE / TABLET: cards ── */
<Box sx={{ p: { xs: 2, sm: 2.5 }, display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{manifests.map((m) => { {manifests.map((m) => {
const meta = STATUS_META[m.status] || STATUS_META.Preparing; const meta = STATUS_META[m.status] || STATUS_META.Preparing;
const KindIcon = m.kind === 'transfer' ? SwapHorizOutlinedIcon : TwoWheelerOutlinedIcon;
return ( return (
<Card key={m.id} elevation={0} sx={{ borderRadius: 3, border: '1px solid #ECEEF1' }}> <Card key={m.id} padding={3} style={{ border: '1px solid var(--color-border)' }}>
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}> <HStack justify="between" align="center" style={{ marginBottom: '10px' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }} gap={1}> <VStack gap={0}>
<Stack direction="row" alignItems="center" spacing={2.25} sx={{ minWidth: 0 }}> <Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{m.id}</Text>
<Avatar variant="rounded" sx={{ bgcolor: meta.bg, color: meta.color, borderRadius: 2, width: 38, height: 38 }}> <Text type="supporting" color="secondary">{m.time}</Text>
<KindIcon sx={{ fontSize: 20 }} /> </VStack>
</Avatar> <Badge variant={meta.variant} label={meta.label} />
<Box sx={{ minWidth: 0 }}> </HStack>
<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 }}> <div style={{ padding: '10px 12px', background: 'var(--color-background-muted)', borderRadius: 'var(--radius-inner)', marginBottom: '12px' }}>
<Journey m={m} /> <Journey m={m} />
</Box> </div>
<Stack direction="row" flexWrap="wrap" gap={0.75} sx={{ mb: 2 }}> <HStack gap={4} style={{ marginBottom: '12px' }}>
<Chip size="small" icon={<TwoWheelerOutlinedIcon sx={{ fontSize: '15px !important' }} />} label={m.vehicle} <VStack gap={0}>
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, maxWidth: '100%', '& .MuiChip-icon': { color: '#9AA0A6' } }} /> <Text type="supporting" color="secondary">Miler / Vehicle</Text>
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '15px !important' }} />} label={`${m.packagesCount} parcels`} <Text type="body" weight="semibold">{m.vehicle}</Text>
sx={{ bgcolor: '#F1F3F5', color: '#495057', fontWeight: 600, '& .MuiChip-icon': { color: '#9AA0A6' } }} /> </VStack>
</Stack> <VStack gap={0}>
<Text type="supporting" color="secondary">Parcels</Text>
<Text type="body" weight="semibold">{m.packagesCount}</Text>
</VStack>
</HStack>
<BatchAction m={m} fullWidth /> <BatchAction m={m} fullWidth />
</CardContent>
</Card> </Card>
); );
})} })}
</Box> </div>
) : ( ) : (
/* ── DESKTOP: spacious table ── */ /* ── DESKTOP: table ── */
<TableContainer sx={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<Table sx={{ minWidth: 820 }}> <Table
<TableHead> data={manifests}
<TableRow sx={{ bgcolor: '#FAFBFC' }}> columns={columns}
{['Batch', 'Journey', 'Miler / Vehicle', 'Parcels', 'Status', 'Action'].map((h, i) => ( idKey="tripsheetid"
<TableCell key={h} align={i === 3 ? 'center' : i === 5 ? 'right' : 'left'} density="balanced"
sx={{ fontWeight: 700, fontSize: '0.7rem', color: '#6C757D', textTransform: 'uppercase', letterSpacing: 0.5, py: 2, borderBottom: '1px solid #ECEEF1', whiteSpace: 'nowrap' }}> dividers="rows"
{h} hasHover
</TableCell> />
))} </div>
</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> </Panel>
</Grid>
</Grid>
{/* Creation Modal */} {/* Create Batch Dialog */}
<Dialog open={openModal} onClose={() => setOpenModal(false)} fullWidth maxWidth="xs"> <Dialog isOpen={openModal} onOpenChange={setOpenModal} width={480}>
<DialogTitle sx={{ fontWeight: 700 }}>Create a New Batch</DialogTitle> <form onSubmit={handleCreateManifest}>
<DialogContent> <Layout
<Box component="form" onSubmit={handleCreateManifest} sx={{ mt: 1 }}> header={<DialogHeader title="Create a New Batch" onOpenChange={setOpenModal} />}
<Stack spacing={2.5}> content={
<TextField <LayoutContent>
select <VStack gap={4} style={{ padding: '24px' }}>
fullWidth <Selector
label="Where is this batch going?" label="Where is this batch going?"
options={ROUTE_OPTIONS}
value={newRoute} value={newRoute}
onChange={(e) => setNewRoute(e.target.value)} onChange={setNewRoute}
> />
<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 <TextInput
fullWidth
label="Full destination address / hub" label="Full destination address / hub"
placeholder="e.g. Dwarka Sector 12, Delhi" placeholder="e.g. Dwarka Sector 12, Delhi"
value={newDestination} value={newDestination}
@@ -385,8 +409,7 @@ export default function Dispatch() {
required required
/> />
<TextField <TextInput
fullWidth
label="Miler name or vehicle number" label="Miler name or vehicle number"
placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092" placeholder="e.g. Amit Kumar (EV) or DL-3C-YY-1092"
value={newVehicle} value={newVehicle}
@@ -394,37 +417,30 @@ export default function Dispatch() {
required required
/> />
<TextField <TextInput
fullWidth
type="number" type="number"
label="How many parcels?" label="How many parcels?"
value={newPkgsCount} value={newPkgsCount}
onChange={(e) => setNewPkgsCount(e.target.value)} onChange={(e) => setNewPkgsCount(e.target.value)}
inputProps={{ min: 1 }} min="1"
required required
/> />
</Stack> </VStack>
</Box> </LayoutContent>
</DialogContent> }
<DialogActions> footer={
<Button onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button> <LayoutFooter hasDivider>
<Button variant="contained" onClick={handleCreateManifest} disabled={creating} <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', width: '100%', padding: '16px 24px' }}>
startIcon={creating ? <CircularProgress size={16} color="inherit" /> : null}> <Button variant="ghost" onClick={() => setOpenModal(false)} disabled={creating}>Cancel</Button>
<Button variant="primary" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create Batch'} {creating ? 'Creating…' : 'Create Batch'}
</Button> </Button>
</DialogActions> </div>
</LayoutFooter>
}
/>
</form>
</Dialog> </Dialog>
</div>
<Snackbar
open={toast.open}
autoHideDuration={4000}
onClose={() => setToast({ ...toast, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert severity={toast.severity} onClose={() => setToast({ ...toast, open: false })} sx={{ width: '100%' }}>
{toast.msg}
</Alert>
</Snackbar>
</Box>
); );
} }

View File

@@ -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 cant 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&rsquo;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 }))}
/>
<TextInput label="Display name" value={staffForm.displayname} onChange={(e) => setStaffForm((f) => ({ ...f, displayname: e.target.value }))} />
<TextInput type="email" label="Email" value={staffForm.email} isRequired onChange={(e) => setStaffForm((f) => ({ ...f, email: e.target.value }))} />
<HStack gap={2} align="end">
<div style={{ flex: 1 }}>
<TextInput
type={showPwd ? 'text' : 'password'}
label="Password"
value={staffForm.password}
isRequired
onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))} onChange={(e) => setStaffForm((f) => ({ ...f, password: e.target.value }))}
InputProps={{ />
endAdornment: ( </div>
<InputAdornment position="end"> <IconButton
<IconButton onClick={() => setShowPwd((s) => !s)} edge="end" size="small"> label={showPwd ? 'Hide password' : 'Show password'}
{showPwd ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />} tooltip={showPwd ? 'Hide password' : 'Show password'}
</IconButton> icon={showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
</InputAdornment> variant="secondary"
) onClick={() => setShowPwd((s) => !s)}
}} /> />
</Stack> </HStack>
</DialogContent> </VStack>
<DialogActions> </LayoutContent>
<Button onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button> }
<Button variant="contained" onClick={submitStaff} disabled={savingStaff} footer={
startIcon={savingStaff ? <CircularProgress size={16} color="inherit" /> : null} <LayoutFooter hasDivider>
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#9E0E20' } }}> <HStack justify="end" gap={2} style={{ width: '100%', padding: '16px 24px' }}>
{savingStaff ? 'Creating…' : 'Create Login'} <Button variant="ghost" onClick={() => setStaffDialog(false)} disabled={savingStaff}>Cancel</Button>
</Button> <Button variant="primary" type="submit" disabled={savingStaff}>{savingStaff ? 'Creating…' : 'Create Login'}</Button>
</DialogActions> </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>
); );
} }

View File

@@ -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' },
{ icon: Inventory2OutlinedIcon, label: 'To Sort', value: stats.pendingSort, color: '#B06000', bg: '#FEF7E0' },
{ icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' },
{ icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' },
].map((s, i) => (
<Grid size={{ xs: 6, md: 3 }} key={i}>
<StatCard icon={s.icon} label={s.label} value={s.value} color={s.color} bg={s.bg} loading={loading} />
</Grid>
))}
</Grid>
{/* ── Main ── */} <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: '1 1 auto' }}>
<Grid container spacing={{ xs: 2, md: 2.5 }} alignItems="stretch"> <div style={{ width: 36, height: 36, borderRadius: '8px', background: '#eff6ff', color: '#3b82f6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Inbox size={18} /></div>
<div>
<div style={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{isToday ? 'Received Today' : 'Received'}</div>
<div style={{ fontSize: '1.25rem', fontWeight: 800, color: '#0f172a', lineHeight: 1.1 }}>{loading ? '...' : stats.received}</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: '#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", <form onSubmit={handleSubmit} style={{ padding: '20px' }}>
borderRadius: 1, <FormLayout>
width: 40, <Field label="Booking ID" description="Must be an existing booking in the system">
height: 40, <TextInput required placeholder="e.g. 15" value={bookingId} onChange={setBookingId} />
}} </Field>
<Field label="Tracking ID">
<TextInput placeholder="e.g. DM-882204" value={trackingId} onChange={setTrackingId} />
</Field>
<Field label="Where it came from">
<select
value={origin}
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' }}
> >
<QrCodeScannerOutlinedIcon /> {ORIGINS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Avatar> </select>
</Field>
<Box> <Field label="Sender Address">
<Typography <TextInput placeholder="e.g. Andheri East, Mumbai" value={senderAddress} onChange={setSenderAddress} />
variant="h5" </Field>
sx={{ fontWeight: 700, color: "#1A1A2E" }}
<Field label="Where it's going (delivery address)">
<TextInput required placeholder="e.g. Rohini Sec 9, Delhi" value={destination} onChange={setDestination} />
</Field>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<Field label="Weight">
<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' }}
> >
Add a Parcel <option value="Good">Good</option>
</Typography> <option value="Damaged Box">Damaged Box</option>
<option value="Wet / Crushed">Wet / Crushed</option>
<option value="Missing Label">Missing Label</option>
</select>
</Field>
</div>
<Typography variant="caption" color="text.secondary"> <Field label="Temperature (Cold Chain)">
Record a parcel arriving at {hubName} <TextInput placeholder="4.0°C — or N/A if dry" value={temp} onChange={setTemp} />
</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} <div style={{ marginTop: '16px' }}>
onChange={(e) => setTrackingId(e.target.value)} sx={fieldSx} <Button
InputProps={{ variant="primary"
startAdornment: <InputAdornment position="start"><QrCodeScannerOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> type="submit"
}} /> disabled={submitting}
icon={submitting ? <Loader2 size={16} className="spin" /> : <CheckCircle2 size={16} />}
<TextField select fullWidth label="Where it came from" value={origin} style={{ width: '100%', justifyContent: 'center' }}
onChange={(e) => setOrigin(e.target.value)} sx={fieldSx} >
InputProps={{ startAdornment: <InputAdornment position="start"><HomeWorkOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }}>
{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}
onChange={(e) => setSenderAddress(e.target.value)} sx={fieldSx} />
<TextField fullWidth label="Where it's going (delivery address)" placeholder="e.g. Rohini Sec 9, Delhi" value={destination}
onChange={(e) => setDestination(e.target.value)} required sx={fieldSx}
InputProps={{ startAdornment: <InputAdornment position="start"><PlaceOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
<Stack direction="row" spacing={2}>
<TextField fullWidth label="Weight" placeholder="2.4 kg" value={weight}
onChange={(e) => setWeight(e.target.value)} sx={fieldSx}
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}
onChange={(e) => setTemp(e.target.value)} sx={fieldSx}
InputProps={{ startAdornment: <InputAdornment position="start"><ThermostatOutlinedIcon sx={{ color: '#9AA0A6', fontSize: 20 }} /></InputAdornment> }} />
<Button fullWidth size="large" variant="contained" type="submit" disabled={submitting}
startIcon={submitting ? <CircularProgress size={18} color="inherit" /> : <CheckCircleOutlinedIcon />}
sx={{ mt: 0.5, py: 1.3, borderRadius: 2, bgcolor: '#C01227', fontWeight: 700,
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>
); );
} }

View File

@@ -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"
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={handleAutoAssignAll}
disabled={selectedOrders.length === 0 || busy}
sx={{ borderRadius: 2, fontWeight: 700, boxShadow: 'none', whiteSpace: 'nowrap' }}
> >
{selectedOrders.length === 0 ? 'Auto-Assign' : `Auto-Assign (${selectedOrders.length})`} <HStack gap={4} align="center">
<span
style={{
background: 'var(--color-background-blue)',
color: 'var(--color-icon-blue)',
width: 40,
height: 40,
borderRadius: 'var(--radius-element)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
>
<UserCheck size={20} />
</span>
<VStack gap={0.5}>
<Heading level={4} style={{ margin: 0 }}>{isToday ? 'Waiting for a Miler' : `Pickup requests · ${rangeLabel}`}</Heading>
<Text type="supporting" color="secondary">
{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> </Button>
} </div>
sx={{ flexWrap: 'wrap', gap: 1, '& .MuiCardHeader-action': { m: 0, alignSelf: 'center' } }}
/>
<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={{ 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 ? (
<EmptyState
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><UserCheck size={48} /></span>}
title={isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
description="There is no data available."
style={{ padding: '64px 0' }}
/>
) : (
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((row) => { {orders.map((row) => {
const isSelected = selectedOrders.includes(row.id); const isSelected = selectedOrders.has(String(row.id));
const isAssigned = row.status !== 'Pending Assignment'; const isAssigned = row.status !== PENDING;
return (
<Card key={row.id} elevation={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 }}>
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
{!isAssigned && (
<Checkbox size="small" sx={{ p: 0 }} checked={isSelected} onChange={() => handleSelectOne(row.id)} />
)}
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, fontFamily: 'monospace', color: '#1A1A2E', lineHeight: 1.2 }}>{row.id}</Typography>
<Typography variant="caption" color="text.secondary">{row.time}</Typography>
</Box>
</Stack>
{(() => {
const c = statusChip(row.status); const c = statusChip(row.status);
return ( return (
<Chip size="small" icon={c.done && c.color === '#1E8E3E' ? <CheckCircleIcon sx={{ fontSize: '15px !important' }} /> : undefined} <Card
label={c.label} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} /> key={row.id}
); padding={4}
})()} style={{
</Stack> 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 && (
<CheckboxInput
label={`Select ${row.id}`}
isLabelHidden
value={isSelected}
onChange={(checked) => handleSelectOne(row.id, checked)}
/>
)}
<VStack gap={0}>
<Text type="body" weight="bold" style={{ fontFamily: 'monospace' }}>{row.id}</Text>
<Text type="supporting" color="secondary">{row.time}</Text>
</VStack>
</HStack>
<Badge variant={c.variant} label={c.label} />
</HStack>
<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>
); );
})} })}
</Box> </div>
)
) : ( ) : (
/* ── 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={
!loadError && (
<EmptyState
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><UserCheck size={48} /></span>}
title={isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
description="There is no data available."
style={{ padding: '64px 0' }}
/> />
</TableCell> )
{['Request', 'Pick Up From', 'Going To', 'Parcel', 'Status', 'Action'].map((h, i) => ( }
<TableCell key={h} align={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' }}> </div>
{h}
</TableCell>
))}
</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> </Panel>
</TableRow>
); {/* Assign Dialog Overlay */}
})} <Dialog isOpen={assignDialogOpen} onOpenChange={setAssignDialogOpen} width={500}>
</TableBody> <Layout
</Table> header={<DialogHeader title={`Choose a Miler for pickup #${selectedOrderForAssign?.id}`} onOpenChange={setAssignDialogOpen} />}
</TableContainer> content={
)} <LayoutContent>
</Card> <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>
<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 ? ( {availableMilers.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> <EmptyState
No available milers right now. Try Auto-Assign, or check the Milers page. title="No milers available"
</Typography> description="No available milers right now. Try Auto-Assign, or check the Milers page."
isCompact
/>
) : ( ) : (
<List> <VStack gap={2}>
{availableMilers.map((miler) => ( {availableMilers.map((miler) => (
<ListItemButton <div
key={miler.id} key={miler.id}
onClick={() => setSelectedMiler(miler.id)} onClick={() => setSelectedMiler(miler.id)}
sx={{ style={{
border: '1px solid', border: `1px solid ${selectedMiler === miler.id ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderColor: selectedMiler === miler.id ? 'primary.main' : 'divider', borderRadius: 'var(--radius-inner)',
borderRadius: 2, padding: '12px 16px',
mb: 1, display: 'flex',
bgcolor: selectedMiler === miler.id ? 'primary.lighter' : 'transparent' alignItems: 'center',
justifyContent: 'space-between',
background: selectedMiler === miler.id ? 'var(--color-background-muted)' : 'transparent',
cursor: 'pointer'
}} }}
> >
<ListItemAvatar> <HStack gap={4} align="center">
<Badge color="success" variant="dot" anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}> <div style={{ position: 'relative' }}>
<Avatar sx={{ bgcolor: 'grey.200', color: 'grey.700' }}><TwoWheelerIcon /></Avatar> <span
</Badge> style={{
</ListItemAvatar> width: '40px',
<ListItemText height: '40px',
primary={miler.name} borderRadius: 'var(--radius-full)',
secondary={ background: 'var(--color-neutral)',
<Stack direction="row" alignItems="center" gap={0.5} component="span"> color: 'var(--color-text-secondary)',
<span>{miler.status}</span> 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 && ( {miler.rating != null && (
<> <>
<span></span> <Text type="supporting" color="secondary"></Text>
<StarRoundedIcon sx={{ fontSize: 14, color: '#F5A623' }} /> <span style={{ color: 'var(--color-icon-orange)', display: 'flex' }}>
<span>{miler.rating}</span> <Star size={12} fill="currentColor" />
</span>
<Text type="supporting" color="secondary">{miler.rating}</Text>
</> </>
)} )}
</Stack> </HStack>
} </VStack>
primaryTypographyProps={{ fontWeight: 600 }} </HStack>
<input
type="radio"
checked={selectedMiler === miler.id}
readOnly
style={{ width: '18px', height: '18px', accentColor: 'var(--color-accent)' }}
/> />
<Radio checked={selectedMiler === miler.id} onChange={() => setSelectedMiler(miler.id)} /> </div>
</ListItemButton>
))} ))}
</List> </VStack>
)} )}
</DialogContent> </div>
<DialogActions> </LayoutContent>
<Button onClick={() => setAssignDialogOpen(false)} color="inherit">Cancel</Button> }
<Button onClick={handleAssign} variant="contained" disabled={!selectedMiler || busy} footer={
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}> <LayoutFooter hasDivider>
Confirm Assignment <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> </Button>
</DialogActions> </div>
</LayoutFooter>
}
/>
</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>
); );
} }

View File

@@ -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} />
{/* Contact */} <DetailRow icon={CalendarClock} label="Time slot" value={order.slot} />
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}> <DetailRow icon={Package} label="Items" value={order.items != null ? `${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}` : ''} />
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}> <DetailRow icon={Scale} label="Weight" value={order.weight} />
{isPickup ? 'MERCHANT / SENDER' : 'CUSTOMER'} <DetailRow icon={Ruler} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} />
</Typography> <DetailRow icon={Wallet} label="Payment" value={order.payment} />
<Stack spacing={1.75}>
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : PersonOutlineOutlinedIcon} label="Name" value={order.customer} />
<DetailRow icon={PhoneOutlinedIcon} label="Phone" value={order.phone} />
<DetailRow icon={PlaceOutlinedIcon} label="Pickup address" value={order.address} />
</Stack>
</Paper>
{/* Pickup summary */}
<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 }}>
PICKUP SUMMARY
</Typography>
<Grid container rowSpacing={2} columnSpacing={1.5}>
<Grid item xs={6}><DetailRow icon={AccessTimeOutlinedIcon} label="Picked at" value={order.time} /></Grid>
<Grid item xs={6}><DetailRow icon={ScheduleOutlinedIcon} label="Time slot" value={order.slot} /></Grid>
<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> </Grid>
</Paper> </Card>
{order.cod > 0 && ( {order.cod > 0 && (
<Box sx={{ p: 2, borderRadius: 2, bgcolor: alpha('#F29900', 0.1), border: `1px solid ${alpha('#F29900', 0.3)}` }}> <div style={{ padding: '14px', borderRadius: 'var(--radius-element)', background: 'var(--color-background-orange)', border: '1px solid var(--color-icon-orange)' }}>
<Stack direction="row" alignItems="center" spacing={1.5}> <HStack gap={2} align="center">
<PaymentsOutlinedIcon sx={{ color: '#B06000' }} /> <span style={{ color: 'var(--color-icon-orange)', display: 'flex' }}><Wallet size={20} /></span>
<Box> <VStack gap={0}>
<Typography variant="caption" sx={{ color: '#B06000', fontWeight: 700 }}>CASH ON PICKUP</Typography> <Text type="supporting" weight="bold" style={{ color: 'var(--color-text-orange)' }}>CASH ON PICKUP</Text>
<Typography sx={{ fontWeight: 800, color: '#B06000' }}>{inr(order.cod)}</Typography> <Text type="body" weight="bold" style={{ color: 'var(--color-text-orange)' }}>{inr(order.cod)}</Text>
</Box> </VStack>
</Stack> </HStack>
</Box> </div>
)} )}
{/* Route & location */} <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 &amp; Location</Text>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}> <VStack gap={3}>
ROUTE &amp; LOCATION <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)}` : ''} />
<Stack spacing={1.75}> {order.instructions && <DetailRow icon={FileText} label="Instructions" value={order.instructions} valueColor="var(--color-text-secondary)" />}
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg" </VStack>
value={isPickup ? `${order.customer}${HUB.label}` : `${HUB.label}${order.customer}`} /> </Card>
<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 */} <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' }}>Assigned Miler</Text>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}> <HStack gap={3} align="center">
ASSIGNED MILER <Avatar name={rider.name} size={40} style={{ backgroundColor: hexAlpha(rider.color, 0.14), color: rider.color }} />
</Typography> <VStack gap={0} style={{ minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1.5}> <Text type="body" weight="bold">{rider.name}</Text>
<Avatar sx={{ width: 40, height: 40, bgcolor: alpha(rider.color, 0.14), color: rider.color, fontWeight: 700 }}>{initials(rider.name)}</Avatar> <Text type="supporting" color="secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Text>
<Box sx={{ flex: 1, minWidth: 0 }}> </VStack>
<Typography sx={{ fontWeight: 700 }} noWrap>{rider.name}</Typography> </HStack>
<Typography variant="caption" color="text.secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Typography> </Card>
</Box> </VStack>
</Stack> </LayoutContent>
</Paper> }
footer={
</Stack> <div style={{ padding: '16px', borderTop: '1px solid var(--color-border)', display: 'flex', gap: '10px' }}>
</Box> <Button variant="secondary" style={{ flex: 1, justifyContent: 'center' }} icon={<Phone size={16} />} onClick={() => window.open(`tel:${order.phone}`, '_self')}>Call</Button>
<Button variant="primary" style={{ flex: 1, justifyContent: 'center', background: rider.color }} icon={<CheckCircle2 size={16} />} onClick={onBack}>Done</Button>
{/* Footer */} </div>
<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]);
return ( const speedMenuItems = [0.5, 1, 2, 4].map((s) => ({
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}> label: `${s}× speed`,
{/* ── Header ── */} icon: speed === s ? <CheckCircle2 size={14} /> : undefined,
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems={{ md: 'center' }} gap={1} mb={2} sx={{ mb: 2 }}> onClick: () => setSpeed(s)
<Stack direction="row" alignItems="center" spacing={2.5}> }));
<Avatar sx={{ bgcolor: alpha('#C01227', 0.1), color: '#C01227', width: 56, height: 56, borderRadius: 2 }}>
<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> return (
<div style={{ paddingBottom: '32px' }}>
<PageHeader
icon={Route}
title="Rider Routes"
subtitle="Pickups each miler covered today — open any order for full details, or press play to replay the trip."
/>
{!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 &amp; their {modeCfg.label.toLowerCase()}</Typography> <Text type="body" weight="bold">Milers &amp; 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 isHub = s.kind === 'hub';
const meta = STATUS_META[s.status]; const meta = STATUS_META[s.status];
const StatusIcon = meta?.icon; const StatusIcon = meta?.icon;
// Hub return — compact row, not a card.
if (isHub) {
return ( return (
<Stack key={key} direction="row" alignItems="center" gap={1.25} sx={{ px: 0.5, py: 0.5 }}> <div
<Box sx={{ width: 28, height: 28, borderRadius: 2, flexShrink: 0, bgcolor: alpha('#C01227', 0.12), color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<WarehouseRoundedIcon sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.label}</Typography>
<Typography variant="caption" color="text.secondary">Back to hub · {s.time}</Typography>
</Box>
</Stack>
);
}
return (
<Box
key={key} key={key}
onMouseEnter={() => setFocusedStop(key)} onMouseEnter={() => setFocusedStop(key)}
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))} onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
onClick={() => openDetail(rider, s, i)} onClick={() => openDetail(rider, s, i)}
sx={{ style={{
p: 1.5, borderRadius: 2, cursor: 'pointer', transition: 'all .15s', padding: '12px', borderRadius: 'var(--radius-element)', cursor: 'pointer',
border: '1px solid', borderColor: focusedStop === key ? alpha(rider.color, 0.55) : '#ECEEF1', border: `1px solid ${focusedStop === key ? hexAlpha(rider.color, 0.55) : 'var(--color-border)'}`,
bgcolor: focusedStop === key ? alpha(rider.color, 0.04) : '#fff', background: focusedStop === key ? hexAlpha(rider.color, 0.05) : 'var(--color-background-surface)'
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 */} <HStack justify="between" align="center" gap={1}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}> <HStack gap={1.5} align="center" style={{ minWidth: 0 }}>
<Stack direction="row" alignItems="center" gap={1} sx={{ 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>
<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> <Text type="body" weight="bold" maxLines={1}>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap> <span style={{ color: 'var(--color-text-secondary)', fontWeight: 600 }}>Order </span>#{s.orderId}
<Box component="span" sx={{ color: '#9AA0A6', fontWeight: 600 }}>Order </Box>#{s.orderId} </Text>
</Typography> </HStack>
</Stack> {meta && <Badge variant={meta.variant} label={meta.label} icon={StatusIcon ? <StatusIcon size={12} /> : undefined} />}
{meta && ( </HStack>
<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 justify="between" align="center" gap={1} style={{ marginTop: 8 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mt: 1 }}> <HStack gap={1} align="center" style={{ minWidth: 0 }}>
<Stack direction="row" alignItems="center" gap={0.75} sx={{ minWidth: 0 }}> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Bike size={14} /></span>
<DeliveryDiningRoundedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0 }} /> <Text type="supporting" color="secondary" maxLines={1}>{rider.name}</Text>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }} noWrap>{rider.name}</Typography> </HStack>
</Stack> <HStack gap={1} align="center" style={{ flexShrink: 0 }}>
<Stack direction="row" alignItems="center" gap={0.5} sx={{ flexShrink: 0 }}> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Clock size={13} /></span>
<AccessTimeOutlinedIcon sx={{ fontSize: 14, color: '#9AA0A6' }} /> <Text type="supporting" color="secondary">{s.time}</Text>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{s.time}</Typography> </HStack>
</Stack> </HStack>
</Stack>
<Divider sx={{ my: 1.25 }} /> <Divider style={{ margin: '10px 0' }} />
{/* merchant + address */} <HStack gap={1} align="center">
<Stack direction="row" alignItems="center" gap={0.75}> <span style={{ color: rider.color, display: 'flex' }}><Store size={14} /></span>
<StorefrontOutlinedIcon sx={{ fontSize: 16, color: rider.color, flexShrink: 0 }} /> <Text type="body" weight="bold" maxLines={1}>{s.customer}</Text>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.customer}</Typography> </HStack>
</Stack> <HStack gap={1} align="start" style={{ marginTop: 4 }}>
<Stack direction="row" alignItems="flex-start" gap={0.75} sx={{ mt: 0.5 }}> <span style={{ color: 'var(--color-icon-disabled)', display: 'flex', marginTop: 1 }}><MapPin size={14} /></span>
<PlaceOutlinedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0, mt: '1px' }} /> <Text type="supporting" color="secondary" maxLines={1} style={{ flex: 1 }}>{s.address}</Text>
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography> </HStack>
</Stack>
{/* metric chips — only render fields the API actually returned */}
{(s.legKm != null || s.weight || s.items != null) && ( {(s.legKm != null || s.weight || s.items != null) && (
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.75, mt: 1.25 }}> <HStack gap={1} wrap="wrap" style={{ marginTop: 10 }}>
{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.legKm != null && <Badge variant="neutral" label={`${s.legKm} km`} />}
{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.weight && <Badge variant="neutral" label={s.weight} />}
{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' }} />} {s.items != null && <Badge variant="neutral" label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} />}
</Stack> </HStack>
)} )}
</Box> </div>
); );
})} })}
</Stack> </VStack>
</Box> )}
</Collapse> </div>
</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

View File

@@ -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}>
{/* Search Panel */}
<Grid size={{ xs: 12, md: 5, lg: 4 }} >
<Stack spacing={3}>
<Card sx={{ borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.04)', border: '1px solid #eaeaea' }}>
<CardHeader
title={<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Scan a Parcel</Typography>}
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><QrCodeScannerIcon /></Avatar>}
/> />
<Divider />
<CardContent sx={{ pt: 3 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', flex: 1, alignItems: 'stretch' }}>
<Stack spacing={2}> {/* Search Panel */}
<TextField <VStack gap={2} style={{ flex: '1 1 300px', display: 'flex', flexDirection: 'column' }}>
fullWidth <Panel style={{ flex: 3, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '10px' }}>
<IconTile size={30}><QrCode size={16} /></IconTile>
<Text type="body" weight="bold">Scan a Parcel</Text>
</div>
<VStack gap={3} style={{ padding: '16px', flex: 1 }}>
<TextInput
label="Parcel tracking number" label="Parcel tracking number"
placeholder="e.g. DM-1001" placeholder="e.g. DM-1001"
value={searchId} value={searchId}
onChange={(e) => setSearchId(e.target.value)} onChange={setSearchId}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
InputProps={{
sx: { borderRadius: 2, bgcolor: '#fff' }
}}
/> />
<Button <Button
variant="contained"
size="large"
onClick={() => handleSearch()} onClick={() => handleSearch()}
fullWidth style={{ width: '100%', justifyContent: 'center' }}
disabled={loading} disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : null} variant="primary"
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'} {loading ? 'Checking…' : 'Tell Me What To Do'}
</Button> </Button>
</Stack> </VStack>
</CardContent> </Panel>
</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;
return (
<div
key={pkg.trackingno} key={pkg.trackingno}
onClick={() => { onClick={() => {
setSearchId(pkg.trackingno); setSearchId(pkg.trackingno);
handleSearch(pkg.trackingno); handleSearch(pkg.trackingno);
}} }}
selected={searchId === pkg.trackingno} style={{
sx={{ padding: '10px 12px',
borderRadius: 2, mb: 1, p: 2, borderRadius: 'var(--radius-inner)',
border: '1px solid', cursor: 'pointer',
borderColor: searchId === pkg.trackingno ? '#C01227' : '#eaeaea', border: `1px solid ${selected ? 'var(--color-accent)' : 'var(--color-border)'}`,
bgcolor: searchId === pkg.trackingno ? '#C0122708' : '#fff', backgroundColor: selected ? 'var(--color-accent-muted)' : 'var(--color-background-surface)',
'&:hover': { bgcolor: '#f8f9fa' } transition: 'all 0.15s ease'
}} }}
> >
<ListItemText <Text type="body" weight="bold" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>{pkg.trackingno}</Text>
primary={pkg.trackingno} <Text type="supporting" color="secondary" style={{ display: 'block', marginTop: 2 }}>Going to {pkg.dest}</Text>
secondary={`Going to ${pkg.dest}`} </div>
primaryTypographyProps={{ fontWeight: 700, fontSize: '0.9rem', color: searchId === pkg.trackingno ? '#C01227' : '#212529' }} );
secondaryTypographyProps={{ fontSize: '0.75rem', mt: 0.5, color: '#6c757d' }} })}
/> </div>
</ListItemButton> </Panel>
))} </VStack>
</List>
</CardContent>
</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&apos;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&apos;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>
); );
} }

View File

@@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>' attribution='&copy; <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}>
{/* Active Network Hub Nodes Summary */} <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 }}>Active Hub Nodes</Typography>} style={{ paddingRight: '20px' }}
avatar={<Avatar sx={{ bgcolor: '#C0122710', color: '#C01227', borderRadius: 2 }}><HubIcon fontSize="small" /></Avatar>} trigger={
/> <div style={{ padding: '16px 8px 16px 20px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<Divider /> <div style={{ background: '#fef2f2', color: '#ef4444', width: '32px', height: '32px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<List disablePadding> <MapPin size={18} />
</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>
</Collapsible>
</Card> </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' }}>
<Bike size={18} />
</div>
<Heading level={5} style={{ margin: 0 }}>Milers Out Now {hub.city ? `(${hub.city})` : ''}</Heading>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', borderTop: '1px solid #e2e8f0' }}>
{milers.length === 0 ? ( {milers.length === 0 ? (
<Box sx={{ px: 3, py: 3 }}> <EmptyState title="No active milers" description="No milers reporting a location right now." isCompact />
<Typography variant="body2" color="text.secondary">No milers reporting a location right now.</Typography>
</Box>
) : ( ) : (
<List disablePadding> milers.map((m) => {
{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>
</Collapsible>
</Card> </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>
</Collapsible>
</Card> </Card>
</Stack> </div>
</Box> </div>
</Box> </div>
</Box>
); );
} }

View File

@@ -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] }
}
}
}
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;