console update

This commit is contained in:
2026-08-03 15:36:34 +05:30
parent 8deaf8513b
commit 13db63e219
10 changed files with 395 additions and 254 deletions

View File

@@ -22,10 +22,9 @@ import {
Map as MapIcon,
MapPin,
Bike,
Store,
Users,
Phone,
ShoppingBag,
Store,
Truck,
Package,
Ruler,
@@ -36,7 +35,6 @@ import {
Mailbox,
StickyNote,
ArrowLeftRight,
Calendar,
ChevronLeft,
ChevronRight,
List,
@@ -44,20 +42,19 @@ import {
} from 'lucide-react';
import {
useFiestaDeliveries,
useFiestaRiders,
useFiestaRiderPeriodicLogs,
useFiestaTenantLocations,
useFiestaTenantCustomers,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import {
colorFor,
getStatusStyle,
STATUS_STYLES,
extractTimeOnly,
} from '../services/dispatchShared';
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 RiderTelemetryPanel from './RiderTelemetryPanel';
import CustomerDetailPanel from './CustomerDetailPanel';
import './DispatchView.css';
@@ -78,6 +75,25 @@ function statusStyle(s: string): React.CSSProperties {
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);
@@ -92,12 +108,9 @@ function pickupLatLon(r: Row): [number, number] | null {
return lat && lon ? [lat, lon] : null;
}
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
type ViewMode = 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
{ id: 'stores', label: 'By Store', icon: Store },
{ id: 'customers', label: 'By Customer', icon: Users },
];
// ── View modes ──────────────────────────────────────────────────────────────────
// The tab row lives in DispatchHubView; only these two modes are reachable.
type ViewMode = 'stores' | 'customers';
interface Group {
id: string;
@@ -111,21 +124,19 @@ interface Group {
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: 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
viewMode: ViewMode;
}
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, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
const [focusedId, setFocusedId] = useState<string | null>(null);
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
useEffect(() => {
setFocusedId(null);
@@ -140,16 +151,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
// 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 });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Rider periodic logs (GPS snapshots) for the focused rider
const riderLogsQ = useFiestaRiderPeriodicLogs({
userid: focusedRiderId ?? undefined,
fromdate: date,
todate: date,
tenantid: tenantId,
});
// Live deliveries only — no sample/demo fallback. When the feed is empty the
// cockpit shows a genuine empty state rather than fabricated riders/stops.
@@ -162,55 +167,94 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
[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();
const name = fstr(loc.locationname) || `Store ${id}`;
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} });
map.set(id, blank(id, fstr(loc.locationname) || `Store ${id}`));
}
}
if (viewMode === 'customers' && customersQ.data) {
for (const cust of customersQ.data) {
if (customerStoreFilter !== 'all') {
const locId = String(fnum(cust.locationid));
if (locId !== customerStoreFilter) continue;
}
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();
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`;
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, raw: cust });
map.set(id, {
...blank(id, customerName(cust) || `Customer ${id}`),
raw: cust,
storeName: storeNames.get(storeId),
});
}
}
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const keyOf = (r: Row): { id: string; name: string } => {
if (viewMode === 'riders') {
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 === '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 };
}
if (viewMode === 'customers') {
const custId = fstr(r.customerid) || fstr(r.contactno) || fstr(r.deliverycustomerphone) || 'unknown';
const name = fstr(r.deliverycustomer) || fstr(r.customername) || `Customer ${custId}`;
return { id: custId.toLowerCase(), name };
}
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
return { id: name.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) {
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} };
// 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);
@@ -221,11 +265,11 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
g.profit += fnum(r.profit);
const rid = fstr(r.userid) || fstr(r.ridername);
if (rid) g.riders.add(rid);
const sub = fstr(r.deliverysuburb);
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, locationsQ.data, customersQ.data]);
}, [rows, viewMode, locationid, locationsQ.data, customersQ.data, customerStoreFilter, storeNames]);
useEffect(() => {
if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) {
@@ -234,28 +278,16 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
}, [viewMode, locationid, groups, focusedId]);
const focused = groups.find((g) => g.id === focusedId) ?? null;
const groupedByRider = viewMode !== 'riders';
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
// 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) {
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); }
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());
@@ -266,6 +298,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
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;
@@ -273,7 +307,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
});
}
return blocks;
}, [focused, groupedByRider, tripSort]);
}, [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.
@@ -289,8 +323,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
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),
title: customerName(r) || `Order ${fstr(r.orderid)}`,
subtitle: areaOf(r) || fstr(r.deliveryaddress),
status: fstr(r.orderstatus),
raw: r,
});
@@ -302,11 +336,14 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
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 activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
const fleetSize = (ridersQ.data ?? []).length;
const fmtTime = (raw: unknown): string => {
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
@@ -364,26 +401,27 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div>
<div id="riders-panel">
{deliveriesQ.isLoading ? (
<div className="ph">Loading dispatch feed</div>
{isLoading ? (
<div className="ph">{viewMode === 'customers' ? 'Loading customers…' : 'Loading dispatch feed…'}</div>
) : focused && viewMode !== 'customers' ? (
<FocusedDetail
focused={focused}
tripBlocks={tripBlocks}
groupedByRider={groupedByRider}
tripSort={tripSort}
setTripSort={setTripSort}
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
fmtTime={fmtTime}
riderLogs={riderLogsQ.data}
riderLogsLoading={riderLogsQ.isLoading}
/>
) : groups.length === 0 ? (
<div className="ph">{viewMode === 'customers' ? 'No customers found' : 'No deliveries for this day'}</div>
<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 === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})</span>
<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
@@ -408,30 +446,15 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div>
)}
</div>
{groups.map((g) => {
const isSelected = focusedId === g.id;
return (
<React.Fragment key={g.id}>
{viewMode === 'customers' ? (
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={isSelected} />
) : viewMode === 'riders' ? (
<RiderCard
g={g}
onClick={() => {
setFocusedId(g.id);
const rid = fnum(g.orders[0]?.userid);
if (rid) setFocusedRiderId(rid);
}}
/>
) : (
<ZoneCard g={g} onClick={() => {
setFocusedId(g.id);
setFocusedRiderId(null);
}} />
)}
</React.Fragment>
);
})}
{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>
@@ -480,7 +503,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div>
) : !focused ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
<MapIcon size={13} /> Select a store to draw its route.
</div>
) : null}
@@ -511,88 +534,70 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
);
}
// ── 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 }) {
// ── 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" onClick={onClick}>
<div className={`rcard zone-card${idle ? ' is-idle' : ''}`} onClick={onClick}>
<div className="zone-card-header">
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div>
<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">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</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>
{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}`} />
))}
{!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-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 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>
);
@@ -602,23 +607,17 @@ function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
function FocusedDetail({
focused,
tripBlocks,
groupedByRider,
tripSort,
setTripSort,
onBack,
fmtTime,
riderLogs,
riderLogsLoading,
}: {
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;
riderLogs?: Row[];
riderLogsLoading?: boolean;
}) {
return (
<>
@@ -628,21 +627,13 @@ function FocusedDetail({
</button>
)}
{riderLogs && riderLogs.length > 0 && (
<RiderTelemetryPanel
logs={riderLogs}
riderName={focused.name}
isLoading={riderLogsLoading}
/>
)}
{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 orders to display</p>
<p className="text-xs text-slate-500 mt-1">There are no deliveries matching this selection for the current date.</p>
<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>
)}
@@ -679,7 +670,7 @@ function FocusedDetail({
<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) && (
{fstr(o.ridername) && (
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
)}
</div>
@@ -693,12 +684,12 @@ function FocusedDetail({
</div>
</div>
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</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.deliverysuburb) || fstr(o.deliveryaddress)) && (
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</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>
@@ -727,9 +718,13 @@ function FocusedDetail({
// ── Customer card ───────────────────────────────────────────────────────────────────
function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) {
const customer = g.raw;
const name = customer ? fstr(customer.customername) || fstr(customer.name) : g.name;
// 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 ${
@@ -757,6 +752,20 @@ function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => voi
<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>