936 lines
41 KiB
JavaScript
936 lines
41 KiB
JavaScript
/* eslint-disable react/prop-types */
|
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
|
import {
|
|
Users, Plus, Download, Phone, MapPin, Bike, Zap, Truck, CheckCircle2,
|
|
AlertTriangle, Clock, Wallet, Pencil, ArrowLeft, ShieldCheck, LayoutGrid, Rows3, Route
|
|
} 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 { StatusDot } from '@astryxdesign/core/StatusDot';
|
|
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
import { Selector } from '@astryxdesign/core/Selector';
|
|
import { MultiSelector } from '@astryxdesign/core/MultiSelector';
|
|
import { Switch } from '@astryxdesign/core/Switch';
|
|
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
|
import { MoreMenu } from '@astryxdesign/core/MoreMenu';
|
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
|
import { AlertDialog } from '@astryxdesign/core/AlertDialog';
|
|
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 StatCard from '@/components/StatCard';
|
|
import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub';
|
|
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;
|
|
}
|
|
|
|
// 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: Zap },
|
|
Motorcycle: { capacity: 25, icon: Bike },
|
|
Cycle: { capacity: 15, icon: Bike },
|
|
'Cargo Van': { capacity: 120, icon: Truck },
|
|
'Mini Truck': { capacity: 200, icon: Truck }
|
|
};
|
|
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'];
|
|
|
|
// Status color maps to Astryx's own categorical tokens (same palette Badge
|
|
// uses elsewhere), so status reads as part of the same visual language as
|
|
// the rest of the app instead of a one-off set of hand-picked hex values.
|
|
const STATUS_META = {
|
|
'On Pickup': { variant: 'blue', dot: 'accent', label: 'On Pickup' },
|
|
Idle: { variant: 'warning', dot: 'warning', label: 'Idle / Available' },
|
|
'On Break': { variant: 'purple', dot: 'accent', label: 'On Break' },
|
|
'Returning to Hub': { variant: 'success', dot: 'success', label: 'Returning' },
|
|
Offline: { variant: 'neutral', dot: 'neutral', label: 'Offline' },
|
|
Suspended: { variant: 'error', dot: 'error', 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);
|
|
|
|
// 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' });
|
|
};
|
|
|
|
// 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.
|
|
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 inr = (n) => `₹${n.toLocaleString('en-IN')}`;
|
|
|
|
const EMPTY_FORM = {
|
|
name: '', phone: '', hub: 'Delhi Operations Hub', zones: [],
|
|
vehicle: '', vehicleNo: '', status: 'Idle', verified: false
|
|
};
|
|
|
|
const STEPS = ['Personal', 'Assignment', 'Vehicle & KYC'];
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// Presentational pieces
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
|
|
function StatusPill({ status }) {
|
|
const m = STATUS_META[status] || STATUS_META.Offline;
|
|
return <Badge variant={m.variant} label={m.label} />;
|
|
}
|
|
|
|
function CapacityBar({ rider, showLabel = true }) {
|
|
const pct = loadPct(rider);
|
|
const variant = pct > 85 ? 'error' : pct > 60 ? 'warning' : 'accent';
|
|
return (
|
|
<VStack gap={1} style={{ minWidth: 130 }}>
|
|
<ProgressBar label={`${rider.name} load`} isLabelHidden value={rider.assigned} max={rider.capacity || 1} variant={variant} />
|
|
{showLabel && (
|
|
<Text type="supporting" color="secondary">
|
|
<b>{rider.assigned}/{rider.capacity}</b> · {pct}%
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
function RiderAvatar({ rider, size = 40 }) {
|
|
const m = STATUS_META[rider.status] || STATUS_META.Offline;
|
|
return (
|
|
<Avatar
|
|
name={rider.name}
|
|
size={size}
|
|
status={<StatusDot variant={m.dot} label={m.label} />}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function VehicleCell({ vehicle, vehicleNo }) {
|
|
const Icon = VEHICLES[vehicle]?.icon || Truck;
|
|
return (
|
|
<HStack gap={2} align="center">
|
|
<span
|
|
style={{
|
|
width: 32, height: 32, borderRadius: 'var(--radius-inner)', background: 'var(--color-background-muted)',
|
|
color: 'var(--color-icon-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
</span>
|
|
<VStack gap={0}>
|
|
<Text type="body" weight="semibold">{vehicle}</Text>
|
|
<Text type="supporting" color="secondary" style={{ fontFamily: 'monospace' }}>{vehicleNo}</Text>
|
|
</VStack>
|
|
</HStack>
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// MAIN COMPONENT
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
export default function Riders() {
|
|
const isMdDown = useMediaQuery('(max-width: 900px)');
|
|
const isLgDown = useMediaQuery('(max-width: 1200px)');
|
|
const toast = useToast();
|
|
const notify = (msg, status = 'success') => toast({ body: msg, type: status });
|
|
|
|
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 [deleting, setDeleting] = useState(false);
|
|
|
|
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.toString().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));
|
|
notify(`${rider.name} onboarded successfully`);
|
|
} else {
|
|
await updateMiler(rider.userid ?? rider.id, toApiPayload(rider));
|
|
notify(`${rider.name} updated`);
|
|
}
|
|
setFormDialog({ open: false, mode: 'add', initial: null });
|
|
loadMilers();
|
|
} catch (err) {
|
|
notify(err?.message || 'Could not save this miler.', 'error');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
const target = deleteTarget;
|
|
if (!target || deleting) return;
|
|
setDeleting(true);
|
|
try {
|
|
await deleteMiler(target.userid ?? target.id);
|
|
setRiders((p) => p.filter((r) => r.id !== target.id));
|
|
notify(`${target.name} removed`, 'info');
|
|
} catch (err) {
|
|
notify(err?.message || 'Could not remove this miler.', 'error');
|
|
} finally {
|
|
setDeleting(false);
|
|
setDeleteTarget(null);
|
|
setProfile(null);
|
|
}
|
|
};
|
|
|
|
const openAdd = () => setFormDialog({ open: true, mode: 'add', initial: null });
|
|
const openEdit = (r) => {
|
|
setProfile(r);
|
|
setProfileEdit(true);
|
|
};
|
|
const openView = (r) => {
|
|
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);
|
|
notify(`${updated.name} updated`);
|
|
} catch (err) {
|
|
notify(err?.message || 'Could not update this miler.', 'error');
|
|
}
|
|
};
|
|
|
|
const menuItemsFor = (r) => [
|
|
{ label: 'View Profile', icon: <Users size={14} />, onClick: () => openView(r) },
|
|
{ label: 'Edit', icon: <Pencil size={14} />, onClick: () => openEdit(r) },
|
|
{ label: 'Call', icon: <Phone size={14} />, onClick: () => window.open(`tel:${r.phone}`, '_self') },
|
|
{ type: 'divider' },
|
|
{ label: 'Remove Miler', icon: <AlertTriangle size={14} />, onClick: () => setDeleteTarget(r) }
|
|
];
|
|
|
|
const STATUS_CHIPS = ['All', 'On Pickup', 'Idle', 'On Break', 'Offline', 'Suspended'];
|
|
|
|
const columns = useMemo(() => [
|
|
{
|
|
key: 'miler',
|
|
header: 'Miler',
|
|
width: proportional(1.6),
|
|
renderCell: (r) => (
|
|
<HStack gap={3} align="center" style={{ cursor: 'pointer' }} onClick={() => openView(r)}>
|
|
<RiderAvatar rider={r} />
|
|
<VStack gap={0}>
|
|
<Text type="body" weight="semibold">{r.name}</Text>
|
|
<Text type="supporting" color="secondary" style={{ fontFamily: 'monospace' }}>{r.id}</Text>
|
|
</VStack>
|
|
</HStack>
|
|
)
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
width: pixel(140),
|
|
renderCell: (r) => <StatusPill status={r.status} />
|
|
},
|
|
{
|
|
key: 'zones',
|
|
header: 'Zones',
|
|
width: proportional(1.4),
|
|
renderCell: (r) => (
|
|
<HStack gap={1} wrap="wrap">
|
|
{r.zones.length === 0 ? <Text type="supporting" color="secondary">—</Text> : r.zones.map((z) => <Badge key={z} variant="neutral" label={z} />)}
|
|
</HStack>
|
|
)
|
|
},
|
|
{
|
|
key: 'vehicle',
|
|
header: 'Vehicle',
|
|
width: proportional(1.2),
|
|
renderCell: (r) => <VehicleCell vehicle={r.vehicle} vehicleNo={r.vehicleNo} />
|
|
},
|
|
{
|
|
key: 'load',
|
|
header: 'Load',
|
|
width: pixel(140),
|
|
renderCell: (r) => <CapacityBar rider={r} />
|
|
},
|
|
{
|
|
key: 'today',
|
|
header: 'Today',
|
|
width: pixel(130),
|
|
renderCell: (r) => (
|
|
<HStack gap={4}>
|
|
<VStack gap={0}>
|
|
<Text type="supporting" color="secondary">Done</Text>
|
|
<Text type="body" weight="bold" style={{ color: 'var(--color-icon-green)' }}>{r.deliveriesDone}</Text>
|
|
</VStack>
|
|
<VStack gap={0}>
|
|
<Text type="supporting" color="secondary">COP</Text>
|
|
<Text type="body" weight="bold">{inr(r.codCollected)}</Text>
|
|
</VStack>
|
|
</HStack>
|
|
)
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
width: pixel(56),
|
|
align: 'end',
|
|
renderCell: (r) => <MoreMenu label={`More actions for ${r.name}`} items={menuItemsFor(r)} />
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
], []);
|
|
|
|
return (
|
|
<div style={{ paddingBottom: '32px' }}>
|
|
<PageHeader
|
|
icon={Users}
|
|
title="Milers"
|
|
subtitle={`${kpi.total} milers • who's working and where`}
|
|
action={
|
|
<HStack gap={2}>
|
|
<Button variant="secondary" icon={<Download size={16} />} onClick={() => notify('Exported as CSV')}>Export Roster</Button>
|
|
<Button variant="primary" icon={<Plus size={16} />} onClick={openAdd}>Add New Miler</Button>
|
|
</HStack>
|
|
}
|
|
/>
|
|
|
|
{/* KPIs */}
|
|
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={1.5} style={{ marginBottom: '16px' }}>
|
|
<StatCard size="sm" icon={Users} label="On Duty" value={kpi.onDuty} tone="blue" />
|
|
<StatCard size="sm" icon={Route} label="In Field" value={kpi.onField} tone="green" />
|
|
<StatCard size="sm" icon={Clock} label="Available" value={kpi.idle} tone="orange" />
|
|
<StatCard size="sm" icon={AlertTriangle} label="SLA Risk" value={kpi.slaRisk} tone="red" />
|
|
<StatCard size="sm" icon={Wallet} label="COP Pending" value={inr(kpi.codToCollect)} tone="purple" />
|
|
</Grid>
|
|
|
|
<Panel>
|
|
{/* Toolbar */}
|
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--color-border)' }}>
|
|
<HStack gap={2} wrap="wrap" align="center">
|
|
<div style={{ flex: '1 1 260px', minWidth: 220 }}>
|
|
<TextInput
|
|
label="Search milers"
|
|
isLabelHidden
|
|
placeholder="Search by name, ID, phone or zone..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div style={{ width: 200 }}>
|
|
<Selector
|
|
label="Vehicle"
|
|
isLabelHidden
|
|
placeholder="All Vehicles"
|
|
options={VEHICLE_TYPES}
|
|
value={vehicleFilter === 'All' ? undefined : vehicleFilter}
|
|
onChange={(v) => setVehicleFilter(v || 'All')}
|
|
hasClear
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 1 }} />
|
|
{!isLgDown && (
|
|
<SegmentedControl label="View" value={view} onChange={setView}>
|
|
<SegmentedControlItem value="table" label="Table" isLabelHidden icon={<Rows3 size={16} />} />
|
|
<SegmentedControlItem value="grid" label="Grid" isLabelHidden icon={<LayoutGrid size={16} />} />
|
|
</SegmentedControl>
|
|
)}
|
|
</HStack>
|
|
|
|
<HStack gap={1.5} wrap="wrap" style={{ marginTop: '14px' }}>
|
|
{STATUS_CHIPS.map((s) => {
|
|
const active = statusFilter === s;
|
|
const meta = STATUS_META[s];
|
|
return (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setStatusFilter(s)}
|
|
style={{
|
|
height: 32,
|
|
padding: '0 12px',
|
|
borderRadius: 'var(--radius-full)',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
border: active ? 'none' : '1px solid var(--color-border)',
|
|
background: active ? 'var(--color-accent)' : 'var(--color-background-surface)',
|
|
color: active ? 'var(--color-on-accent)' : 'var(--color-text-secondary)'
|
|
}}
|
|
>
|
|
{meta ? meta.label.split(' / ')[0] : s}
|
|
</button>
|
|
);
|
|
})}
|
|
</HStack>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{loadError && (
|
|
<div style={{ padding: '16px' }}>
|
|
<Banner status="error" title="Error" description={loadError} />
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div style={{ padding: '64px', display: 'flex', justifyContent: 'center' }}>
|
|
<Spinner label="Loading milers" size="lg" />
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<EmptyState
|
|
icon={<span style={{ color: 'var(--color-icon-disabled)', display: 'flex' }}><Users size={48} /></span>}
|
|
title="No milers match your filters"
|
|
description="Try adjusting or clearing your search and filters."
|
|
style={{ padding: '64px 0' }}
|
|
actions={
|
|
<Button variant="secondary" onClick={() => { setSearch(''); setStatusFilter('All'); setVehicleFilter('All'); }}>
|
|
Clear all filters
|
|
</Button>
|
|
}
|
|
/>
|
|
) : effectiveView === 'table' ? (
|
|
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
<Table data={filtered} columns={columns} idKey="id" density="balanced" dividers="rows" hasHover />
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: '20px' }}>
|
|
<Grid columns={{ minWidth: 280 }} gap={3}>
|
|
{filtered.map((r) => (
|
|
<RiderCard key={r.id} rider={r} onView={openView} menuItems={menuItemsFor(r)} />
|
|
))}
|
|
</Grid>
|
|
</div>
|
|
)}
|
|
|
|
{filtered.length > 0 && (
|
|
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)' }}>
|
|
<Text type="supporting" color="secondary">Showing {filtered.length} of {riders.length} milers</Text>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
|
|
{/* Add / Edit Dialog */}
|
|
<RiderFormDialog
|
|
open={formDialog.open}
|
|
mode={formDialog.mode}
|
|
initial={formDialog.initial}
|
|
isMdDown={isMdDown}
|
|
onClose={() => setFormDialog({ open: false, mode: 'add', initial: null })}
|
|
onSave={handleSave}
|
|
/>
|
|
|
|
{/* Profile Sheet */}
|
|
<ProfileDrawer
|
|
rider={profile}
|
|
startInEdit={profileEdit}
|
|
isMdDown={isMdDown}
|
|
onClose={() => { setProfile(null); setProfileEdit(false); }}
|
|
onSave={saveProfileEdit}
|
|
onDelete={(r) => setDeleteTarget(r)}
|
|
/>
|
|
|
|
{/* Delete confirmation */}
|
|
<AlertDialog
|
|
isOpen={Boolean(deleteTarget)}
|
|
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
|
title="Remove miler?"
|
|
description={`${deleteTarget?.name || 'This miler'} will be permanently removed from the roster.`}
|
|
actionLabel="Yes, remove"
|
|
actionVariant="destructive"
|
|
isActionLoading={deleting}
|
|
onAction={handleDelete}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// Rider Card (grid view)
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
function RiderCard({ rider, onView, menuItems }) {
|
|
const pct = loadPct(rider);
|
|
return (
|
|
<Card style={{ border: '1px solid var(--color-border)', cursor: 'pointer' }} onClick={() => onView(rider)}>
|
|
<HStack gap={3} align="start" justify="between">
|
|
<HStack gap={3} align="center">
|
|
<RiderAvatar rider={rider} size={48} />
|
|
<VStack gap={0}>
|
|
<Text type="body" weight="bold">{rider.name}</Text>
|
|
<Text type="supporting" color="secondary" style={{ fontFamily: 'monospace' }}>{rider.id}</Text>
|
|
</VStack>
|
|
</HStack>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<MoreMenu label={`More actions for ${rider.name}`} items={menuItems} />
|
|
</div>
|
|
</HStack>
|
|
|
|
<div style={{ margin: '14px 0' }}>
|
|
<StatusPill status={rider.status} />
|
|
</div>
|
|
|
|
<VStack gap={1} style={{ marginBottom: '14px' }}>
|
|
<HStack justify="between">
|
|
<Text type="supporting" color="secondary">Today's Load</Text>
|
|
<Text type="supporting" weight="bold">{rider.assigned}/{rider.capacity} · {pct}%</Text>
|
|
</HStack>
|
|
<ProgressBar label={`${rider.name} load`} isLabelHidden value={rider.assigned} max={rider.capacity || 1} variant={pct > 85 ? 'error' : pct > 60 ? 'warning' : 'accent'} />
|
|
</VStack>
|
|
|
|
<VStack gap={1}>
|
|
<Text type="supporting" color="secondary" weight="semibold">Service Zones</Text>
|
|
<HStack gap={1} wrap="wrap">
|
|
{rider.zones.length === 0 ? <Text type="supporting" color="secondary">—</Text> : (
|
|
<>
|
|
{rider.zones.slice(0, 3).map((z) => <Badge key={z} variant="neutral" label={z} />)}
|
|
{rider.zones.length > 3 && <Badge variant="blue" label={`+${rider.zones.length - 3}`} />}
|
|
</>
|
|
)}
|
|
</HStack>
|
|
</VStack>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// Add / Edit Rider Dialog (3-step)
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
function RiderFormDialog({ open, onClose, onSave, initial, mode, isMdDown }) {
|
|
const [activeStep, setActiveStep] = useState(0);
|
|
const [form, setForm] = useState(initial || EMPTY_FORM);
|
|
const [errors, setErrors] = useState({});
|
|
|
|
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 = (e) => {
|
|
e.preventDefault();
|
|
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
|
|
});
|
|
};
|
|
|
|
const isLast = activeStep === STEPS.length - 1;
|
|
|
|
return (
|
|
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={480} variant={isMdDown ? 'fullscreen' : 'standard'} maxHeight="85vh" purpose="form">
|
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: 1 }}>
|
|
<Layout
|
|
header={<DialogHeader title={mode === 'add' ? 'Onboard New Miler' : `Edit ${form.name}`} onOpenChange={onClose} />}
|
|
content={
|
|
<LayoutContent>
|
|
<VStack gap={0.5} style={{ padding: '20px 24px 4px' }}>
|
|
<Text type="supporting" color="secondary">Step {activeStep + 1} of {STEPS.length} · {STEPS[activeStep]}</Text>
|
|
<ProgressBar label="Onboarding progress" isLabelHidden value={activeStep + 1} max={STEPS.length} variant="accent" />
|
|
</VStack>
|
|
|
|
<VStack gap={4} style={{ padding: '20px 24px' }}>
|
|
{activeStep === 0 && (
|
|
<>
|
|
<TextInput label="Full Name" value={form.name} hasAutoFocus onChange={(e) => set('name', e.target.value)} status={errors.name ? { type: 'error', message: errors.name } : undefined} />
|
|
<TextInput label="Phone Number" value={form.phone} placeholder="+91 XXXXXXXXXX" onChange={(e) => set('phone', e.target.value)} status={errors.phone ? { type: 'error', message: errors.phone } : undefined} />
|
|
<Selector label="Home Hub" options={HUB_OPTIONS} value={form.hub} onChange={(v) => set('hub', v)} />
|
|
</>
|
|
)}
|
|
|
|
{activeStep === 1 && (
|
|
<>
|
|
<MultiSelector
|
|
label="Service Zones"
|
|
options={ALL_ZONES}
|
|
value={form.zones}
|
|
onChange={(v) => set('zones', v)}
|
|
triggerDisplay="badges"
|
|
hasSearch
|
|
status={errors.zones ? { type: 'error', message: errors.zones } : undefined}
|
|
/>
|
|
{mode === 'edit' && (
|
|
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={(v) => set('status', v)} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeStep === 2 && (
|
|
<>
|
|
<Selector
|
|
label="Vehicle Type"
|
|
options={VEHICLE_TYPES.map((v) => ({ value: v, label: `${v} · ${VEHICLES[v].capacity} parcels` }))}
|
|
value={form.vehicle}
|
|
onChange={(v) => set('vehicle', v)}
|
|
status={errors.vehicle ? { type: 'error', message: errors.vehicle } : undefined}
|
|
/>
|
|
<TextInput
|
|
label="Vehicle Registration No."
|
|
value={form.vehicleNo}
|
|
placeholder="DL-00-AB-0000"
|
|
onChange={(e) => set('vehicleNo', e.target.value.toUpperCase())}
|
|
description={form.vehicle === 'Cycle' ? 'Optional for cycles' : undefined}
|
|
status={errors.vehicleNo ? { type: 'error', message: errors.vehicleNo } : undefined}
|
|
/>
|
|
<Card variant="muted" padding={3}>
|
|
<HStack gap={2} align="center">
|
|
<span style={{ color: form.verified ? 'var(--color-icon-green)' : 'var(--color-icon-disabled)', display: 'flex' }}>
|
|
<ShieldCheck size={20} />
|
|
</span>
|
|
<VStack gap={0} style={{ flex: 1 }}>
|
|
<Switch label="KYC Verified" value={form.verified} onChange={(v) => set('verified', v)} description="Driving licence & ID proof validated" labelSpacing="spread" />
|
|
</VStack>
|
|
</HStack>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</VStack>
|
|
</LayoutContent>
|
|
}
|
|
footer={
|
|
<LayoutFooter hasDivider>
|
|
<HStack justify="between" style={{ width: '100%', padding: '16px 24px' }}>
|
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
<HStack gap={2}>
|
|
{activeStep > 0 && <Button variant="secondary" onClick={back}>Back</Button>}
|
|
{!isLast ? (
|
|
<Button variant="primary" onClick={next}>Continue</Button>
|
|
) : (
|
|
<Button variant="primary" type="submit" icon={<CheckCircle2 size={16} />}>
|
|
{mode === 'add' ? 'Add Miler' : 'Save Changes'}
|
|
</Button>
|
|
)}
|
|
</HStack>
|
|
</HStack>
|
|
</LayoutFooter>
|
|
}
|
|
/>
|
|
</form>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
// Profile Sheet — view + inline edit, right-anchored full-height dialog
|
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
function ProfileDrawer({ rider: riderProp, onClose, onSave, onDelete, startInEdit = false, isMdDown }) {
|
|
const [editing, setEditing] = useState(startInEdit);
|
|
// Retain the last opened miler so the sheet keeps rendering its content
|
|
// while it closes — riderProp goes null immediately on close, and
|
|
// unmounting the Dialog right away would cut off its closing animation.
|
|
const [retained, setRetained] = useState(riderProp);
|
|
const [form, setForm] = useState(riderProp || {});
|
|
|
|
useEffect(() => {
|
|
if (riderProp) {
|
|
setRetained(riderProp);
|
|
setForm(riderProp);
|
|
setEditing(startInEdit);
|
|
}
|
|
}, [riderProp, startInEdit]);
|
|
|
|
const open = Boolean(riderProp);
|
|
const rider = riderProp || retained;
|
|
|
|
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);
|
|
};
|
|
|
|
const meta = rider ? (STATUS_META[rider.status] || STATUS_META.Offline) : STATUS_META.Offline;
|
|
|
|
return (
|
|
<Dialog
|
|
isOpen={open}
|
|
onOpenChange={(o) => { if (!o) onClose(); }}
|
|
width={isMdDown ? '100%' : 480}
|
|
maxHeight="100vh"
|
|
style={{ height: '100vh' }}
|
|
position={{ top: 0, right: 0, bottom: 0 }}
|
|
purpose="info"
|
|
>
|
|
{rider && (
|
|
<Layout
|
|
height="fill"
|
|
header={
|
|
<div style={{ background: 'var(--color-brand)', color: 'var(--color-on-brand)', padding: '20px 24px' }}>
|
|
<Button variant="ghost" onClick={onClose} icon={<ArrowLeft size={16} />} style={{ color: 'var(--color-on-brand)', marginBottom: '12px', marginLeft: '-8px' }}>
|
|
Close
|
|
</Button>
|
|
<Heading level={3} style={{ color: 'var(--color-on-brand)', margin: 0 }}>{form.name || rider.name}</Heading>
|
|
<Text type="supporting" style={{ color: 'var(--color-on-brand)', opacity: 0.85, fontFamily: 'monospace' }}>{rider.id}</Text>
|
|
<HStack gap={1.5} wrap="wrap" style={{ marginTop: '12px' }}>
|
|
<Badge variant={meta.variant} label={editing ? 'Editing…' : meta.label} />
|
|
{rider.verified && <Badge variant="green" label="Verified" />}
|
|
<Badge variant="neutral" label={`${rider.rating} ★ · ${sr}%`} />
|
|
</HStack>
|
|
</div>
|
|
}
|
|
content={
|
|
<LayoutContent isScrollable>
|
|
<VStack gap={3} style={{ padding: '20px', background: 'var(--color-background-muted)' }}>
|
|
{editing ? (
|
|
<Card padding={4}>
|
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '16px' }}>
|
|
Edit Miler Details
|
|
</Text>
|
|
<VStack gap={3}>
|
|
<TextInput label="Full name" value={form.name || ''} onChange={setField('name')} />
|
|
<TextInput label="Phone" value={form.phone || ''} onChange={setField('phone')} />
|
|
<Selector label="Status" options={STATUS_OPTIONS} value={form.status || ''} onChange={(v) => setForm((f) => ({ ...f, status: v }))} />
|
|
<Selector label="Vehicle" options={VEHICLE_TYPES} value={form.vehicle || ''} onChange={(v) => setForm((f) => ({ ...f, vehicle: v }))} />
|
|
<TextInput label="Vehicle number" value={form.vehicleNo || ''} onChange={setField('vehicleNo')} />
|
|
<Selector label="Hub" options={HUB_OPTIONS} value={form.hub || ''} onChange={(v) => setForm((f) => ({ ...f, hub: v }))} />
|
|
<MultiSelector label="Zones" options={ALL_ZONES} value={form.zones || []} onChange={(v) => setForm((f) => ({ ...f, zones: v }))} triggerDisplay="badges" hasSearch />
|
|
</VStack>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<VStack gap={2}>
|
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase' }}>Today's Performance</Text>
|
|
<Grid columns={3} gap={1.5}>
|
|
<StatCard size="sm" icon={CheckCircle2} label="Picked up" value={rider.deliveriesDone} tone="green" hover={false} />
|
|
<StatCard size="sm" icon={AlertTriangle} label="Failed" value={rider.deliveriesFailed} tone="red" hover={false} />
|
|
<StatCard size="sm" icon={Wallet} label="COP" value={inr(rider.codCollected)} tone="orange" hover={false} />
|
|
</Grid>
|
|
</VStack>
|
|
|
|
<Card padding={3}>
|
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '12px' }}>Live Load</Text>
|
|
<CapacityBar rider={rider} showLabel />
|
|
<HStack justify="between" align="center" style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px dashed var(--color-border)' }}>
|
|
<Text type="body" weight="semibold" color="secondary">Pending pickups</Text>
|
|
<Text type="large" weight="bold">{rider.pickupsPending}</Text>
|
|
</HStack>
|
|
</Card>
|
|
|
|
<Card padding={3}>
|
|
<Text type="supporting" weight="bold" color="secondary" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', marginBottom: '14px' }}>Miler Details</Text>
|
|
<Grid columns={2} gap={3}>
|
|
{[
|
|
{ icon: Phone, label: 'Phone', value: rider.phone },
|
|
{ icon: MapPin, label: 'Hub', value: rider.hub },
|
|
{ icon: MapPin, label: 'Zones', value: rider.zones?.join(', ') || 'N/A' },
|
|
{ icon: Bike, label: 'Vehicle', value: `${rider.vehicle} • ${rider.vehicleNo}` },
|
|
{ icon: Clock, label: 'Check-in', value: `${rider.checkInTime} (${rider.hoursToday}h)` }
|
|
].map((d, i) => (
|
|
<HStack key={i} gap={2} align="start">
|
|
<span style={{ color: 'var(--color-icon-disabled)', display: 'flex', marginTop: 2 }}><d.icon size={16} /></span>
|
|
<VStack gap={0} style={{ minWidth: 0 }}>
|
|
<Text type="supporting" color="secondary">{d.label}</Text>
|
|
<Text type="body" weight="semibold">{d.value}</Text>
|
|
</VStack>
|
|
</HStack>
|
|
))}
|
|
</Grid>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</VStack>
|
|
</LayoutContent>
|
|
}
|
|
footer={
|
|
<LayoutFooter hasDivider>
|
|
<HStack gap={2} style={{ width: '100%', padding: '16px 20px' }}>
|
|
{editing ? (
|
|
<>
|
|
<Button variant="secondary" style={{ flex: 1, justifyContent: 'center' }} onClick={cancelEdit}>Cancel</Button>
|
|
<Button variant="primary" style={{ flex: 1, justifyContent: 'center' }} icon={<CheckCircle2 size={16} />} onClick={saveEdit}>Save Changes</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="secondary" style={{ flex: 1, justifyContent: 'center' }} icon={<Phone size={16} />} onClick={() => window.open(`tel:${rider.phone}`, '_self')}>Call Miler</Button>
|
|
<Button variant="primary" style={{ flex: 1, justifyContent: 'center' }} icon={<Pencil size={16} />} onClick={() => setEditing(true)}>Edit Details</Button>
|
|
<Button variant="ghost" onClick={() => onDelete(rider)}>Remove</Button>
|
|
</>
|
|
)}
|
|
</HStack>
|
|
</LayoutFooter>
|
|
}
|
|
/>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|