- Added DateRangePicker component for selecting date ranges in OrderAssignment. - Updated OrderAssignment to fetch bookings based on selected date range. - Enhanced status handling in OrderAssignment with new status chip display logic. - Refactored KPI card implementation in RiderRoutes and Riders to use shared StatCard component. - Improved ProfileDrawer to retain rider data during close transition. - Fixed minor text formatting in Routing component.
1405 lines
55 KiB
JavaScript
1405 lines
55 KiB
JavaScript
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
|
import {
|
|
Box, Typography, Card, CardContent, Avatar, Chip, Table, TableBody,
|
|
TableCell, TableContainer, TableHead, TableRow, IconButton, Button,
|
|
Stack, Tooltip, LinearProgress, Dialog, DialogContent, DialogActions, Drawer,
|
|
TextField, MenuItem, Divider, InputAdornment, Menu, ListItemIcon,
|
|
Snackbar, Alert, useMediaQuery, Grid, Select, FormControl, InputLabel,
|
|
Autocomplete, Switch, FormControlLabel, Paper, ToggleButtonGroup,
|
|
ToggleButton, Stepper, Step, StepLabel, CircularProgress
|
|
} from '@mui/material';
|
|
import { useTheme, alpha } from '@mui/material/styles';
|
|
|
|
// ── Professional outlined icon set ──────────────────────────────────────────────
|
|
import AddOutlinedIcon from '@mui/icons-material/AddOutlined';
|
|
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
|
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
|
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
|
import MoreVertOutlinedIcon from '@mui/icons-material/MoreVertOutlined';
|
|
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
|
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
import CircleRoundedIcon from '@mui/icons-material/CircleRounded';
|
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
|
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
|
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
|
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
|
import PersonAddAlt1OutlinedIcon from '@mui/icons-material/PersonAddAlt1Outlined';
|
|
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
|
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
|
import ElectricBikeOutlinedIcon from '@mui/icons-material/ElectricBikeOutlined';
|
|
import PedalBikeOutlinedIcon from '@mui/icons-material/PedalBikeOutlined';
|
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
|
import AirportShuttleOutlinedIcon from '@mui/icons-material/AirportShuttleOutlined';
|
|
import InventoryOutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
|
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
|
|
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
|
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
|
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
|
import GppGoodOutlinedIcon from '@mui/icons-material/GppGoodOutlined';
|
|
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
|
import TableRowsOutlinedIcon from '@mui/icons-material/TableRowsOutlined';
|
|
import GridViewOutlinedIcon from '@mui/icons-material/GridViewOutlined';
|
|
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
|
import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
|
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
|
|
|
import StatCard from '@/components/StatCard';
|
|
import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub';
|
|
import { getHubContext } from '@/auth/session';
|
|
|
|
// The backend uses snake-case availability values; this page uses friendlier labels.
|
|
const API_TO_UI_STATUS = { Available: 'Idle', Assigned: 'On Pickup', On_Break: 'On Break', Offline: 'Offline' };
|
|
const UI_TO_API_STATUS = {
|
|
Idle: 'Available',
|
|
'On Pickup': 'Assigned',
|
|
'On Break': 'On_Break',
|
|
'Returning to Hub': 'Assigned',
|
|
Offline: 'Offline',
|
|
Suspended: 'Offline'
|
|
};
|
|
|
|
// ── Reference data ──────────────────────────────────────────────────────────────
|
|
const VEHICLES = {
|
|
'Electric Bike': { capacity: 30, icon: ElectricBikeOutlinedIcon },
|
|
'Motorcycle': { capacity: 25, icon: TwoWheelerOutlinedIcon },
|
|
'Cycle': { capacity: 15, icon: PedalBikeOutlinedIcon },
|
|
'Cargo Van': { capacity: 120, icon: AirportShuttleOutlinedIcon },
|
|
'Mini Truck': { capacity: 200, icon: LocalShippingOutlinedIcon },
|
|
};
|
|
const VEHICLE_TYPES = Object.keys(VEHICLES);
|
|
|
|
const ALL_ZONES = ['Dwarka', 'Janakpuri', 'Saket', 'Malviya Nagar', 'Rohini', 'Vasant Kunj', 'Central Delhi', 'Lajpat Nagar', 'Karol Bagh', 'Connaught Place', 'Mayur Vihar'];
|
|
const HUB_OPTIONS = ['Delhi Operations Hub', 'Mumbai Hub (BOM-02)', 'Bengaluru Hub (BLR-03)', 'Jaipur Hub (JAI-08)'];
|
|
const STATUS_OPTIONS = ['On Pickup', 'Idle', 'On Break', 'Returning to Hub', 'Offline', 'Suspended'];
|
|
|
|
const STATUS_META = {
|
|
'On Pickup': { color: '#1A73E8', bg: '#E8F0FE', label: 'On Pickup' },
|
|
'Idle': { color: '#B06000', bg: '#FEF7E0', label: 'Idle / Available' },
|
|
'On Break': { color: '#8E24AA', bg: '#F3E5F5', label: 'On Break' },
|
|
'Returning to Hub': { color: '#0B8043', bg: '#E6F4EA', label: 'Returning' },
|
|
'Offline': { color: '#80868B', bg: '#F1F3F4', label: 'Offline' },
|
|
'Suspended': { color: '#D93025', bg: '#FCE8E6', label: 'Suspended' },
|
|
};
|
|
|
|
let idSeq = 8060;
|
|
const genId = () => `RDR-${idSeq++}`;
|
|
|
|
// Turn a vehicle type name into the 1-based id the backend uses (best effort).
|
|
const vehicleIdFor = (vehicle) => Math.max(1, VEHICLE_TYPES.indexOf(vehicle) + 1);
|
|
|
|
// Map a raw API miler onto the rich shape this page renders. Fields the API does
|
|
// not provide (zones, COD, live load) default to empty/zero so the UI still works.
|
|
// Format an ISO check-in timestamp as HH:MM (or "—").
|
|
const checkInLabel = (iso) => {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return '—';
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const mapMiler = (m, hubName) => {
|
|
// Real backend exposes defaultvehicletype (e.g. "Bike"); fall back to id lookup.
|
|
const vehicle = m.defaultvehicletype || VEHICLE_TYPES[(m.vehicleid || 1) - 1] || 'Motorcycle';
|
|
const capacity = m.capacity || VEHICLES[vehicle]?.capacity || 0;
|
|
return {
|
|
id: m.userid,
|
|
userid: m.userid,
|
|
vehicleid: m.vehicleid,
|
|
hubid: m.hubid,
|
|
name: m.displayname || `Miler ${m.userid}`,
|
|
phone: m.phone || '—',
|
|
hub: hubName,
|
|
zones: Array.isArray(m.zones) ? m.zones : m.currentpincode ? [m.currentpincode] : [],
|
|
vehicle,
|
|
// Real registration plate from the joined vehicles table; fall back to a
|
|
// placeholder tag if the miler has no vehicle assigned.
|
|
vehicleNo: m.vehicleno || (m.vehicleid ? `VEH-${m.vehicleid}` : '—'),
|
|
status: API_TO_UI_STATUS[m.availabilitystatus] || 'Idle',
|
|
checkInTime: checkInLabel(m.checkinat),
|
|
hoursToday: m.hoursactive ?? 0,
|
|
assigned: m.assignedload ?? 0,
|
|
capacity,
|
|
pickupsPending: m.pickupspending ?? 0,
|
|
deliveriesPending: m.assignedload ?? 0,
|
|
deliveriesDone: m.totalcompletedpickups ?? m.completedorders ?? 0,
|
|
deliveriesFailed: m.totalcancelledpickups ?? m.cancelledorders ?? 0,
|
|
codCollected: m.codcollected ?? 0,
|
|
codPending: m.codpending ?? 0,
|
|
rating: m.rating ?? 0,
|
|
verified: Boolean(m.isverified ?? m.device_token)
|
|
};
|
|
};
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
const successRate = (r) => {
|
|
const total = r.deliveriesDone + r.deliveriesFailed;
|
|
return total === 0 ? 100 : Math.round((r.deliveriesDone / total) * 100);
|
|
};
|
|
|
|
const loadPct = (r) => (r.capacity === 0 ? 0 : Math.round((r.assigned / r.capacity) * 100));
|
|
const initials = (name) => name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
|
const inr = (n) => `₹${n.toLocaleString('en-IN')}`;
|
|
|
|
const EMPTY_FORM = {
|
|
name: '', phone: '', hub: 'Delhi Operations Hub', zones: [],
|
|
vehicle: '', vehicleNo: '', status: 'Idle', verified: false,
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// Presentational Components (Improved spacing & visuals)
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
|
|
function StatusPill({ status, size = 'md' }) {
|
|
const m = STATUS_META[status] || STATUS_META['Offline'];
|
|
const small = size === 'sm';
|
|
return (
|
|
<Box sx={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 0.75,
|
|
px: small ? 1.5 : 2, py: small ? 0.5 : 0.75, borderRadius: 2,
|
|
bgcolor: m.bg, border: `1px solid ${alpha(m.color, 0.2)}`, whiteSpace: 'nowrap',
|
|
}}>
|
|
<CircleRoundedIcon sx={{ fontSize: small ? 8 : 9, color: m.color }} />
|
|
<Typography sx={{ fontSize: small ? '0.7rem' : '0.75rem', fontWeight: 700, color: m.color, lineHeight: 1 }}>
|
|
{m.label}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function CapacityBar({ rider, showLabel = true }) {
|
|
const pct = loadPct(rider);
|
|
const color = pct > 85 ? '#D93025' : pct > 60 ? '#F29900' : '#1A73E8';
|
|
return (
|
|
<Box sx={{ minWidth: 140 }}>
|
|
{showLabel && (
|
|
<Typography variant="caption" sx={{ display: 'block', mb: 0.8 }}>
|
|
<Box component="span" sx={{ fontWeight: 700, color: '#343A40' }}>{rider.assigned}/{rider.capacity}</Box>
|
|
<Box component="span" sx={{ fontWeight: 700, color, ml: 0.75 }}>· {pct}%</Box>
|
|
</Typography>
|
|
)}
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={pct}
|
|
sx={{
|
|
height: 6,
|
|
borderRadius: 2,
|
|
bgcolor: '#EDEFF2',
|
|
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 2 }
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function RiderAvatar({ rider, size = 48 }) {
|
|
const m = STATUS_META[rider.status] || STATUS_META['Offline'];
|
|
return (
|
|
<Box sx={{ position: 'relative', flexShrink: 0 }}>
|
|
<Avatar sx={{
|
|
width: size, height: size, fontSize: size * 0.36, fontWeight: 800,
|
|
bgcolor: alpha(m.color, 0.1), color: m.color, border: `2px solid ${alpha(m.color, 0.2)}`,
|
|
}}>
|
|
{initials(rider.name)}
|
|
</Avatar>
|
|
<Box sx={{
|
|
position: 'absolute', bottom: -2, right: -2, width: size * 0.32, height: size * 0.32,
|
|
borderRadius: '50%', bgcolor: m.color, border: '2.5px solid #fff',
|
|
}} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function VehicleCell({ vehicle, vehicleNo }) {
|
|
const Icon = VEHICLES[vehicle]?.icon || LocalShippingOutlinedIcon;
|
|
return (
|
|
<Stack direction="row" alignItems="center" gap={1.5}>
|
|
<Avatar variant="rounded" sx={{ width: 36, height: 36, bgcolor: '#F8F9FA', color: '#5F6368' }}>
|
|
<Icon sx={{ fontSize: 20 }} />
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{vehicle}</Typography>
|
|
<Typography variant="caption" sx={{ color: '#9AA0A6', fontFamily: 'monospace' }}>{vehicleNo}</Typography>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// KPI card — the shared StatCard is the single reference design (see components/StatCard).
|
|
// Kept as a thin alias so the call sites below stay unchanged; `hover` on for the lift effect.
|
|
const KpiCard = (props) => <StatCard {...props} hover />;
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// Dialogs (Cleaner & Better Spaced)
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
|
|
const STEPS = ['Personal', 'Assignment', 'Vehicle & KYC'];
|
|
|
|
function RiderFormDialog({ open, onClose, onSave, initial, mode }) {
|
|
const theme = useTheme();
|
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
|
const [activeStep, setActiveStep] = useState(0);
|
|
const [form, setForm] = useState(initial || EMPTY_FORM);
|
|
const [errors, setErrors] = useState({});
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setForm(initial || EMPTY_FORM);
|
|
setErrors({});
|
|
setActiveStep(0);
|
|
}
|
|
}, [open, initial]);
|
|
|
|
const set = (k, v) => {
|
|
setForm(p => ({ ...p, [k]: v }));
|
|
setErrors(e => ({ ...e, [k]: undefined }));
|
|
};
|
|
|
|
const validateStep = (step) => {
|
|
const e = {};
|
|
if (step === 0) {
|
|
if (!form.name.trim()) e.name = 'Full name is required';
|
|
if (!form.phone.trim()) e.phone = 'Phone number is required';
|
|
else if (!/[0-9]{10}/.test(form.phone.replace(/\D/g, ''))) e.phone = 'Enter a valid 10-digit phone number';
|
|
}
|
|
if (step === 1) {
|
|
if (form.zones.length === 0) e.zones = 'Assign at least one service zone';
|
|
}
|
|
if (step === 2) {
|
|
if (!form.vehicle) e.vehicle = 'Select a vehicle type';
|
|
if (!form.vehicleNo.trim() && form.vehicle !== 'Cycle') e.vehicleNo = 'Vehicle number is required';
|
|
}
|
|
setErrors(e);
|
|
return Object.keys(e).length === 0;
|
|
};
|
|
|
|
const next = () => { if (validateStep(activeStep)) setActiveStep(s => s + 1); };
|
|
const back = () => setActiveStep(s => s - 1);
|
|
|
|
const submit = () => {
|
|
if (!validateStep(2)) return;
|
|
const cap = VEHICLES[form.vehicle]?.capacity || 30;
|
|
onSave({
|
|
...form,
|
|
vehicleNo: form.vehicleNo.trim() || '—',
|
|
id: mode === 'add' ? genId() : form.id,
|
|
capacity: cap,
|
|
checkInTime: mode === 'add' ? '—' : form.checkInTime,
|
|
hoursToday: mode === 'add' ? 0 : form.hoursToday,
|
|
assigned: mode === 'add' ? 0 : form.assigned,
|
|
pickupsPending: mode === 'add' ? 0 : form.pickupsPending,
|
|
deliveriesPending: mode === 'add' ? 0 : form.deliveriesPending,
|
|
deliveriesDone: mode === 'add' ? 0 : form.deliveriesDone,
|
|
deliveriesFailed: mode === 'add' ? 0 : form.deliveriesFailed,
|
|
codCollected: mode === 'add' ? 0 : form.codCollected,
|
|
codPending: mode === 'add' ? 0 : form.codPending,
|
|
rating: mode === 'add' ? 5.0 : form.rating,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
fullScreen={fullScreen}
|
|
PaperProps={{
|
|
sx: {
|
|
borderRadius: fullScreen ? 0 : 2,
|
|
boxShadow: '0 25px 70px rgba(0,0,0,0.18)'
|
|
}
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<Box sx={{ px: 4, pt: 4, pb: 3 }}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
|
<Stack direction="row" alignItems="center" gap={2}>
|
|
<Avatar variant="rounded" sx={{ bgcolor: alpha('#C01227', 0.1), color: '#C01227', width: 48, height: 48 }}>
|
|
<PersonAddAlt1OutlinedIcon />
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 800, color: '#1A1A2E' }}>
|
|
{mode === 'add' ? 'Onboard New Miler' : 'Edit Miler'}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{mode === 'add' ? 'Add delivery agent to the fleet' : `Updating ${form.name}`}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
<IconButton onClick={onClose}>
|
|
<CloseOutlinedIcon />
|
|
</IconButton>
|
|
</Stack>
|
|
|
|
<Stepper activeStep={activeStep} sx={{ mt: 4 }} alternativeLabel>
|
|
{STEPS.map(label => (
|
|
<Step key={label}>
|
|
<StepLabel sx={{ '& .MuiStepLabel-label': { fontWeight: 600 } }}>{label}</StepLabel>
|
|
</Step>
|
|
))}
|
|
</Stepper>
|
|
</Box>
|
|
|
|
<Divider />
|
|
|
|
<DialogContent sx={{ px: 4, py: 4 }}>
|
|
{/* STEP 0 */}
|
|
{activeStep === 0 && (
|
|
<Stack spacing={3}>
|
|
<TextField fullWidth label="Full Name" value={form.name} autoFocus
|
|
onChange={e => set('name', e.target.value)} error={!!errors.name} helperText={errors.name}
|
|
InputProps={{ startAdornment: <InputAdornment position="start"><PersonOutlineOutlinedIcon /></InputAdornment> }} />
|
|
|
|
<TextField fullWidth label="Phone Number" value={form.phone}
|
|
onChange={e => set('phone', e.target.value)} error={!!errors.phone} helperText={errors.phone}
|
|
placeholder="+91 XXXXXXXXXX" InputProps={{ startAdornment: <InputAdornment position="start"><PhoneOutlinedIcon /></InputAdornment> }} />
|
|
|
|
<FormControl fullWidth>
|
|
<InputLabel>Home Hub</InputLabel>
|
|
<Select value={form.hub} label="Home Hub" onChange={e => set('hub', e.target.value)}>
|
|
{HUB_OPTIONS.map(h => <MenuItem key={h} value={h}>{h}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* STEP 1 */}
|
|
{activeStep === 1 && (
|
|
<Stack spacing={3}>
|
|
<Autocomplete multiple value={form.zones} options={ALL_ZONES}
|
|
onChange={(_, v) => set('zones', v)}
|
|
renderInput={(params) => <TextField {...params} label="Service Zones" error={!!errors.zones} helperText={errors.zones} />}
|
|
renderTags={(value, getTagProps) => value.map((option, i) => <Chip {...getTagProps({ index: i })} label={option} color="primary" variant="outlined" />)}
|
|
/>
|
|
|
|
{mode === 'edit' && (
|
|
<FormControl fullWidth>
|
|
<InputLabel>Status</InputLabel>
|
|
<Select value={form.status} onChange={e => set('status', e.target.value)}>
|
|
{STATUS_OPTIONS.map(s => (
|
|
<MenuItem key={s} value={s}>
|
|
<StatusPill status={s} size="sm" />
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
|
|
{/* STEP 2 */}
|
|
{activeStep === 2 && (
|
|
<Stack spacing={3}>
|
|
<FormControl fullWidth error={!!errors.vehicle}>
|
|
<InputLabel>Vehicle Type</InputLabel>
|
|
<Select value={form.vehicle} label="Vehicle Type" onChange={e => set('vehicle', e.target.value)}>
|
|
{VEHICLE_TYPES.map(v => {
|
|
const IconComp = VEHICLES[v].icon;
|
|
return (
|
|
<MenuItem key={v} value={v}>
|
|
<Stack direction="row" gap={1.5} alignItems="center">
|
|
<IconComp />
|
|
<Box>
|
|
{v}
|
|
<Typography variant="caption" display="block">{VEHICLES[v].capacity} parcels</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
);
|
|
})}
|
|
</Select>
|
|
{errors.vehicle && <Typography variant="caption" color="error" sx={{ mt: 1 }}>{errors.vehicle}</Typography>}
|
|
</FormControl>
|
|
|
|
<TextField fullWidth label="Vehicle Registration No." value={form.vehicleNo}
|
|
onChange={e => set('vehicleNo', e.target.value.toUpperCase())} error={!!errors.vehicleNo}
|
|
helperText={errors.vehicleNo || (form.vehicle === 'Cycle' ? 'Optional for cycles' : '')}
|
|
placeholder="DL-00-AB-0000" sx={{ '& input': { fontFamily: 'monospace' } }} />
|
|
|
|
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
|
<FormControlLabel
|
|
control={<Switch checked={form.verified} onChange={e => set('verified', e.target.checked)} color="success" />}
|
|
label={
|
|
<Stack direction="row" gap={1.5} alignItems="center">
|
|
<GppGoodOutlinedIcon sx={{ color: form.verified ? '#1E8E3E' : '#9AA0A6' }} />
|
|
<Box>
|
|
<Typography fontWeight={600}>KYC Verified</Typography>
|
|
<Typography variant="caption" color="text.secondary">Driving licence & ID proof validated</Typography>
|
|
</Box>
|
|
</Stack>
|
|
}
|
|
/>
|
|
</Paper>
|
|
</Stack>
|
|
)}
|
|
</DialogContent>
|
|
|
|
<Divider />
|
|
<DialogActions sx={{ px: 4, py: 3 }}>
|
|
<Button onClick={onClose} sx={{ borderRadius: 2 }}>Cancel</Button>
|
|
<Box sx={{ flex: 1 }} />
|
|
{activeStep > 0 && <Button onClick={back} variant="outlined" sx={{ borderRadius: 2 }}>Back</Button>}
|
|
{activeStep < STEPS.length - 1 ? (
|
|
<Button onClick={next} variant="contained" sx={{ borderRadius: 2, bgcolor: '#C01227', '&:hover': { bgcolor: '#9E0E20' } }}>
|
|
Continue
|
|
</Button>
|
|
) : (
|
|
<Button onClick={submit} variant="contained" startIcon={<CheckCircleOutlinedIcon />} sx={{ borderRadius: 2, bgcolor: '#C01227', '&:hover': { bgcolor: '#9E0E20' } }}>
|
|
{mode === 'add' ? 'Add Miler' : 'Save Changes'}
|
|
</Button>
|
|
)}
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// Profile Drawer - view + inline edit, in the same right-side sheet
|
|
function ProfileDrawer({ rider: riderProp, onClose, onSave, startInEdit = false }) {
|
|
const [editing, setEditing] = useState(startInEdit);
|
|
// Retain the last opened miler so the sheet keeps rendering its content while it
|
|
// slides OUT — the prop becomes null on close, and unmounting here (an early
|
|
// `return null`) would kill the exit animation and make closing feel instant.
|
|
const [retained, setRetained] = useState(riderProp);
|
|
const [form, setForm] = useState(riderProp || {});
|
|
|
|
// Reset the editable copy + mode whenever a different miler is opened.
|
|
useEffect(() => {
|
|
if (riderProp) {
|
|
setRetained(riderProp);
|
|
setForm(riderProp);
|
|
setEditing(startInEdit);
|
|
}
|
|
}, [riderProp, startInEdit]);
|
|
|
|
const open = Boolean(riderProp); // drives the slide; false triggers the exit anim
|
|
const rider = riderProp || retained; // keep content during the close transition
|
|
// NOTE: we deliberately do NOT early-return when there's no rider. The Drawer stays
|
|
// mounted (open=false) from first render, so the first click is a real false→true
|
|
// transition and animates — otherwise the first open mounts already-open and skips it.
|
|
const sr = rider ? successRate(rider) : 0;
|
|
|
|
const setField = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
|
|
|
|
const cancelEdit = () => { setForm(rider); setEditing(false); };
|
|
const saveEdit = () => {
|
|
const updated = { ...rider, ...form, capacity: VEHICLES[form.vehicle]?.capacity ?? rider.capacity };
|
|
onSave(updated);
|
|
setEditing(false);
|
|
};
|
|
|
|
return (
|
|
<Drawer
|
|
anchor="right"
|
|
open={open}
|
|
onClose={onClose}
|
|
// Slow the slide a touch (default ~225ms feels abrupt) for a smoother open/close.
|
|
transitionDuration={{ enter: 400, exit: 340 }}
|
|
sx={{
|
|
// This build renders temporary drawers at zIndex.drawer (1200), which sits
|
|
// BELOW the app bar (drawer + 1). On mobile the sheet starts at top:0, so its
|
|
// Close button would hide under the app bar. Lift the whole modal above it.
|
|
zIndex: (t) => t.zIndex.modal,
|
|
'& .MuiDrawer-paper': {
|
|
width: { xs: '100%', sm: 520 },
|
|
maxWidth: '100%',
|
|
// Run the sheet the full height of the viewport so there is no empty
|
|
// strip above the red header (zIndex.modal keeps Close clickable).
|
|
top: 0,
|
|
height: '100%',
|
|
borderTopLeftRadius: { sm: 16 },
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
boxShadow: '-8px 0 30px rgba(0,0,0,0.12)',
|
|
},
|
|
}}
|
|
>
|
|
{rider && (<>
|
|
{/* Header */}
|
|
<Box sx={{ background: 'linear-gradient(135deg, #C01227 0%, #8D0E1D 100%)', px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}>
|
|
<Button onClick={onClose} startIcon={<ArrowBackRoundedIcon />} size="small"
|
|
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)' } }}>
|
|
Close
|
|
</Button>
|
|
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25,fontSize: '1.5rem' }}>{form.name || rider.name}</Typography>
|
|
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mb: 0.25 }}>{rider.id}</Typography>
|
|
<Stack direction="row" alignItems="center" spacing={0.75} useFlexGap sx={{ flexWrap: 'wrap', mt: 2 }}>
|
|
<Chip size="small" label={form.status || rider.status}
|
|
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
|
{rider.verified && (
|
|
<Chip size="small" icon={<VerifiedOutlinedIcon sx={{ color: '#fff !important' }} />} label="Verified"
|
|
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
|
)}
|
|
<Chip size="small" label={editing ? 'Editing…' : `${rider.rating} ★ · ${sr}%`}
|
|
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
|
</Stack>
|
|
|
|
</Box>
|
|
|
|
{/* Content */}
|
|
<Box sx={{ p: 2, bgcolor: '#F8F9FB', flex: 1, overflow: 'auto' }}>
|
|
{editing ? (
|
|
/* ── Inline edit form (same sheet) ── */
|
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}>
|
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 2.5, fontSize: '0.75rem' }}>
|
|
EDIT MILER DETAILS
|
|
</Typography>
|
|
<Stack spacing={2.5}>
|
|
<TextField label="Full name" value={form.name || ''} onChange={setField('name')} fullWidth size="small" />
|
|
<TextField label="Phone" value={form.phone || ''} onChange={setField('phone')} fullWidth size="small" />
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Status</InputLabel>
|
|
<Select label="Status" value={form.status || ''} onChange={setField('status')}>
|
|
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Vehicle</InputLabel>
|
|
<Select label="Vehicle" value={form.vehicle || ''} onChange={setField('vehicle')}>
|
|
{VEHICLE_TYPES.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<TextField label="Vehicle number" value={form.vehicleNo || ''} onChange={setField('vehicleNo')} fullWidth size="small" />
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Hub</InputLabel>
|
|
<Select label="Hub" value={form.hub || ''} onChange={setField('hub')}>
|
|
{HUB_OPTIONS.map((h) => <MenuItem key={h} value={h}>{h}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<Autocomplete
|
|
multiple
|
|
options={ALL_ZONES}
|
|
value={form.zones || []}
|
|
onChange={(_, v) => setForm((f) => ({ ...f, zones: v }))}
|
|
renderInput={(params) => <TextField {...params} label="Zones" size="small" placeholder="Add zone" />}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
) : (
|
|
<Grid container spacing={1.75}>
|
|
|
|
{/* Today's Performance */}
|
|
<Grid size={12}>
|
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 1.5, fontSize: '0.75rem' }}>
|
|
TODAY'S PERFORMANCE
|
|
</Typography>
|
|
<Grid container spacing={1.5}>
|
|
{[
|
|
{ label: "Picked up", value: rider.deliveriesDone, icon: CheckCircleOutlinedIcon, color: "#16A34A", bg: "#DCFCE7" },
|
|
{ label: "Failed", value: rider.deliveriesFailed, icon: WarningAmberOutlinedIcon, color: "#DC2626", bg: "#FEE2E2" },
|
|
{ label: "COP", value: inr(rider.codCollected), icon: PaymentsOutlinedIcon, color: "#D97706", bg: "#FEF3C7" },
|
|
].map((stat, i) => (
|
|
<Grid size={4} key={i}>
|
|
<StatCard icon={stat.icon} label={stat.label} value={stat.value} color={stat.color} bg={stat.bg} />
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* Live Load Panel */}
|
|
<Grid size={12}>
|
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}>
|
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 1.75, fontSize: '0.75rem' }}>
|
|
LIVE LOAD
|
|
</Typography>
|
|
<CapacityBar rider={rider} showLabel />
|
|
<Divider sx={{ my: 2 }} />
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ gap: 2, width: '100%' }}>
|
|
<Typography variant="body2" sx={{ color: '#6C757D', fontWeight: 600 }}>Pending pickups</Typography>
|
|
<Typography sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1E293B', lineHeight: 1 }}>{rider.pickupsPending}</Typography>
|
|
</Stack>
|
|
</Paper>
|
|
</Grid>
|
|
|
|
{/* Miler Details Panel */}
|
|
<Grid size={12}>
|
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}>
|
|
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 2, fontSize: '0.75rem' }}>
|
|
MILER DETAILS
|
|
</Typography>
|
|
<Grid container spacing={2.5}>
|
|
{[
|
|
{ icon: PhoneOutlinedIcon, label: "Phone", value: rider.phone },
|
|
{ icon: HomeWorkOutlinedIcon, label: "Hub", value: rider.hub },
|
|
{ icon: PlaceOutlinedIcon, label: "Zones", value: rider.zones?.join(', ') || 'N/A' },
|
|
{ icon: BadgeOutlinedIcon, label: "Vehicle", value: `${rider.vehicle} • ${rider.vehicleNo}` },
|
|
{ icon: AccessTimeOutlinedIcon, label: "Check-in", value: `${rider.checkInTime} (${rider.hoursToday}h)` },
|
|
].map((detail, idx) => (
|
|
<Grid size={6} key={idx}>
|
|
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
|
<detail.icon sx={{ color: '#94A3B8', fontSize: 20, mt: '2px' }} />
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="caption" sx={{ color: '#6C757D', display: 'block', fontSize: '0.72rem', lineHeight: 1.3 }}>{detail.label}</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155', display: 'block', wordBreak: 'break-word' }}>{detail.value}</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
</Paper>
|
|
</Grid>
|
|
|
|
</Grid>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Footer */}
|
|
<Box sx={{ p: 2.5, borderTop: '1px solid #E9ECEF', bgcolor: '#fff', display: 'flex', gap: 1.5, justifyContent: 'flex-end', flexShrink: 0 }}>
|
|
{editing ? (
|
|
<>
|
|
<Button fullWidth variant="outlined" onClick={cancelEdit} sx={{ borderRadius: 2 }}>
|
|
Cancel
|
|
</Button>
|
|
<Button fullWidth variant="contained" startIcon={<CheckCircleOutlinedIcon />} onClick={saveEdit} sx={{ borderRadius: 2, bgcolor: '#C01227', '&:hover': { bgcolor: '#9E0E1F' } }}>
|
|
Save Changes
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button fullWidth variant="outlined" startIcon={<CallOutlinedIcon />} href={`tel:${rider.phone}`} sx={{ borderRadius: 2 }}>
|
|
Call Miler
|
|
</Button>
|
|
<Button fullWidth variant="contained" startIcon={<EditOutlinedIcon />} onClick={() => setEditing(true)} sx={{ borderRadius: 2, bgcolor: '#C01227', '&:hover': { bgcolor: '#9E0E1F' } }}>
|
|
Edit Details
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</>)}
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
// Delete Dialog
|
|
function DeleteDialog({ rider, onClose, onConfirm }) {
|
|
return (
|
|
<Dialog open={!!rider} onClose={onClose} maxWidth="xs" fullWidth>
|
|
<DialogContent sx={{ textAlign: 'center', pt: 5, pb: 3 }}>
|
|
<Avatar sx={{ mx: 'auto', bgcolor: '#FCE8E6', width: 70, height: 70 }}>
|
|
<DeleteOutlineIcon sx={{ color: '#D93025', fontSize: 36 }} />
|
|
</Avatar>
|
|
<Typography variant="h5" fontWeight={700} mt={3}>Remove Miler?</Typography>
|
|
<Typography color="text.secondary" sx={{ mt: 1 }}>
|
|
{rider?.name} will be permanently removed from the roster.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 4, pb: 4, justifyContent: 'center', gap: 2 }}>
|
|
<Button onClick={onClose} variant="outlined" sx={{ borderRadius: 2 }}>Cancel</Button>
|
|
<Button onClick={onConfirm} variant="contained" color="error" sx={{ borderRadius: 2 }}>Yes, Remove</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// Miler Card (Grid)
|
|
function RiderCard({ rider, onView, onMenu }) {
|
|
const pct = loadPct(rider);
|
|
const pctColor =
|
|
pct > 85 ? "#D93025" : pct > 60 ? "#F29900" : "#1A73E8";
|
|
|
|
return (
|
|
<Card
|
|
elevation={0}
|
|
onClick={() => onView(rider)}
|
|
sx={{
|
|
height: "100%",
|
|
borderRadius: 2,
|
|
border: "1px solid #E8EAED",
|
|
cursor: "pointer",
|
|
transition: "all .25s ease",
|
|
bgcolor: "#fff",
|
|
"&:hover": {
|
|
transform: "translateY(-4px)",
|
|
borderColor: "#C01227",
|
|
boxShadow: "0 16px 40px rgba(0,0,0,.08)",
|
|
},
|
|
}}
|
|
>
|
|
<CardContent
|
|
sx={{
|
|
p: 4,
|
|
"&:last-child": {
|
|
pb: 4,
|
|
},
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<Stack
|
|
direction="row"
|
|
spacing={2.5}
|
|
alignItems="flex-start"
|
|
>
|
|
<RiderAvatar rider={rider} size={54} />
|
|
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Stack
|
|
direction="row"
|
|
spacing={0.75}
|
|
alignItems="center"
|
|
>
|
|
<Typography
|
|
fontWeight={700}
|
|
fontSize={18}
|
|
noWrap
|
|
>
|
|
{rider.name}
|
|
</Typography>
|
|
|
|
{rider.verified && (
|
|
<VerifiedOutlinedIcon
|
|
sx={{
|
|
fontSize: 18,
|
|
color: "#1A73E8",
|
|
}}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
|
|
<Typography
|
|
sx={{
|
|
mt: 0.5,
|
|
fontSize: 13,
|
|
color: "#9AA0A6",
|
|
fontFamily: "monospace",
|
|
letterSpacing: ".5px",
|
|
}}
|
|
>
|
|
{rider.id}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onMenu(e, rider);
|
|
}}
|
|
>
|
|
<MoreVertOutlinedIcon />
|
|
</IconButton>
|
|
</Stack>
|
|
|
|
{/* Status */}
|
|
<Box sx={{ mt: 3 }}>
|
|
<StatusPill status={rider.status} size="sm" />
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 3 }} />
|
|
|
|
{/* Load */}
|
|
<Box>
|
|
<Stack
|
|
direction="row"
|
|
justifyContent="space-between"
|
|
alignItems="center"
|
|
sx={{ mb: 1.5 }}
|
|
>
|
|
<Typography
|
|
fontSize={13}
|
|
fontWeight={600}
|
|
color="text.secondary"
|
|
>
|
|
Today's Load
|
|
</Typography>
|
|
|
|
<Typography
|
|
fontWeight={700}
|
|
fontSize={14}
|
|
>
|
|
{rider.assigned}/{rider.capacity}
|
|
|
|
<Box
|
|
component="span"
|
|
sx={{
|
|
color: pctColor,
|
|
ml: 1,
|
|
}}
|
|
>
|
|
{pct}%
|
|
</Box>
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={pct}
|
|
sx={{
|
|
height: 8,
|
|
borderRadius: 2,
|
|
bgcolor: "#ECEFF3",
|
|
|
|
"& .MuiLinearProgress-bar": {
|
|
bgcolor: pctColor,
|
|
borderRadius: 2,
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Zones */}
|
|
<Box sx={{ mt: 3.5 }}>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
display: "block",
|
|
color: "#6B7280",
|
|
fontWeight: 600,
|
|
mb: 1.5,
|
|
letterSpacing: ".3px",
|
|
}}
|
|
>
|
|
SERVICE ZONES
|
|
</Typography>
|
|
|
|
<Stack
|
|
direction="row"
|
|
spacing={1}
|
|
useFlexGap
|
|
sx={{ flexWrap: 'wrap' }}
|
|
>
|
|
{rider.zones.slice(0, 3).map((z) => (
|
|
<Chip
|
|
key={z}
|
|
label={z}
|
|
size="small"
|
|
sx={{
|
|
bgcolor: "#F4F6F8",
|
|
color: "#495057",
|
|
borderRadius: 2,
|
|
fontWeight: 600,
|
|
px: 0.5,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{rider.zones.length > 3 && (
|
|
<Chip
|
|
label={`+${rider.zones.length - 3}`}
|
|
size="small"
|
|
sx={{
|
|
bgcolor: "#EEF2FF",
|
|
color: "#4F46E5",
|
|
borderRadius: 2,
|
|
fontWeight: 700,
|
|
}}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// MAIN COMPONENT
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
export default function Riders() {
|
|
const theme = useTheme();
|
|
const isMdDown = useMediaQuery(theme.breakpoints.down('md'));
|
|
const isLgDown = useMediaQuery(theme.breakpoints.down('lg'));
|
|
|
|
const hub = getHubContext();
|
|
const hubName = hub.hubname || 'This Hub';
|
|
|
|
const [riders, setRiders] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('All');
|
|
const [vehicleFilter, setVehicleFilter] = useState('All');
|
|
const [view, setView] = useState('table');
|
|
|
|
const loadMilers = useCallback(async () => {
|
|
setLoading(true);
|
|
setLoadError('');
|
|
try {
|
|
const res = await getMilers();
|
|
setRiders((res?.data || []).map((m) => mapMiler(m, hubName)));
|
|
} catch (err) {
|
|
setLoadError(err?.message || 'Could not load milers.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadMilers();
|
|
}, [loadMilers]);
|
|
|
|
const [formDialog, setFormDialog] = useState({ open: false, mode: 'add', initial: null });
|
|
const [profile, setProfile] = useState(null);
|
|
const [profileEdit, setProfileEdit] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
|
const [menuRider, setMenuRider] = useState(null);
|
|
const [snack, setSnack] = useState({ open: false, msg: '', severity: 'success' });
|
|
|
|
const toast = (msg, severity = 'success') => setSnack({ open: true, msg, severity });
|
|
|
|
const effectiveView = isLgDown ? 'grid' : view;
|
|
|
|
const kpi = useMemo(() => {
|
|
const onDuty = riders.filter(r => !['Offline', 'Suspended'].includes(r.status)).length;
|
|
const onField = riders.filter(r => ['On Pickup', 'Returning to Hub'].includes(r.status)).length;
|
|
const idle = riders.filter(r => r.status === 'Idle').length;
|
|
const slaRisk = riders.filter(r => loadPct(r) > 85 || r.deliveriesFailed >= 5).length;
|
|
const codToCollect = riders.reduce((s, r) => s + r.codPending, 0);
|
|
return { onDuty, onField, idle, slaRisk, codToCollect, total: riders.length };
|
|
}, [riders]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return riders.filter(r => {
|
|
const matchSearch = !q ||
|
|
r.name.toLowerCase().includes(q) ||
|
|
r.id.toLowerCase().includes(q) ||
|
|
r.phone.includes(q) ||
|
|
r.zones.some(z => z.toLowerCase().includes(q));
|
|
const matchStatus = statusFilter === 'All' || r.status === statusFilter;
|
|
const matchVehicle = vehicleFilter === 'All' || r.vehicle === vehicleFilter;
|
|
return matchSearch && matchStatus && matchVehicle;
|
|
});
|
|
}, [riders, search, statusFilter, vehicleFilter]);
|
|
|
|
// Build the API payload from the rich form object.
|
|
const toApiPayload = (rider) => ({
|
|
displayname: rider.name,
|
|
phone: rider.phone,
|
|
hubid: rider.hubid || hub.hubid,
|
|
vehicleid: rider.vehicleid || vehicleIdFor(rider.vehicle),
|
|
availabilitystatus: UI_TO_API_STATUS[rider.status] || 'Available'
|
|
});
|
|
|
|
const handleSave = async (rider) => {
|
|
try {
|
|
if (formDialog.mode === 'add') {
|
|
await createMiler(toApiPayload(rider));
|
|
toast(`${rider.name} onboarded successfully`);
|
|
} else {
|
|
await updateMiler(rider.userid ?? rider.id, toApiPayload(rider));
|
|
toast(`${rider.name} updated`);
|
|
}
|
|
setFormDialog({ open: false, mode: 'add', initial: null });
|
|
loadMilers();
|
|
} catch (err) {
|
|
toast(err?.message || 'Could not save this miler.', 'error');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
const target = deleteTarget;
|
|
try {
|
|
await deleteMiler(target.userid ?? target.id);
|
|
setRiders((p) => p.filter((r) => r.id !== target.id));
|
|
toast(`${target.name} removed`, 'info');
|
|
} catch (err) {
|
|
toast(err?.message || 'Could not remove this miler.', 'error');
|
|
} finally {
|
|
setDeleteTarget(null);
|
|
setProfile(null);
|
|
}
|
|
};
|
|
|
|
const openAdd = () => setFormDialog({ open: true, mode: 'add', initial: null });
|
|
// Edit now opens the same right-side sheet in edit mode (no separate modal).
|
|
const openEdit = (r) => {
|
|
setMenuAnchor(null);
|
|
setProfile(r);
|
|
setProfileEdit(true);
|
|
};
|
|
const openView = (r) => {
|
|
setMenuAnchor(null);
|
|
setProfile(r);
|
|
setProfileEdit(false);
|
|
};
|
|
const saveProfileEdit = async (updated) => {
|
|
try {
|
|
await updateMiler(updated.userid ?? updated.id, toApiPayload(updated));
|
|
setRiders((p) => p.map((r) => (r.id === updated.id ? updated : r)));
|
|
setProfile(updated);
|
|
setProfileEdit(false);
|
|
toast(`${updated.name} updated`);
|
|
} catch (err) {
|
|
toast(err?.message || 'Could not update this miler.', 'error');
|
|
}
|
|
};
|
|
const openMenu = (e, r) => {
|
|
setMenuAnchor(e.currentTarget);
|
|
setMenuRider(r);
|
|
};
|
|
|
|
const STATUS_CHIPS = ['All', 'On Pickup', 'Idle', 'On Break', 'Offline', 'Suspended'];
|
|
|
|
return (
|
|
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}>
|
|
{/* Header */}
|
|
{/* ── Improved Header with better gaps ── */}
|
|
<Stack
|
|
direction={{ xs: 'column', md: 'row' }}
|
|
justifyContent="space-between"
|
|
alignItems={{ xs: 'stretch', md: 'center' }}
|
|
gap={{ xs: 2.5, md: 3 }}
|
|
mb={4}
|
|
>
|
|
{/* Title & Avatar Branding Group */}
|
|
<Stack
|
|
direction="row"
|
|
alignItems="center"
|
|
spacing={5} // Increase this value (5 = 40px)
|
|
sx={{ minWidth: 0 }}
|
|
>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha('#C01227', 0.1),
|
|
color: '#C01227',
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 2,
|
|
flexShrink: 0
|
|
}}
|
|
>
|
|
<GroupsOutlinedIcon sx={{ fontSize: 30 }} />
|
|
</Avatar>
|
|
|
|
<Box
|
|
sx={{
|
|
minWidth: 0,
|
|
ml: 1 // Additional left margin for extra spacing
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="h4"
|
|
sx={{
|
|
fontWeight: 800,
|
|
letterSpacing: '-0.4px',
|
|
lineHeight: 1.2,
|
|
mb: 1,
|
|
fontSize: {
|
|
xs: '1.6rem',
|
|
sm: '2rem'
|
|
}
|
|
}}
|
|
>
|
|
Milers
|
|
</Typography>
|
|
|
|
<Typography
|
|
variant="body1"
|
|
color="text.secondary"
|
|
sx={{
|
|
fontWeight: 500,
|
|
lineHeight: 1.6
|
|
}}
|
|
>
|
|
{kpi.total} milers • who's working and where
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
|
|
{/* Actions Group - pushed to the far right with clear spacing between buttons */}
|
|
<Stack
|
|
direction={{ xs: "column", sm: "row" }}
|
|
spacing={2.5}
|
|
alignItems="center"
|
|
justifyContent="flex-end"
|
|
sx={{
|
|
flexShrink: 0,
|
|
ml: { md: "auto" },
|
|
}}
|
|
>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<DownloadOutlinedIcon />}
|
|
onClick={() => toast("Exported as CSV")}
|
|
sx={{
|
|
width: { xs: "100%", sm: "auto" },
|
|
borderRadius: 2,
|
|
px: 3,
|
|
height: 48,
|
|
textTransform: "none",
|
|
fontWeight: 600,
|
|
mr: { sm: 1 }, // Extra gap on desktop
|
|
}}
|
|
>
|
|
Export Roster
|
|
</Button>
|
|
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<AddOutlinedIcon />}
|
|
onClick={openAdd}
|
|
sx={{
|
|
width: { xs: "100%", sm: "auto" },
|
|
borderRadius: 2,
|
|
bgcolor: "#C01227",
|
|
px: 3.5,
|
|
height: 48,
|
|
textTransform: "none",
|
|
fontWeight: 600,
|
|
boxShadow: "none",
|
|
"&:hover": {
|
|
bgcolor: "#9E0E1F",
|
|
boxShadow: "none",
|
|
},
|
|
}}
|
|
>
|
|
Add New Miler
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* KPIs */}
|
|
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mb: 3 }}>
|
|
{[
|
|
{ icon: GroupsOutlinedIcon, label: 'On Duty', value: kpi.onDuty, sub: `of ${kpi.total}`, color: '#1A73E8', bg: '#E8F0FE', trend: true },
|
|
{ icon: RouteOutlinedIcon, label: 'In Field', value: kpi.onField, sub: 'Active pickups', color: '#1E8E3E', bg: '#E6F4EA' },
|
|
{ icon: AccessTimeOutlinedIcon, label: 'Available', value: kpi.idle, sub: 'Ready for assignment', color: '#B06000', bg: '#FEF7E0' },
|
|
{ icon: WarningAmberOutlinedIcon, label: 'SLA Risk', value: kpi.slaRisk, sub: 'Overloaded milers', color: '#D93025', bg: '#FCE8E6' },
|
|
{ icon: PaymentsOutlinedIcon, label: 'COP Pending', value: inr(kpi.codToCollect), sub: 'To be collected', color: '#8E24AA', bg: '#F3E5F5' },
|
|
].map((k, i) => (
|
|
<Grid size={{ xs: 6, md: 2.4 }} key={i}>
|
|
<KpiCard {...k} />
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* Main Card */}
|
|
<Card sx={{ borderRadius: 2, border: '1px solid #ECEEF1', overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
|
{/* Toolbar */}
|
|
<Box sx={{ p: { xs: 2.5, md: 2 }, borderBottom: '1px solid #F1F3F5' } } >
|
|
<Stack
|
|
direction={{ xs: "column", lg: "row" }}
|
|
spacing={1}
|
|
alignItems="center"
|
|
sx={{ mb: 2 }}
|
|
>
|
|
<TextField
|
|
fullWidth
|
|
size="medium"
|
|
placeholder="Search milers by name, ID, phone or zone..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchOutlinedIcon />
|
|
</InputAdornment>
|
|
),
|
|
sx: { borderRadius: 2 },
|
|
}}
|
|
sx={{
|
|
maxWidth: { lg: 420 },
|
|
}}
|
|
/>
|
|
|
|
<FormControl
|
|
sx={{
|
|
minWidth: 220,
|
|
}}
|
|
>
|
|
<Select
|
|
value={vehicleFilter}
|
|
onChange={(e) => setVehicleFilter(e.target.value)}
|
|
sx={{ borderRadius: 2 }}
|
|
>
|
|
<MenuItem value="All">All Vehicles</MenuItem>
|
|
{VEHICLE_TYPES.map((v) => (
|
|
<MenuItem key={v} value={v}>
|
|
{v}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Box flex={1} />
|
|
|
|
{!isLgDown && (
|
|
<ToggleButtonGroup
|
|
exclusive
|
|
value={view}
|
|
onChange={(_, v) => v && setView(v)}
|
|
sx={{ borderRadius: 2 }}
|
|
>
|
|
<ToggleButton value="table">
|
|
<TableRowsOutlinedIcon />
|
|
</ToggleButton>
|
|
<ToggleButton value="grid">
|
|
<GridViewOutlinedIcon />
|
|
</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Status Chips */}
|
|
<Stack direction="row" sx={{ flexWrap: 'wrap', columnGap: 1.25, rowGap: 1.5, mt: 4 }}>
|
|
{STATUS_CHIPS.map(s => {
|
|
const active = statusFilter === s;
|
|
const meta = STATUS_META[s] || {};
|
|
// Each status carries its own colour; "All" falls back to a neutral dark.
|
|
const accent = meta.color || '#1A1A2E';
|
|
const tint = meta.bg || '#F8F9FB';
|
|
return (
|
|
<Chip
|
|
key={s}
|
|
label={s}
|
|
clickable
|
|
onClick={() => setStatusFilter(s)}
|
|
icon={meta.color ? <CircleRoundedIcon sx={{ fontSize: 10, color: active ? '#fff' : meta.color }} /> : undefined}
|
|
sx={{
|
|
height: 38,
|
|
px: 1,
|
|
borderRadius: 2,
|
|
fontSize: '0.82rem',
|
|
fontWeight: active ? 700 : 600,
|
|
bgcolor: active ? accent : tint,
|
|
color: active ? '#fff' : accent,
|
|
border: active ? 'none' : `1px solid ${alpha(accent, 0.25)}`,
|
|
'& .MuiChip-icon': { color: active ? '#fff' : meta.color },
|
|
'& .MuiChip-label': { px: 1.25 },
|
|
'&:hover': { bgcolor: active ? accent : alpha(accent, 0.14) }
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Box>
|
|
|
|
{/* Content Area */}
|
|
{loadError && (
|
|
<Alert severity="error" onClose={() => setLoadError('')} sx={{ m: 2, borderRadius: 2 }}>
|
|
{loadError}
|
|
</Alert>
|
|
)}
|
|
{loading ? (
|
|
<Box sx={{ py: 12, display: 'flex', justifyContent: 'center' }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : filtered.length === 0 ? (
|
|
<Box sx={{ py: 12, textAlign: 'center' }}>
|
|
<SearchOutlinedIcon sx={{ fontSize: 80, color: '#E0E0E0', mb: 3 }} />
|
|
<Typography variant="h5" fontWeight={600}>No milers match your filters</Typography>
|
|
<Button onClick={() => { setSearch(''); setStatusFilter('All'); setVehicleFilter('All'); }} sx={{ mt: 3 }} variant="outlined">
|
|
Clear all filters
|
|
</Button>
|
|
</Box>
|
|
) : effectiveView === 'table' ? (
|
|
<TableContainer>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow sx={{ bgcolor: '#FAFBFC' }}>
|
|
<TableCell sx={{ fontWeight: 700, color: '#6C757D' }}>Miler</TableCell>
|
|
<TableCell sx={{ fontWeight: 700, color: '#6C757D' }}>Status</TableCell>
|
|
<TableCell sx={{ fontWeight: 700, color: '#6C757D' }}>Zones</TableCell>
|
|
<TableCell sx={{ fontWeight: 700, color: '#6C757D' }}>Vehicle</TableCell>
|
|
<TableCell sx={{ fontWeight: 700, color: '#6C757D' }}>Load</TableCell>
|
|
<TableCell sx={{ fontWeight: 700, color: '#6C757D' }}>Today</TableCell>
|
|
<TableCell align="right" />
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{filtered.map(rider => (
|
|
<TableRow key={rider.id} hover sx={{ cursor: 'pointer' }} onClick={() => openView(rider)}>
|
|
<TableCell>
|
|
<Stack direction="row" gap={2} alignItems="center" sx={{ mb: 1 }}>
|
|
<Box sx={{ mr: 1 }}>
|
|
<RiderAvatar rider={rider} size={42} />
|
|
</Box>
|
|
|
|
<Box>
|
|
<Typography fontWeight={600}>{rider.name}</Typography>
|
|
<Typography variant="caption" sx={{ color: '#9AA0A6' }}>{rider.id}</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell><StatusPill status={rider.status} /></TableCell>
|
|
<TableCell>
|
|
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
|
{rider.zones.map(z => <Chip key={z} label={z} size="small" variant="outlined" />)}
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell><VehicleCell vehicle={rider.vehicle} vehicleNo={rider.vehicleNo} /></TableCell>
|
|
<TableCell><CapacityBar rider={rider} /></TableCell>
|
|
<TableCell>
|
|
<Stack direction="row" spacing={4}>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Done</Typography>
|
|
<Typography fontWeight={700} color="#1E8E3E">{rider.deliveriesDone}</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">COP</Typography>
|
|
<Typography fontWeight={700}>{inr(rider.codCollected)}</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<Stack direction="row" gap={1} justifyContent="flex-end">
|
|
<Tooltip title="View"><IconButton onClick={(e) => { e.stopPropagation(); openView(rider); }}><VisibilityOutlinedIcon /></IconButton></Tooltip>
|
|
<Tooltip title="Edit"><IconButton onClick={(e) => { e.stopPropagation(); openEdit(rider); }}><EditOutlinedIcon /></IconButton></Tooltip>
|
|
<IconButton onClick={(e) => { e.stopPropagation(); openMenu(e, rider); }}><MoreVertOutlinedIcon /></IconButton>
|
|
</Stack>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
) : (
|
|
<Box sx={{ p: { xs: 2.5, md: 4 } }}>
|
|
<Grid container spacing={{ xs: 2.5, md: 3 }}>
|
|
{filtered.map(r => (
|
|
<Grid size={{ xs: 12, sm: 6, lg: 4 }} key={r.id}>
|
|
<RiderCard rider={r} onView={openView} onMenu={openMenu} />
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
</Box>
|
|
)}
|
|
|
|
{filtered.length > 0 && (
|
|
<Box sx={{ px: 4, py: 2.5, borderTop: '1px solid #F1F3F5', bgcolor: '#FAFBFC' }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Showing {filtered.length} of {riders.length} milers
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Context Menu */}
|
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => setMenuAnchor(null)} PaperProps={{ sx: { borderRadius: 2 } }}>
|
|
<MenuItem onClick={() => openView(menuRider)}><ListItemIcon><VisibilityOutlinedIcon /></ListItemIcon>View Profile</MenuItem>
|
|
<MenuItem onClick={() => openEdit(menuRider)}><ListItemIcon><EditOutlinedIcon /></ListItemIcon>Edit</MenuItem>
|
|
<MenuItem component="a" href={`tel:${menuRider?.phone}`}><ListItemIcon><CallOutlinedIcon /></ListItemIcon>Call</MenuItem>
|
|
<Divider />
|
|
<MenuItem onClick={() => { setDeleteTarget(menuRider); setMenuAnchor(null); }} sx={{ color: '#D93025' }}>
|
|
<ListItemIcon><DeleteOutlineIcon sx={{ color: '#D93025' }} /></ListItemIcon>Remove Miler
|
|
</MenuItem>
|
|
</Menu>
|
|
|
|
{/* Dialogs */}
|
|
<RiderFormDialog open={formDialog.open} mode={formDialog.mode} initial={formDialog.initial} onClose={() => setFormDialog({ open: false, mode: 'add', initial: null })} onSave={handleSave} />
|
|
<ProfileDrawer
|
|
rider={profile}
|
|
startInEdit={profileEdit}
|
|
onClose={() => { setProfile(null); setProfileEdit(false); }}
|
|
onSave={saveProfileEdit}
|
|
/>
|
|
<DeleteDialog rider={deleteTarget} onClose={() => setDeleteTarget(null)} onConfirm={handleDelete} />
|
|
|
|
{/* Toast */}
|
|
<Snackbar open={snack.open} autoHideDuration={2500} onClose={() => setSnack(s => ({ ...s, open: false }))}>
|
|
<Alert severity={snack.severity} variant="filled" sx={{ borderRadius: 2 }}>{snack.msg}</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
);
|
|
} |