774 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|