Files
doormile_hub_console/src/pages/operations/Riders.jsx

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&apos;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&apos;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>
);
}