updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View File

@@ -0,0 +1,448 @@
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>
);
}