Files
daily_merchant_web/src/components/DispatchView.tsx
2026-08-03 15:36:34 +05:30

774 lines
34 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Dispatch cockpit — integrates live deliveries, rider assignments, and route
* visualization. Reuses the dispatch page stylesheet from the operations console
* and reproduces its DOM structure: header, view-mode tabs, sidebar (KPI + group
* cards), and map centrepiece with Leaflet.
*
* Features:
* • Group deliveries by rider, zone, location, or status
* • Focus on a specific group to see its trip blocks and detailed order cards
* • Map-based route visualization with planned stops (actual GPS awaiting backend)
* • Real-time KPI cards (orders, riders, completion %)
* • Date navigation for historical dispatch view
*/
import React, { useMemo, useState, useEffect } from 'react';
import {
Map as MapIcon,
MapPin,
Bike,
Users,
Phone,
Store,
Truck,
Package,
Ruler,
Wallet,
Crosshair,
Clock,
Utensils,
Mailbox,
StickyNote,
ArrowLeftRight,
ChevronLeft,
ChevronRight,
List,
Play,
} from 'lucide-react';
import {
useFiestaDeliveries,
useFiestaTenantLocations,
useFiestaTenantCustomers,
} from '../services/fiestaQueries';
import {
FIESTA_TENANT_ID,
num as fnum,
str as fstr,
customerName,
customerStoreId,
type Row,
} from '../services/fiestaApi';
import { colorFor } from '../services/dispatchShared';
import DispatchMap, { type MapPoint } from './DispatchMap';
import CustomerDetailPanel from './CustomerDetailPanel';
import './DispatchView.css';
// Legacy direct utilities (will be migrated to dispatchShared)
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 };
}
/**
* Best available area label for a delivery row. `getdeliveries` has NO
* `deliverysuburb` column — reading it left every "areas" count at 0 and hid the
* suburb strip entirely. The feed does carry `locationsuburb` and a full
* `deliveryaddress`, so fall back to the address's locality segment
* ("12 Main St, Peelamedu, Coimbatore, 641004" → "Coimbatore").
*/
function areaOf(r: Row): string {
const direct = fstr(r.deliverysuburb).trim() || fstr(r.locationsuburb).trim();
if (direct) return direct;
const parts = fstr(r.deliveryaddress)
.split(',')
.map((p) => p.trim())
.filter(Boolean)
// Trailing postcode / country segments aren't areas.
.filter((p) => !/^\d{4,6}$/.test(p) && p.toLowerCase() !== 'india');
return parts.length > 1 ? parts[parts.length - 1] : '';
}
/** 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;
}
// ── View modes ──────────────────────────────────────────────────────────────────
// The tab row lives in DispatchHubView; only these two modes are reachable.
type ViewMode = 'stores' | 'customers';
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>;
raw?: any;
/** Customer view: the outlet this customer is registered against, for the card badge. */
storeName?: string;
}
interface DispatchViewProps {
locationid?: number;
tenantId?: number;
date: string;
viewMode: ViewMode;
}
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
const [focusedId, setFocusedId] = useState<string | null>(null);
useEffect(() => {
setFocusedId(null);
}, [viewMode]);
const [customerStoreFilter, setCustomerStoreFilter] = useState<string>('all');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
const [animateNonce, setAnimateNonce] = useState(0);
const [animating, setAnimating] = useState(false);
// Core dispatch data
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const locationsQ = useFiestaTenantLocations(tenantId);
// Customers. Admin (no locationid) gets every customer under the tenant and
// filters client-side; a store user sends its locationid so the backend scopes
// to that outlet server-side via `tenantcustomers.locationid`.
const customersQ = useFiestaTenantCustomers({ tenantid: tenantId, locationid: locationid || 0 });
// Live deliveries only — no sample/demo fallback. When the feed is empty the
// cockpit shows a genuine empty state rather than fabricated riders/stops.
const allRows = deliveriesQ.data ?? [];
const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid;
const rows = useMemo(
() => allRows.filter(inScope),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allRows, locationid],
);
/** locationid → outlet name, for stamping a store badge on customer cards. */
const storeNames = useMemo(() => {
const m = new Map<number, string>();
for (const loc of locationsQ.data ?? []) {
const id = fnum(loc.locationid);
if (id) m.set(id, fstr(loc.locationname).trim() || `Store ${id}`);
}
return m;
}, [locationsQ.data]);
// ── Grouping ────────────────────────────────────────────────────────────────
// Both modes seed from the roster first (every outlet / every customer under
// the scope) and then fold the day's deliveries into those seeds. That's what
// makes an outlet with no deliveries today, or a customer who hasn't ordered
// today, still appear in the list rather than vanishing.
const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>();
const blank = (id: string, name: string): Group => ({
id,
name,
color: colorFor(id),
orders: [],
delivered: 0,
totalKm: 0,
profit: 0,
riders: new Set(),
suburbs: new Map(),
statusCounts: {},
});
if (viewMode === 'stores' && locationsQ.data) {
// Admin: every outlet under the tenant. Store user: only its own, because
// `locationid` is set.
for (const loc of locationsQ.data) {
if (locationid && fnum(loc.locationid) !== locationid) continue;
const id = String(fnum(loc.locationid)).toLowerCase();
map.set(id, blank(id, fstr(loc.locationname) || `Store ${id}`));
}
}
if (viewMode === 'customers' && customersQ.data) {
for (const cust of customersQ.data) {
const storeId = customerStoreId(cust);
// Admin's store dropdown. A store user never sees it (the backend has
// already scoped the list), so this only ever narrows the admin view.
if (customerStoreFilter !== 'all' && String(storeId) !== customerStoreFilter) continue;
const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
map.set(id, {
...blank(id, customerName(cust) || `Customer ${id}`),
raw: cust,
storeName: storeNames.get(storeId),
});
}
}
const keyOf = (r: Row): { id: string; name: string } => {
if (viewMode === 'stores') {
const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown';
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`;
return { id: locId.toLowerCase(), name };
}
const custId = fstr(r.customerid) || fstr(r.deliverycontactno) || 'unknown';
return { id: custId.toLowerCase(), name: customerName(r) || `Customer ${custId}` };
};
for (const r of rows) {
const { id, name } = keyOf(r);
let g = map.get(id);
if (!g) {
// A delivery whose customer isn't in the roster (not linked to this
// tenant, or beyond the page we fetched). Keep it — dropping it would
// hide real work from the board — but honour an active store filter.
if (viewMode === 'customers' && customerStoreFilter !== 'all') continue;
g = blank(id, name);
if (viewMode === 'customers') {
// Stand in for the missing customer record so the card still opens.
// Without a `raw` the detail panel stayed on "Select a customer" no
// matter how many times the card was clicked.
g.raw = {
customerid: fnum(r.customerid),
firstname: fstr(r.deliverycustomer),
contactno: fstr(r.deliverycontactno),
address: fstr(r.deliveryaddress),
tenantlocationid: fnum(r.locationid),
locationname: fstr(r.locationname),
};
g.storeName = storeNames.get(fnum(r.locationid)) || fstr(r.locationname) || undefined;
}
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 = areaOf(r);
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 || a.name.localeCompare(b.name));
}, [rows, viewMode, locationid, locationsQ.data, customersQ.data, customerStoreFilter, storeNames]);
useEffect(() => {
if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) {
setFocusedId(groups[0].id);
}
}, [viewMode, locationid, groups, focusedId]);
const focused = groups.find((g) => g.id === focusedId) ?? null;
// Trip blocks for the focused group, one per rider.
const tripBlocks = useMemo(() => {
if (!focused) return [];
const map = new Map<string, { label: string; color: string; orders: Row[] }>();
for (const r of focused.orders) {
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
const label = fstr(r.ridername) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
let blk = map.get(rid);
if (!blk) { blk = { label, color: colorFor(rid), orders: [] }; map.set(rid, 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);
}
// `step` isn't in the getdeliveries response, so planned order falls back
// to assignment time. Kept in case the backend starts sending it.
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, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop
// for the day (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: customerName(r) || `Order ${fstr(r.orderid)}`,
subtitle: areaOf(r) || 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;
// The roster query matters as much as the delivery feed — gating on deliveries
// alone flashed "No customers found" while the customer list was still loading.
const isLoading =
deliveriesQ.isLoading || (viewMode === 'customers' ? customersQ.isLoading : locationsQ.isLoading);
// KPI scope.
const totalOrders = rows.length;
const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').length;
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">
{/* ── 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={26} /> : <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">CONSOLE</span>
</div>
<span className="sb-header-scope">
<span className="sb-scope-dot" />
{totalOrders} stops
</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-deliveries">
<span className="sb-tile-icon">
<Truck size={16} />
</span>
<div className="sb-tile-body">
<div className="sb-tile-value">
{`${totalDelivered}/${totalOrders}`}
</div>
<div className="sb-tile-label">
Deliveries
</div>
</div>
</div>
</div>
</div>
<div id="riders-panel">
{isLoading ? (
<div className="ph">{viewMode === 'customers' ? 'Loading customers…' : 'Loading dispatch feed…'}</div>
) : focused && viewMode !== 'customers' ? (
<FocusedDetail
focused={focused}
tripBlocks={tripBlocks}
tripSort={tripSort}
setTripSort={setTripSort}
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
fmtTime={fmtTime}
/>
) : groups.length === 0 ? (
<div className="ph">
{viewMode === 'customers'
? (customerStoreFilter !== 'all' ? 'No customers for this store' : 'No customers found')
: 'No stores found'}
</div>
) : (
<>
<div className="ph relative flex items-center justify-between w-full h-8">
<span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
{viewMode === 'customers' && !locationid && locationsQ.data && (
<div className="absolute right-0 flex items-center group/filter cursor-pointer">
<select
className="appearance-none w-36 text-[11px] font-extrabold tracking-wide rounded-md pl-3 pr-8 py-1.5 bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5 hover:bg-slate-50 outline-none focus:ring-2 focus:ring-[#662582]/30 transition-all duration-300 cursor-pointer relative z-0 truncate"
style={{ textOverflow: 'ellipsis' }}
value={customerStoreFilter}
onChange={(e) => {
setCustomerStoreFilter(e.target.value);
setFocusedId(null);
}}
>
<option value="all">All Stores</option>
{locationsQ.data.map(loc => (
<option key={fnum(loc.locationid)} value={String(fnum(loc.locationid))}>
{fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`}
</option>
))}
</select>
<div className="absolute right-2 text-slate-400 pointer-events-none group-hover/filter:text-[#662582] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
)}
</div>
{groups.map((g) => (
<React.Fragment key={g.id}>
{viewMode === 'customers' ? (
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={focusedId === g.id} />
) : (
<StoreCard g={g} onClick={() => setFocusedId(g.id)} />
)}
</React.Fragment>
))}
</>
)}
</div>
</div>
{/* Main Content Area */}
{viewMode === 'customers' ? (
<div id="customer-panel-wrap" style={{ flex: 1, position: 'relative', zIndex: 50, backgroundColor: '#f8fafc', overflow: 'hidden', minWidth: 0 }}>
<style>{`
#customer-panel-wrap * {
margin: revert;
padding: revert;
box-sizing: border-box;
}
`}</style>
<div className="relative h-full w-full flex flex-col">
{focused?.raw ? (
<CustomerDetailPanel customer={focused.raw} onClose={() => setFocusedId(null)} />
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center h-full w-full">
<div className="w-14 h-14 rounded-2xl bg-purple-50 border border-purple-100 text-[#662582] flex items-center justify-center mb-md">
<Users size={24} />
</div>
<p className="font-bold text-sm text-slate-900">Select a customer</p>
<p className="text-xs text-slate-500 mt-1">Choose a customer from the list to view their billing history.</p>
</div>
)}
</div>
</div>
) : (
<div id="map-wrap">
{/* Live Leaflet route map */}
<DispatchMap
points={mapPoints}
route={Boolean(focused)}
routeColor={focused?.color || '#662582'}
start={routeStart}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
animateNonce={animateNonce}
/>
{/* Contextual note overlaid on the map */}
{mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
</div>
) : !focused ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> Select a store 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>
);
}
// ── Store card ───────────────────────────────────────────────────────────────────
// One per outlet. Admin sees every outlet under the tenant, a store user sees
// only its own. Outlets with no deliveries today are deliberately still listed —
// "which of my stores is idle" is the question the admin list has to answer — so
// they render a quiet idle state instead of a row of zeroes.
function StoreCard({ g, onClick }: { g: Group; onClick: () => void }) {
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
const idle = g.orders.length === 0;
return (
<div className={`rcard zone-card${idle ? ' is-idle' : ''}`} onClick={onClick}>
<div className="zone-card-header">
<div className="zone-card-emoji" style={{ color: idle ? '#94a3b8' : g.color }}><Store size={16} /></div>
<div className="zone-card-titles">
<div className="zone-card-name">{g.name}</div>
<div className="zone-card-sub">
{idle
? 'No deliveries today'
: `${g.riders.size} rider${g.riders.size === 1 ? '' : 's'} · ${g.orders.length} ${g.orders.length === 1 ? 'delivery' : 'deliveries'}`}
</div>
</div>
<span className="zone-card-arrow" aria-hidden="true"></span>
</div>
{!idle && (
<>
<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">
{/* Only render a stat the feed can actually populate — the areas and
profit pills used to sit at a permanent 0 for every store. */}
{g.suburbs.size > 0 && (
<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">{g.suburbs.size === 1 ? 'area' : 'areas'}</span>
</span>
)}
{g.totalKm > 0 && (
<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,
tripSort,
setTripSort,
onBack,
fmtTime,
}: {
focused: Group;
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
tripSort: 'planned' | 'time';
setTripSort: (v: 'planned' | 'time') => void;
onBack?: () => void;
fmtTime: (raw: unknown) => string;
}) {
return (
<>
{onBack && (
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
</button>
)}
{tripBlocks.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center h-48 border-2 border-dashed border-slate-200 rounded-xl mt-4 bg-slate-50/50">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3">
<Package size={20} />
</div>
<p className="font-bold text-slate-700 text-sm">No deliveries for {focused.name}</p>
<p className="text-xs text-slate-500 mt-1">Nothing has been dispatched from this store on the selected date.</p>
</div>
)}
{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>
{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} /> {customerName(o) || 'Customer'}</div>
{fstr(o.pickupcustomer) && (
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
)}
{fstr(o.deliveryaddress) && (
<div className="zone-order-line"><MapPin size={11} /> {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>
))}
</>
);
}
// ── Customer card ───────────────────────────────────────────────────────────────────
function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) {
const customer = g.raw;
// g.name is already resolved through customerName(); reading `customername` off
// the raw row (a column gettenantcustomers doesn't return) made every card in
// the list read "Unknown Customer".
const name = g.name;
const phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : '';
const deliveries = g.orders.length;
return (
<div
className={`rcard transition-all duration-300 cursor-pointer overflow-hidden relative group ${
isSelected
? 'border-purple-300 shadow-md bg-purple-50/50'
: 'bg-white border-slate-200 hover:border-purple-200 hover:shadow-sm'
}`}
onClick={onClick}
>
{isSelected && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#662582] rounded-l-md"></div>
)}
<div className="flex items-center gap-4 p-3.5 pl-4 border-none">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-colors ${
isSelected ? 'bg-[#662582] text-white shadow-md' : 'bg-slate-100 text-slate-500 group-hover:bg-purple-100 group-hover:text-[#662582]'
}`}>
<Users size={20} strokeWidth={isSelected ? 2 : 1.5} />
</div>
<div className="min-w-0 flex-1">
<div className={`font-bold text-sm truncate tracking-tight ${isSelected ? 'text-[#0f172a]' : 'text-slate-800'}`}>
{name || 'Unknown Customer'}
</div>
{phone && (
<div className={`text-xs truncate font-medium mt-0.5 flex items-center gap-1 ${isSelected ? 'text-[#662582]' : 'text-slate-500'}`}>
<Phone size={10} /> {phone}
</div>
)}
{(g.storeName || deliveries > 0) && (
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
{g.storeName && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">
<Store size={9} /> {g.storeName}
</span>
)}
{deliveries > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<Truck size={9} /> {deliveries} today
</span>
)}
</div>
)}
</div>
</div>
</div>
);
}