/* 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 ; } function CapacityBar({ rider, showLabel = true }) { const pct = loadPct(rider); const variant = pct > 85 ? 'error' : pct > 60 ? 'warning' : 'accent'; return ( {showLabel && ( {rider.assigned}/{rider.capacity} · {pct}% )} ); } function RiderAvatar({ rider, size = 40 }) { const m = STATUS_META[rider.status] || STATUS_META.Offline; return ( } /> ); } function VehicleCell({ vehicle, vehicleNo }) { const Icon = VEHICLES[vehicle]?.icon || Truck; return ( {vehicle} {vehicleNo} ); } // ════════════════════════════════════════════════════════════════════════════════ // 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: , onClick: () => openView(r) }, { label: 'Edit', icon: , onClick: () => openEdit(r) }, { label: 'Call', icon: , onClick: () => window.open(`tel:${r.phone}`, '_self') }, { type: 'divider' }, { label: 'Remove Miler', icon: , 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) => ( openView(r)}> {r.name} {r.id} ) }, { key: 'status', header: 'Status', width: pixel(140), renderCell: (r) => }, { key: 'zones', header: 'Zones', width: proportional(1.4), renderCell: (r) => ( {r.zones.length === 0 ? : r.zones.map((z) => )} ) }, { key: 'vehicle', header: 'Vehicle', width: proportional(1.2), renderCell: (r) => }, { key: 'load', header: 'Load', width: pixel(140), renderCell: (r) => }, { key: 'today', header: 'Today', width: pixel(130), renderCell: (r) => ( Done {r.deliveriesDone} COP {inr(r.codCollected)} ) }, { key: 'actions', header: '', width: pixel(56), align: 'end', renderCell: (r) => } // eslint-disable-next-line react-hooks/exhaustive-deps ], []); return (
} /> {/* KPIs */} {/* Toolbar */}
setSearch(e.target.value)} />
setVehicleFilter(v || 'All')} hasClear />
{!isLgDown && ( } /> } /> )} {STATUS_CHIPS.map((s) => { const active = statusFilter === s; const meta = STATUS_META[s]; return ( ); })}
{/* Content */} {loadError && (
)} {loading ? (
) : filtered.length === 0 ? ( } title="No milers match your filters" description="Try adjusting or clearing your search and filters." style={{ padding: '64px 0' }} actions={ } /> ) : effectiveView === 'table' ? (
) : (
{filtered.map((r) => ( ))}
)} {filtered.length > 0 && (
Showing {filtered.length} of {riders.length} milers
)} {/* Add / Edit Dialog */} setFormDialog({ open: false, mode: 'add', initial: null })} onSave={handleSave} /> {/* Profile Sheet */} { setProfile(null); setProfileEdit(false); }} onSave={saveProfileEdit} onDelete={(r) => setDeleteTarget(r)} /> {/* Delete confirmation */} { 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} /> ); } // ════════════════════════════════════════════════════════════════════════════════ // Rider Card (grid view) // ════════════════════════════════════════════════════════════════════════════════ function RiderCard({ rider, onView, menuItems }) { const pct = loadPct(rider); return ( onView(rider)}> {rider.name} {rider.id}
e.stopPropagation()}>
Today's Load {rider.assigned}/{rider.capacity} · {pct}% 85 ? 'error' : pct > 60 ? 'warning' : 'accent'} /> Service Zones {rider.zones.length === 0 ? : ( <> {rider.zones.slice(0, 3).map((z) => )} {rider.zones.length > 3 && } )}
); } // ════════════════════════════════════════════════════════════════════════════════ // 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 ( { if (!o) onClose(); }} width={480} variant={isMdDown ? 'fullscreen' : 'standard'} maxHeight="85vh" purpose="form">
} content={ Step {activeStep + 1} of {STEPS.length} · {STEPS[activeStep]} {activeStep === 0 && ( <> set('name', e.target.value)} status={errors.name ? { type: 'error', message: errors.name } : undefined} /> set('phone', e.target.value)} status={errors.phone ? { type: 'error', message: errors.phone } : undefined} /> set('hub', v)} /> )} {activeStep === 1 && ( <> set('zones', v)} triggerDisplay="badges" hasSearch status={errors.zones ? { type: 'error', message: errors.zones } : undefined} /> {mode === 'edit' && ( set('status', v)} /> )} )} {activeStep === 2 && ( <> ({ 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} /> set('vehicleNo', e.target.value.toUpperCase())} description={form.vehicle === 'Cycle' ? 'Optional for cycles' : undefined} status={errors.vehicleNo ? { type: 'error', message: errors.vehicleNo } : undefined} /> set('verified', v)} description="Driving licence & ID proof validated" labelSpacing="spread" /> )} } footer={ {activeStep > 0 && } {!isLast ? ( ) : ( )} } />
); } // ════════════════════════════════════════════════════════════════════════════════ // 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 ( { if (!o) onClose(); }} width={isMdDown ? '100%' : 480} maxHeight="100vh" style={{ height: '100vh' }} position={{ top: 0, right: 0, bottom: 0 }} purpose="info" > {rider && ( {form.name || rider.name} {rider.id} {rider.verified && } } content={ {editing ? ( Edit Miler Details setForm((f) => ({ ...f, status: v }))} /> setForm((f) => ({ ...f, vehicle: v }))} /> setForm((f) => ({ ...f, hub: v }))} /> setForm((f) => ({ ...f, zones: v }))} triggerDisplay="badges" hasSearch /> ) : ( <> Today's Performance Live Load Pending pickups {rider.pickupsPending} Miler Details {[ { 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) => ( {d.label} {d.value} ))} )} } footer={ {editing ? ( <> ) : ( <> )} } /> )} ); }