update on the user page regardinga the dispatch and order page and the deliveries page

This commit is contained in:
Gokul
2026-06-09 16:01:26 +05:30
parent 9f25c5f60a
commit d8c1517239
16 changed files with 14328 additions and 43 deletions

View File

@@ -0,0 +1,727 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Dispatch page — a faithful port of the operations console's dispatch cockpit
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone
* cards + per-trip order cards), and the `#map-wrap` centrepiece.
*
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
* external optimisation services. Those need a mapping stack + dispatch backends
* this tenant doesn't expose, so the `#map-wrap` plots the real planned stop
* order and marks the live-GPS / compare / AI-assign layers as awaiting backend —
* no fabricated telemetry. Everything else is driven by the live Fiesta feed.
*/
import React, { useMemo, useState } from 'react';
import {
Map as MapIcon,
MapPin,
Bike,
Globe,
Info,
Package,
Ruler,
Wallet,
Crosshair,
Clock,
Utensils,
Mailbox,
StickyNote,
ArrowLeftRight,
Calendar,
ChevronLeft,
ChevronRight,
List,
Play,
PlugZap,
} from 'lucide-react';
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData';
import DispatchMap, { type MapPoint } from './DispatchMap';
import './DispatchView.css';
// ── Status colours (match the console palette) ───────────────────────────────────
const STATUS_HEX: Record<string, string> = {
pending: '#f59e0b',
accepted: '#6366f1',
arrived: '#06b6d4',
picked: '#8b5cf6',
active: '#14b8a6',
skipped: '#f97316',
delivered: '#22c55e',
cancelled: '#ef4444',
};
function statusStyle(s: string): React.CSSProperties {
const hex = STATUS_HEX[s.toLowerCase()] || '#64748b';
return { background: `${hex}1f`, color: hex };
}
// Stable rider/zone colour.
const COLORS = ['#3b82f6', '#a855f7', '#10b981', '#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#ec4899', '#f97316', '#06b6d4'];
function colorFor(key: string): string {
let hash = 0;
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash);
return COLORS[Math.abs(hash) % COLORS.length];
}
/** Drop coordinates from a delivery row (several field spellings), or null. */
function dropLatLon(r: Row): [number, number] | null {
const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude);
const lon = fnum(r.droplon) || fnum(r.deliverylong) || fnum(r.deliverylon) || fnum(r.deliverylongitude);
return lat && lon ? [lat, lon] : null;
}
/** Pickup/hub coordinates from a delivery row, or null. */
function pickupLatLon(r: Row): [number, number] | null {
const lat = fnum(r.pickuplat) || fnum(r.pickuplatitude);
const lon = fnum(r.pickuplong) || fnum(r.picklongitude) || fnum(r.pickuplon);
return lat && lon ? [lat, lon] : null;
}
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (89, 12:3016, after 19) are intentional.
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
const BATCHES: Array<{ id: BatchId; label: string; range: string }> = [
{ id: 'all', label: 'All', range: 'Full day' },
{ id: 'morning', label: 'Morning', range: '12 AM 8 AM' },
{ id: 'afternoon', label: 'Afternoon', range: '9 AM 12:30 PM' },
{ id: 'evening', label: 'Evening', range: '4 PM 7 PM' },
];
function rowHourFrac(r: Row): number | null {
const raw = fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate);
const m = raw.match(/[ T](\d{1,2}):(\d{2})/);
if (!m) return null;
return Number(m[1]) + Number(m[2]) / 60;
}
function inBatch(r: Row, b: BatchId): boolean {
if (b === 'all') return true;
const h = rowHourFrac(r);
if (h == null) return false;
if (b === 'morning') return h >= 0 && h < 8;
if (b === 'afternoon') return h >= 9 && h < 12.5;
return h >= 16 && h < 19; // evening
}
function initialBatch(): BatchId {
const h = new Date().getHours();
if (h >= 0 && h < 8) return 'morning';
if (h >= 9 && h < 12.5) return 'afternoon';
if (h >= 16 && h < 19) return 'evening';
return 'all';
}
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info';
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
{ id: 'kitchens', label: 'By Location', icon: MapPin },
{ id: 'zones', label: 'By Zone', icon: MapIcon },
{ id: 'riders', label: 'By Rider', icon: Bike },
{ id: 'all', label: 'All Routes', icon: Globe },
{ id: 'rider-info', label: 'Rider Info', icon: Info },
];
interface Group {
id: string;
name: string;
color: string;
orders: Row[];
delivered: number;
totalKm: number;
profit: number;
riders: Set<string>;
suburbs: Map<string, number>;
statusCounts: Record<string, number>;
}
interface DispatchViewProps {
locationid?: number;
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function DispatchView({ locationid }: DispatchViewProps) {
const today = new Date();
const [date, setDate] = useState<string>(ymd(today));
const [batch, setBatch] = useState<BatchId>(initialBatch());
const [viewMode, setViewMode] = useState<ViewMode>('riders');
const [focusedId, setFocusedId] = useState<string | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
const [animateNonce, setAnimateNonce] = useState(0);
const [animating, setAnimating] = useState(false);
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
// Sample-data fallback: when the live feed returns nothing, render the demo set
// so the cockpit isn't blank. The header labels it "Sample data" so it's never
// mistaken for live (see services/dispatchMockData.ts).
const liveRows = deliveriesQ.data ?? [];
const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0;
const allRows = usingMock ? MOCK_DELIVERIES : liveRows;
// Sample rows aren't tied to the signed-in store, so skip the outlet filter for them.
const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid;
const rows = useMemo(
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allRows, batch, locationid, usingMock],
);
const batchCounts = useMemo(() => {
const acc: Record<string, number> = { all: 0, morning: 0, afternoon: 0, evening: 0 };
const scoped = allRows.filter(inScope);
for (const b of BATCHES) acc[b.id] = scoped.filter((r) => inBatch(r, b.id)).length;
return acc;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allRows, locationid, usingMock]);
// ── Grouping ────────────────────────────────────────────────────────────────
const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>();
const keyOf = (r: Row): { id: string; name: string } => {
if (viewMode === 'riders' || viewMode === 'rider-info') {
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
}
if (viewMode === 'kitchens') {
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
return { id: name.toLowerCase(), name };
}
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
return { id: name.toLowerCase(), name };
};
for (const r of rows) {
const { id, name } = keyOf(r);
let g = map.get(id);
if (!g) {
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} };
map.set(id, g);
}
g.orders.push(r);
const st = fstr(r.orderstatus).toLowerCase();
if (st === 'delivered') g.delivered += 1;
g.statusCounts[st] = (g.statusCounts[st] ?? 0) + 1;
g.totalKm += fnum(r.kms);
g.profit += fnum(r.profit);
const rid = fstr(r.userid) || fstr(r.ridername);
if (rid) g.riders.add(rid);
const sub = fstr(r.deliverysuburb);
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
}
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length);
}, [rows, viewMode]);
const focused = groups.find((g) => g.id === focusedId) ?? null;
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
const tripBlocks = useMemo(() => {
if (!focused) return [];
const map = new Map<string, { label: string; color: string; orders: Row[] }>();
for (const r of focused.orders) {
let key: string;
let label: string;
let color: string;
if (groupedByRider) {
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
key = rid;
label = fstr(r.ridername) || fstr(r.username) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
color = colorFor(rid);
} else {
key = fstr(r.trip_number) || '1';
label = `Trip ${key}`;
color = focused.color;
}
let blk = map.get(key);
if (!blk) { blk = { label, color, orders: [] }; map.set(key, blk); }
blk.orders.push(r);
}
const blocks = Array.from(map.values());
for (const blk of blocks) {
blk.orders.sort((a, b) => {
if (tripSort === 'time') {
const ta = fstr(a.deliverytime) || fstr(a.expecteddeliverytime);
const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime);
return ta.localeCompare(tb);
}
const sa = fnum(a.step);
const sb = fnum(b.step);
if (sa && sb && sa !== sb) return sa - sb;
return fstr(a.assigntime).localeCompare(fstr(b.assigntime));
});
}
return blocks;
}, [focused, groupedByRider, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop
// in the wave (coloured per rider). Rows without coordinates are skipped.
const mapPoints = useMemo<MapPoint[]>(() => {
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
const out: MapPoint[] = [];
src.forEach((r, i) => {
const ll = dropLatLon(r);
if (!ll) return;
out.push({
id: fstr(r.deliveryid) || fstr(r.orderid) || String(i),
lat: ll[0],
lon: ll[1],
step: fnum(r.step) || i + 1,
color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'),
title: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`,
subtitle: fstr(r.deliverysuburb) || fstr(r.deliveryaddress),
status: fstr(r.orderstatus),
raw: r,
});
});
return out;
}, [focused, tripBlocks, rows]);
// Route start = the focused group's pickup/hub (so the road route originates there).
const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0];
const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null;
// KPI scope.
const totalOrders = rows.length;
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length;
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
// Date chip helpers.
const isToday = date === ymd(today);
const dateObj = new Date(`${date}T00:00:00`);
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
const shiftDate = (delta: number) => {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + delta);
if (d > today) return;
setDate(ymd(d));
setFocusedId(null);
};
const fmtTime = (raw: unknown): string => {
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
return m ? `${m[1]}:${m[2]}` : '';
};
return (
<div style={{ height: '100%', minHeight: 0 }}>
<div className="dispatch-container embedded">
{/* ── Header ── */}
<div id="hdr">
<div className="logo">
<div className="logo-badge">D</div>
<div className="logo-name">Dispatch</div>
<div className="logo-city-wrap">
<span className="logo-city" style={{ cursor: 'default' }}>
<MapPin size={13} />
<span className="logo-city-text">Coimbatore</span>
</span>
</div>
</div>
<div className="hdr-stats">
{deliveriesQ.isLoading ? (
<span className="live-status">
<span className="live-dot" /> Syncing
</span>
) : deliveriesQ.isError ? (
<span className="live-status live-status-error">
<span className="live-dot error" /> Offline
</span>
) : usingMock ? (
<span className="live-status" title="No live deliveries for this day — showing sample data">
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders
</span>
) : (
<span className="live-status live-status-ready">
<span className="live-dot ready" /> Live · {totalOrders} orders
</span>
)}
<div className={`date-chip${isToday ? ' is-today' : ''}`}>
<button className="date-chip-nav" onClick={() => shiftDate(-1)} title="Previous day">
<ChevronLeft size={16} />
</button>
<div className="date-chip-main" style={{ position: 'relative' }}>
<span className="date-chip-icon"><Calendar size={14} /></span>
<span className="date-chip-text">
<span className="date-chip-label">
Date {isToday && <span className="date-chip-today-pill">Today</span>}
</span>
<span className="date-chip-value">{prettyDate}</span>
</span>
<input
type="date"
value={date}
max={ymd(today)}
onChange={(e) => { setDate(e.target.value); setFocusedId(null); }}
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
aria-label="Pick date"
/>
</div>
<button className="date-chip-nav" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
{/* ── View-mode tabs ── */}
<div id="strat-row">
{VIEW_TABS.map((t) => {
const Icon = t.icon;
return (
<button
key={t.id}
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`}
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
>
<span className="sbt-icon"><Icon size={15} /></span>
{t.label}
</button>
);
})}
</div>
{/* ── Batch / wave bar ── */}
<div id="batch-row">
<span className="batch-label">Batch</span>
<div className="batch-scroll">
{BATCHES.map((b) => (
<button
key={b.id}
className={`batch-btn batch-slot ${batch === b.id ? 'active' : ''}`}
onClick={() => { setBatch(b.id); setFocusedId(null); }}
title={`${b.label} (${b.range})`}
>
<span className="batch-btn-label">{b.label}</span>
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
</button>
))}
</div>
</div>
{/* ── Body ── */}
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
<button
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
onClick={() => setSidebarCollapsed((c) => !c)}
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
>
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
{/* Sidebar */}
<div id="sidebar">
<div className="sb-header">
<div className="sb-header-top">
<div className="sb-header-title">
<span className="sb-title-bar" aria-hidden="true" />
<span className="sb-title-text">RIDER DISPATCH</span>
</div>
<span className="sb-header-scope">
<span className="sb-scope-dot" />
{scopeLabel}
</span>
</div>
<div className="sb-header-tiles">
<div className="sb-tile sb-tile-orders">
<span className="sb-tile-icon"><Package size={16} /></span>
<div className="sb-tile-body">
<div className="sb-tile-value">{totalOrders}</div>
<div className="sb-tile-label">Orders</div>
</div>
</div>
<div className="sb-tile sb-tile-riders">
<span className="sb-tile-icon"><Bike size={16} /></span>
<div className="sb-tile-body">
<div className="sb-tile-value">{activeRiders}{fleetSize ? `/${fleetSize}` : ''}</div>
<div className="sb-tile-label">Riders</div>
</div>
</div>
</div>
</div>
<div id="riders-panel">
{deliveriesQ.isLoading ? (
<div className="ph">Loading dispatch feed</div>
) : focused ? (
<FocusedDetail
focused={focused}
tripBlocks={tripBlocks}
groupedByRider={groupedByRider}
tripSort={tripSort}
setTripSort={setTripSort}
onBack={() => setFocusedId(null)}
fmtTime={fmtTime}
/>
) : groups.length === 0 ? (
<div className="ph">No deliveries in this wave</div>
) : (
<>
<div className="ph">
{viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length})
</div>
{groups.map((g) => (
<React.Fragment key={g.id}>
{viewMode === 'riders' || viewMode === 'rider-info'
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
</React.Fragment>
))}
</>
)}
</div>
</div>
{/* Map area */}
<div id="map-wrap">
{/* Live Leaflet route map */}
<DispatchMap
points={mapPoints}
route={Boolean(focused)}
routeColor={focused?.color || '#581c87'}
start={routeStart}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
animateNonce={animateNonce}
/>
{/* Contextual note overlaid on the map */}
{viewMode === 'rider-info' ? (
<div className="dmp-overlay-note">
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend map shows planned drops.
</div>
) : mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
</div>
) : !focused ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route.
</div>
) : null}
{/* bottom-right overlay controls (gated) */}
<div id="ov-br">
<button
className={`sbt ${animating ? 'active' : ''}`}
disabled={!focused || mapPoints.length < 2}
onClick={() => {
if (!focused || mapPoints.length < 2) return;
setAnimating(true);
setAnimateNonce((n) => n + 1);
window.setTimeout(() => setAnimating(false), 2300);
}}
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
>
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
</button>
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
</button>
</div>
</div>
</div>
</div>
</div>
);
}
// ── Rider card ───────────────────────────────────────────────────────────────────
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) {
const total = g.orders.length;
const percent = total ? Math.round((g.delivered / total) * 100) : 0;
const isDone = total > 0 && g.delivered === total;
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed';
const trips = new Set(g.orders.map((o) => fstr(o.trip_number) || '1')).size;
return (
<div className="rcard" onClick={onClick}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${g.color}18`, color: g.color }}>
<Bike size={18} />
</div>
<div className="rcard-info">
<div className="rcard-name">{g.name}</div>
<div className="rcard-zone">{zoneName} · {trips} trip{trips > 1 ? 's' : ''}</div>
</div>
<div className={`rcard-badge ${isDone ? 'is-done' : ''}`}>{g.delivered}/{total}</div>
</div>
<div className="bar-bg">
<div className="bar-fg" style={{ width: `${percent}%`, background: g.color }} />
</div>
<div className="rcard-meta">
<span><Ruler size={11} /> {g.totalKm.toFixed(1)} km</span>
{g.profit > 0 && <span><Wallet size={11} /> {g.profit.toLocaleString('en-IN')}</span>}
</div>
<div className="step-ids">
{g.orders.slice(0, 16).map((o, i) => (
<span key={fstr(o.orderid) || i} className="step-id">S{fnum(o.step) || i + 1}</span>
))}
</div>
</div>
);
}
// ── Zone card (also used for By Location / All Routes) ───────────────────────────
function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
return (
<div className="rcard zone-card" onClick={onClick}>
<div className="zone-card-header">
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div>
<div className="zone-card-titles">
<div className="zone-card-name">{g.name}</div>
<div className="zone-card-sub">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</div>
</div>
<span className="zone-card-arrow" aria-hidden="true"></span>
</div>
{g.orders.length > 0 && (
<div className="zone-progress-row">
<div className="zone-status-bar">
{Object.entries(g.statusCounts).map(([s, c]) => (
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
))}
</div>
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
</div>
)}
<div className="zone-stat-pills">
<span className="zone-stat-pill">
<span className="zone-stat-icon"><MapPin size={12} /></span>
<span className="zone-stat-value">{g.suburbs.size}</span>
<span className="zone-stat-label">areas</span>
</span>
<span className="zone-stat-pill">
<span className="zone-stat-icon"><Ruler size={12} /></span>
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
<span className="zone-stat-label">km</span>
</span>
{g.profit > 0 && (
<span className="zone-stat-pill">
<span className="zone-stat-icon"><Wallet size={12} /></span>
<span className="zone-stat-value">{g.profit.toLocaleString('en-IN')}</span>
<span className="zone-stat-label">profit</span>
</span>
)}
</div>
{suburbs.length > 0 && (
<div className="zone-card-suburbs">
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
</div>
)}
</div>
);
}
// ── Focused detail (trip blocks + order cards) ───────────────────────────────────
function FocusedDetail({
focused,
tripBlocks,
groupedByRider,
tripSort,
setTripSort,
onBack,
fmtTime,
}: {
focused: Group;
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
groupedByRider: boolean;
tripSort: 'planned' | 'time';
setTripSort: (v: 'planned' | 'time') => void;
onBack: () => void;
fmtTime: (raw: unknown) => string;
}) {
return (
<>
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
</button>
{tripBlocks.map((blk, bi) => (
<div className="trip-block" key={bi}>
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
<span className="th-badge" style={{ background: blk.color }}>{blk.label}</span>
<span className="trip-stats">
<span><MapPin size={11} /> {blk.orders.length} stops</span>
<span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
</span>
<div className="trip-sort-toggle" role="group">
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}>
<List size={12} /> <span>Planned</span>
</button>
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}>
<Clock size={12} /> <span>By time</span>
</button>
</div>
</div>
<div className="zone-order-grid">
{blk.orders.map((o, i) => {
const st = fstr(o.orderstatus).toLowerCase();
const step = fnum(o.step) || i + 1;
const actual = fstr(o.deliverytime);
const expected = fstr(o.expecteddeliverytime);
const profit = fnum(o.profit);
const km = fnum(o.kms);
const charge = fnum(o.deliverycharge) || fnum(o.deliverycharges);
return (
<div className={`zone-order-card ${st === 'delivered' ? '' : 'is-pending-time'}`} key={fstr(o.deliveryid) || fstr(o.orderid) || i}>
<div className="zone-order-card-head">
<div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
<div className="zone-order-id-block">
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div>
{groupedByRider && fstr(o.ridername) && (
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
)}
</div>
<div className="zone-order-status-stack">
{st && <span className="zone-order-status" style={statusStyle(st)}>{st}</span>}
{(actual || expected) && (
<span className={`zone-order-time ${actual ? '' : 'is-expected'}`}>
<Clock size={10} /> {fmtTime(actual || expected)}
</span>
)}
</div>
</div>
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</div>
{fstr(o.pickupcustomer) && (
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
)}
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && (
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div>
)}
{fstr(o.ordernotes) && (
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
)}
<div className="zone-order-stats">
<span className="zone-order-chip"><Ruler size={10} /> {km.toFixed(1)} km</span>
{profit !== 0 && (
<span className={`zone-order-chip ${profit < 0 ? 'is-loss' : 'is-profit'}`}>
<Wallet size={10} /> {Math.abs(profit).toLocaleString('en-IN')}
</span>
)}
{charge > 0 && <span className="zone-order-chip">{charge} chg</span>}
<span className="zone-order-chip zone-order-trip"><Crosshair size={10} /> S{step}</span>
</div>
</div>
);
})}
</div>
</div>
))}
</>
);
}