Files
doormilxpress_astryx/src/components/doormile/BatchAnalysis.jsx

449 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}