/* 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 (
} onClick={() => notify('Exported as CSV')}>Export Roster
} onClick={openAdd}>Add New Miler
}
/>
{/* 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 (
);
}
// ════════════════════════════════════════════════════════════════════════════════
// 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 (
);
}