449 lines
22 KiB
JavaScript
449 lines
22 KiB
JavaScript
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 (
|
||
<Stack space="default">
|
||
{/* ── Window picker ────────────────────────────────────────────────── */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{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 (
|
||
<button
|
||
key={w.key}
|
||
type="button"
|
||
onClick={() => !loading && openWindow(w.key)}
|
||
disabled={loading}
|
||
className={cn(
|
||
'flex min-w-[10rem] flex-col items-start gap-0.5 rounded-xl border px-3.5 py-2.5 text-left transition-colors',
|
||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40',
|
||
active
|
||
? 'border-brand bg-brand-tint/60'
|
||
: failed
|
||
? 'border-destructive/30 bg-destructive-muted'
|
||
: 'border-border bg-surface hover:border-brand/40 hover:bg-brand-tint/30'
|
||
)}
|
||
>
|
||
<span className="flex w-full items-center justify-between gap-2">
|
||
<span className="text-body-sm font-semibold text-ink-1">{w.label}</span>
|
||
<Badge variant={failed ? 'destructive' : result ? 'success' : 'outline'} className="text-[10px]">
|
||
{loading ? 'Loading…' : failed ? 'Failed' : result ? `✓ ${result.fetchedAt}` : 'Analyse'}
|
||
</Badge>
|
||
</span>
|
||
<span className="text-caption text-ink-4">{w.timeRange}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* ── Result ───────────────────────────────────────────────────────── */}
|
||
{!activeKey ? (
|
||
<EmptyState icon={Sparkles} size="sm" title="Pick a window" description="Choose a batch above to see how the solver rates it." />
|
||
) : isLoadingActive && !cached ? (
|
||
<EmptyState icon={RefreshCw} size="sm" title={`Analysing ${activeMeta?.label}…`} />
|
||
) : !cached ? null : raw?.success === false ? (
|
||
<Alert tone="destructive" title={raw?.error?.code || 'Request failed'}>
|
||
{raw?.error?.message || 'The solver returned an error.'}
|
||
{raw?.request_id && <p className="mt-1 text-caption text-ink-4">request_id: {raw.request_id}</p>}
|
||
</Alert>
|
||
) : (
|
||
<BatchAnalysisResult
|
||
meta={activeMeta}
|
||
raw={raw}
|
||
fetchedAt={cached.fetchedAt}
|
||
riders={riders}
|
||
isLoading={isLoadingActive}
|
||
onRefresh={() => openWindow(activeKey, { force: true })}
|
||
/>
|
||
)}
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Stack space="default">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div>
|
||
<p className="text-body font-semibold text-ink-1">
|
||
{meta?.label} batch
|
||
{raw.date && <span className="ml-1.5 font-normal text-ink-3">· {raw.date}</span>}
|
||
{win.from && win.to && (
|
||
<span className="ml-1.5 font-normal text-ink-3">
|
||
· {win.from} – {win.to}
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-caption text-ink-3">
|
||
Fetched at {fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'}
|
||
</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={onRefresh} loading={isLoading}>
|
||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> Refresh
|
||
</Button>
|
||
</div>
|
||
|
||
{/* ── Hero strip ─────────────────────────────────────────────────── */}
|
||
<Grid cols={3}>
|
||
<KpiCard icon={Package} value={number(fleet.total_orders, '—')} label="Total orders" tone="brand" index={0} />
|
||
<KpiCard
|
||
icon={Bike}
|
||
value={number(fleet.total_riders, '—')}
|
||
label={fleet.orders_per_rider_avg != null ? `Active riders · ${fleet.orders_per_rider_avg} avg/rider` : 'Active riders'}
|
||
tone="navy"
|
||
index={1}
|
||
/>
|
||
<KpiCard
|
||
icon={Clock}
|
||
value={fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min` : '—'}
|
||
label={fleet.fleet_start && fleet.fleet_done ? `Fleet window · ${fleet.fleet_start} → ${fleet.fleet_done}` : 'Fleet window'}
|
||
tone="accent"
|
||
index={2}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* ── Fleet health ───────────────────────────────────────────────── */}
|
||
<Surface radius="xl" padding="lg">
|
||
<SectionHeader title="Fleet health" className="mb-3" />
|
||
<Grid cols={4}>
|
||
<div className="rounded-lg bg-surface-subtle p-3">
|
||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||
{utilSafe != null ? `${utilSafe.toFixed(0)}%` : '—'}
|
||
</p>
|
||
<p className="text-caption text-ink-3">Avg utilisation</p>
|
||
<Badge variant={HEALTH_TONE[utilHealth]} className="mt-1 text-[10px]">
|
||
{HEALTH_LABEL[utilHealth]}
|
||
</Badge>
|
||
</div>
|
||
<div className="rounded-lg bg-surface-subtle p-3">
|
||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||
{fleet.load_balance_stdev != null ? Number.parseFloat(fleet.load_balance_stdev).toFixed(2) : '—'}
|
||
</p>
|
||
<p className="text-caption text-ink-3">Load balance σ</p>
|
||
<p className="mt-1 text-[11px] text-ink-4">
|
||
{fleet.load_balance_stdev != null
|
||
? Number.parseFloat(fleet.load_balance_stdev) > 1.0
|
||
? 'Uneven workload'
|
||
: 'Balanced'
|
||
: 'Stdev of orders/rider'}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg bg-surface-subtle p-3">
|
||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||
{fleet.finish_time_spread_minutes != null ? `${fleet.finish_time_spread_minutes} min` : '—'}
|
||
</p>
|
||
<p className="text-caption text-ink-3">Finish spread</p>
|
||
<p className="mt-1 text-[11px] text-ink-4">
|
||
{fleet.finish_time_stdev_minutes != null
|
||
? `σ ${Number.parseFloat(fleet.finish_time_stdev_minutes).toFixed(1)} min`
|
||
: 'First → last finish'}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg bg-surface-subtle p-3">
|
||
<p className="text-heading-sm font-semibold tabular-nums text-ink-1">
|
||
{fleet.avg_active_minutes != null ? `${Number.parseFloat(fleet.avg_active_minutes).toFixed(0)} min` : '—'}
|
||
</p>
|
||
<p className="text-caption text-ink-3">Avg active</p>
|
||
<p className="mt-1 text-[11px] text-ink-4">Per rider, this batch</p>
|
||
</div>
|
||
</Grid>
|
||
</Surface>
|
||
|
||
{/* ── Top recommendation ────────────────────────────────────────── */}
|
||
{hasRec ? (
|
||
<Alert tone="info" icon={Sparkles} title={(rec.action || 'Recommendation').replaceAll('_', ' ')}>
|
||
<p>
|
||
<strong>{resolveRiderName(riders, rec.idle_rider_id, rec.idle_rider_name)}</strong>
|
||
{rec.primary_kitchen && (
|
||
<>
|
||
{' '}
|
||
· primary kitchen <strong>{rec.primary_kitchen}</strong>
|
||
</>
|
||
)}
|
||
{rec.second_kitchen && (
|
||
<>
|
||
{' '}
|
||
→ also serve <strong>{rec.second_kitchen}</strong> after {rec.second_kitchen_dispatch_after || '—'}
|
||
</>
|
||
)}
|
||
</p>
|
||
{rec.fleet_improvement_minutes != null && (
|
||
<p className="mt-1 text-caption">
|
||
Fleet improves by {rec.fleet_improvement_minutes} min if this substitution runs.
|
||
</p>
|
||
)}
|
||
{rec.description && <p className="mt-1 text-caption">{rec.description}</p>}
|
||
{rec.fleet_balance_assessment && <p className="mt-1 text-caption text-ink-3">{rec.fleet_balance_assessment}</p>}
|
||
</Alert>
|
||
) : (
|
||
<Alert tone={severity} icon={severity === 'warning' ? AlertTriangle : severity === 'success' ? CheckCircle2 : Info}>
|
||
<p className="font-medium">
|
||
{severity === 'warning'
|
||
? 'Fleet imbalance detected — no feasible substitution'
|
||
: severity === 'success'
|
||
? 'Fleet ran balanced — no reassignment needed'
|
||
: 'No action recommended'}
|
||
</p>
|
||
{rec?.reason && <p className="mt-1 text-caption">{rec.reason}</p>}
|
||
{rec?.fleet_balance_assessment && <p className="mt-1 text-caption text-ink-3">{rec.fleet_balance_assessment}</p>}
|
||
</Alert>
|
||
)}
|
||
|
||
{/* ── Rider timelines ───────────────────────────────────────────── */}
|
||
{sortedTimelines.length > 0 && (
|
||
<Surface radius="xl" padding="lg">
|
||
<SectionHeader
|
||
title="Rider timelines"
|
||
subtitle="Sorted by latest finish — bottleneck riders first."
|
||
className="mb-3"
|
||
/>
|
||
<Stack space="xs">
|
||
{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 (
|
||
<div key={r.userid} className="rounded-lg bg-surface-subtle p-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<Bike className="h-3.5 w-3.5 shrink-0 text-ink-3" aria-hidden="true" />
|
||
<span className="truncate text-body-sm font-medium text-ink-1">{displayName}</span>
|
||
<span className="shrink-0 text-caption text-ink-4">#{r.userid}</span>
|
||
<Badge variant={active ? 'success' : 'outline'} className="shrink-0 text-[10px]">
|
||
{r.status || '—'}
|
||
</Badge>
|
||
</div>
|
||
<Badge variant={HEALTH_TONE[riderHealth]} className="shrink-0 text-[10px]">
|
||
{riderUtilPct != null ? `${riderUtilPct.toFixed(0)}% util` : '—'}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="mt-2 flex flex-wrap gap-3 text-caption text-ink-3">
|
||
<span>
|
||
{r.started_at || '—'} → {r.finished_at || '—'}
|
||
</span>
|
||
<span>
|
||
{r.kitchen ? (
|
||
<>Kitchen: {r.kitchen}</>
|
||
) : (
|
||
<span className="text-ink-4">No primary kitchen</span>
|
||
)}
|
||
</span>
|
||
<span>
|
||
{completed}/{r.order_count ?? '—'} orders
|
||
{r.pending_orders ? ` · ${r.pending_orders} pending` : ''}
|
||
</span>
|
||
{r.pace_orders_per_hour != null && <span>{Number.parseFloat(r.pace_orders_per_hour).toFixed(1)}/hr pace</span>}
|
||
{r.active_minutes != null && <span>{Number.parseFloat(r.active_minutes).toFixed(0)} min active</span>}
|
||
{r.idle_minutes != null && (
|
||
<span className={r.idle_minutes > 30 ? 'font-medium text-warning' : undefined}>
|
||
{r.idle_minutes} min idle
|
||
</span>
|
||
)}
|
||
{r.free_window_minutes != null && (
|
||
<span className="flex items-center gap-1">
|
||
<Timer className="h-3 w-3" aria-hidden="true" />
|
||
{Number.parseFloat(r.free_window_minutes).toFixed(0)} min free before next batch
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Surface>
|
||
)}
|
||
|
||
{/* ── Substitution opportunities ────────────────────────────────── */}
|
||
{subs.length > 0 && (
|
||
<Surface radius="xl" padding="lg">
|
||
<SectionHeader
|
||
title="Substitution opportunities"
|
||
subtitle={`${subs.length} way${subs.length === 1 ? '' : 's'} an idle rider could relieve a busy one.`}
|
||
icon={Users}
|
||
className="mb-3"
|
||
/>
|
||
<Stack space="xs">
|
||
{subs.map((s, index) => {
|
||
const idle = s.idle_rider || {};
|
||
const relieved = s.most_relieved_rider || {};
|
||
const improved = s.fleet_improvement_minutes ?? 0;
|
||
return (
|
||
<div key={index} className="rounded-lg bg-surface-subtle p-3">
|
||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||
<p className="text-body-sm text-ink-1">
|
||
<strong>{resolveRiderName(riders, idle.userid, idle.name)}</strong> covers{' '}
|
||
<strong>{s.target_kitchen}</strong>
|
||
</p>
|
||
<Badge variant={improved > 0 ? 'success' : 'outline'} className="shrink-0 text-[10px]">
|
||
Fleet {improved > 0 ? '↑' : '•'} {improved} min
|
||
</Badge>
|
||
</div>
|
||
<p className="mt-1 text-caption text-ink-3">
|
||
{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
|
||
</p>
|
||
{(relieved.name || relieved.userid != null) && (
|
||
<p className="mt-1 flex items-center gap-1 text-caption text-success">
|
||
Most relieved: <strong>{resolveRiderName(riders, relieved.userid, relieved.name)}</strong> (
|
||
{relieved.original_finish} → {relieved.new_finish}, saves {relieved.time_saved_minutes} min)
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Surface>
|
||
)}
|
||
</Stack>
|
||
);
|
||
}
|