import React, { useEffect, useRef, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { AlertTriangle, Bike, CheckCircle2, Clock, Info, Package, RefreshCw, Sparkles, Timer, Users, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Alert, Badge, Button, EmptyState, Grid, KpiCard, SectionHeader, Stack, Surface } from '@/components/ds'; import { fetchBatchEfficiency } from '@/api/doormile'; import { number } from '@/lib/doormileFormat'; /** * The solver's own read on how a wave actually ran — fleet utilisation, load * balance, and whether one rider should have covered another's kitchen. * * This calls the dispatch optimiser (`routes.workolik.com/batch/efficiency`), * not the Doormile API, so unlike everywhere else in this console the shape * comes from a versioned solver contract rather than something proven against * `express-console-api.md`. Field names below (`fleet_summary`, * `rider_timelines`, `substitution_opportunities`, `top_recommendation`) are * exactly what the source console's Dispatch Analysis tab reads. * * Three fixed windows, not a date/time picker — the solver only knows * morning/afternoon/evening as named batches, matching the same three waves * the rest of this console buckets on (`@/lib/batchBucket`), just under the * solver's own labels and clock ranges. */ const ANALYSIS_WINDOWS = [ { key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM' }, { key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:30 PM' }, { key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM' }, ]; /** Auto-refresh cadence for whichever window is open — matches the source * console's own interval. */ const POLL_MS = 15_000; /** * The recommendation banner's tone when the solver found no actionable * substitution. The backend can return `action: 'none'` while still flagging * real imbalance in `fleet_balance_assessment` ("High imbalance — 57 min * spread") — reading only `action` and defaulting to a plain "balanced" state * would bury that. Text first, numeric spread/stdev as a fallback when the * assessment string is missing or unrecognised. */ const severityOf = ({ assessment, spreadMin, stdev }) => { const text = String(assessment || '').toLowerCase(); if (/high imbalance|severe|critical/.test(text)) return 'warning'; if (/imbalance|recommend/.test(text)) return 'warning'; if (spreadMin != null && spreadMin > 30) return 'warning'; if (stdev != null && stdev > 1.0) return 'warning'; if (/balanced|excellent|good/.test(text)) return 'success'; return 'info'; }; const healthOf = (pct) => (pct == null ? 'unknown' : pct >= 85 ? 'good' : pct >= 70 ? 'ok' : 'low'); const HEALTH_TONE = { good: 'success', ok: 'warning', low: 'destructive', unknown: 'neutral' }; const HEALTH_LABEL = { good: 'Strong', ok: 'Moderate', low: 'Low', unknown: '—' }; /** userid → a real name. The solver has no auth/users table of its own, so * `rider_timelines` ships placeholder names like "Rider 883" — resolved * against the live rider rows this board already has wherever possible. */ const resolveRiderName = (riders, uid, fallback) => { const key = String(uid ?? '').trim(); if (!key) return fallback || '—'; const placeholder = typeof fallback === 'string' && /^Rider\s+\d+$/i.test(fallback.trim()); const live = (riders || []).find((r) => String(r.userid ?? r.id) === key); if (live) return live.ridername || live.name || fallback || `Rider ${key}`; if (!placeholder && fallback) return fallback; return `Rider ${key}`; }; /** @param {any} props */ export function BatchAnalysis({ tenantId, riders = [] }) { const [activeKey, setActiveKey] = useState(null); const [results, setResults] = useState({}); const [loadingWindow, setLoadingWindow] = useState(null); /* Mirrors `loadingWindow` into a ref so the poll timer below can read the latest in-flight state without re-subscribing on every flip — the timer itself only needs to exist once per active window. */ const loadingRef = useRef(null); useEffect(() => { loadingRef.current = loadingWindow; }, [loadingWindow]); const analyse = useMutation({ mutationFn: fetchBatchEfficiency, onMutate: (vars) => setLoadingWindow(vars.batch), onSuccess: (data, vars) => setResults((current) => ({ ...current, [vars.batch]: { data, fetchedAt: dayjs().format('HH:mm:ss') } })), onSettled: () => setLoadingWindow(null), }); const openWindow = (key, { force = false } = {}) => { setActiveKey(key); const cached = results[key]; if (!force && cached && cached.data?.success !== false) return; analyse.mutate({ batch: key, tenantId }); }; /* Auto-refresh the active window every 15s, skipping when a request is already in flight (no queue-stacking on a slow network) or the tab is hidden (no point spending solver quota on a screen nobody is looking at). */ useEffect(() => { if (!activeKey) return undefined; const tick = () => { if (loadingRef.current) return; if (typeof document !== 'undefined' && document.hidden) return; analyse.mutate({ batch: activeKey, tenantId }); }; const id = setInterval(tick, POLL_MS); return () => clearInterval(id); }, [activeKey, tenantId]); const activeMeta = ANALYSIS_WINDOWS.find((w) => w.key === activeKey); const cached = activeKey ? results[activeKey] : null; const isLoadingActive = loadingWindow === activeKey; const raw = cached?.data; return ( {/* ── Window picker ────────────────────────────────────────────────── */}
{ANALYSIS_WINDOWS.map((w) => { const result = results[w.key]; const loading = loadingWindow === w.key; const failed = result?.data?.success === false; const active = activeKey === w.key; return ( ); })}
{/* ── Result ───────────────────────────────────────────────────────── */} {!activeKey ? ( ) : isLoadingActive && !cached ? ( ) : !cached ? null : raw?.success === false ? ( {raw?.error?.message || 'The solver returned an error.'} {raw?.request_id &&

request_id: {raw.request_id}

}
) : ( openWindow(activeKey, { force: true })} /> )}
); } function BatchAnalysisResult({ meta, raw, fetchedAt, riders, isLoading, onRefresh }) { const fleet = raw.fleet_summary || {}; const timelines = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : []; const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : []; const rec = raw.top_recommendation; const hasRecRider = Boolean(rec && (rec.idle_rider_name || rec.idle_rider_id != null)); const hasRec = Boolean(rec && rec.action && rec.action !== 'none' && hasRecRider); const win = raw.window || {}; const util = Number.parseFloat(fleet.avg_utilisation_pct); const utilSafe = Number.isFinite(util) ? util : null; const utilHealth = healthOf(utilSafe); const severity = severityOf({ assessment: rec?.fleet_balance_assessment, spreadMin: Number.parseFloat(fleet.finish_time_spread_minutes), stdev: Number.parseFloat(fleet.load_balance_stdev), }); /* Bottleneck riders first — the one who finished last is the one an operator actually wants to see, not whoever the API listed first. */ const sortedTimelines = [...timelines].sort((a, b) => String(b.finished_at || '').localeCompare(String(a.finished_at || ''))); return (

{meta?.label} batch {raw.date && · {raw.date}} {win.from && win.to && ( · {win.from} – {win.to} )}

Fetched at {fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}

{/* ── Hero strip ─────────────────────────────────────────────────── */} {/* ── Fleet health ───────────────────────────────────────────────── */}

{utilSafe != null ? `${utilSafe.toFixed(0)}%` : '—'}

Avg utilisation

{HEALTH_LABEL[utilHealth]}

{fleet.load_balance_stdev != null ? Number.parseFloat(fleet.load_balance_stdev).toFixed(2) : '—'}

Load balance σ

{fleet.load_balance_stdev != null ? Number.parseFloat(fleet.load_balance_stdev) > 1.0 ? 'Uneven workload' : 'Balanced' : 'Stdev of orders/rider'}

{fleet.finish_time_spread_minutes != null ? `${fleet.finish_time_spread_minutes} min` : '—'}

Finish spread

{fleet.finish_time_stdev_minutes != null ? `σ ${Number.parseFloat(fleet.finish_time_stdev_minutes).toFixed(1)} min` : 'First → last finish'}

{fleet.avg_active_minutes != null ? `${Number.parseFloat(fleet.avg_active_minutes).toFixed(0)} min` : '—'}

Avg active

Per rider, this batch

{/* ── Top recommendation ────────────────────────────────────────── */} {hasRec ? (

{resolveRiderName(riders, rec.idle_rider_id, rec.idle_rider_name)} {rec.primary_kitchen && ( <> {' '} · primary kitchen {rec.primary_kitchen} )} {rec.second_kitchen && ( <> {' '} → also serve {rec.second_kitchen} after {rec.second_kitchen_dispatch_after || '—'} )}

{rec.fleet_improvement_minutes != null && (

Fleet improves by {rec.fleet_improvement_minutes} min if this substitution runs.

)} {rec.description &&

{rec.description}

} {rec.fleet_balance_assessment &&

{rec.fleet_balance_assessment}

}
) : (

{severity === 'warning' ? 'Fleet imbalance detected — no feasible substitution' : severity === 'success' ? 'Fleet ran balanced — no reassignment needed' : 'No action recommended'}

{rec?.reason &&

{rec.reason}

} {rec?.fleet_balance_assessment &&

{rec.fleet_balance_assessment}

}
)} {/* ── Rider timelines ───────────────────────────────────────────── */} {sortedTimelines.length > 0 && ( {sortedTimelines.map((r) => { const active = String(r.status || '').toLowerCase() === 'active'; const activeMin = Number.parseFloat(r.active_minutes); const idleMin = Number.parseFloat(r.idle_minutes); const totalMin = (Number.isFinite(activeMin) ? activeMin : 0) + (Number.isFinite(idleMin) ? idleMin : 0); const riderUtilPct = totalMin > 0 ? (activeMin / totalMin) * 100 : null; const riderHealth = healthOf(riderUtilPct); const completed = r.completed_orders ?? r.order_count; const displayName = resolveRiderName(riders, r.userid, r.name); return (
{riderUtilPct != null ? `${riderUtilPct.toFixed(0)}% util` : '—'}
{r.started_at || '—'} → {r.finished_at || '—'} {r.kitchen ? ( <>Kitchen: {r.kitchen} ) : ( No primary kitchen )} {completed}/{r.order_count ?? '—'} orders {r.pending_orders ? ` · ${r.pending_orders} pending` : ''} {r.pace_orders_per_hour != null && {Number.parseFloat(r.pace_orders_per_hour).toFixed(1)}/hr pace} {r.active_minutes != null && {Number.parseFloat(r.active_minutes).toFixed(0)} min active} {r.idle_minutes != null && ( 30 ? 'font-medium text-warning' : undefined}> {r.idle_minutes} min idle )} {r.free_window_minutes != null && ( )}
); })}
)} {/* ── Substitution opportunities ────────────────────────────────── */} {subs.length > 0 && ( {subs.map((s, index) => { const idle = s.idle_rider || {}; const relieved = s.most_relieved_rider || {}; const improved = s.fleet_improvement_minutes ?? 0; return (

{resolveRiderName(riders, idle.userid, idle.name)} covers{' '} {s.target_kitchen}

0 ? 'success' : 'outline'} className="shrink-0 text-[10px]"> Fleet {improved > 0 ? '↑' : '•'} {improved} min

{s.travel_to_kitchen_km} km · {s.travel_to_kitchen_minutes} min travel · arrives {s.arrive_at_kitchen} ·{' '} {s.total_orders_transferred} orders transferred · +{s.extra_km_for_idle_rider} km for idle rider

{(relieved.name || relieved.userid != null) && (

Most relieved: {resolveRiderName(riders, relieved.userid, relieved.name)} ( {relieved.original_finish} → {relieved.new_finish}, saves {relieved.time_saved_minutes} min)

)}
); })}
)}
); }