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

@@ -6,7 +6,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react'; import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react';
import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries'; import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries';
import { num as fnum, str as fstr } from '../services/fiestaApi'; import { num as fnum, str as fstr, customerName, customerStoreId } from '../services/fiestaApi';
interface AdminCustomersViewProps { interface AdminCustomersViewProps {
tenantId: number; tenantId: number;
@@ -36,16 +36,19 @@ export default function AdminCustomersView({ tenantId }: AdminCustomersViewProps
null; null;
const spent = fnum(c.totalspent); const spent = fnum(c.totalspent);
const locId = fnum(c.locationid) || fnum(c.applocationid); // `locationid` is not a column on this response — the store link is
const loc = locations.find((l: any) => fnum(l.locationid) === locId || fnum(l.applocationid) === locId); // aliased `tenantlocationid`. Matching on the wrong name meant every
const storeName = loc ? fstr(loc.locationname) : (fstr(c.locationname) || 'Central'); // customer fell through to the invented store name 'Central'.
const locId = customerStoreId(c);
const loc = locations.find((l: any) => fnum(l.locationid) === locId);
const storeName = (loc ? fstr(loc.locationname) : fstr(c.locationname)).trim() || '—';
return { return {
id, id,
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer', name: customerName(c) || 'Customer',
phone: fstr(c.contactno) || '—', phone: fstr(c.contactno) || '—',
email: fstr(c.email), email: fstr(c.email),
address: fstr(c.address) || 'Coimbatore', address: fstr(c.address) || '',
ordersCount: Number(c.orderscount) || 0, ordersCount: Number(c.orderscount) || 0,
totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—', totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—',
storeName, storeName,

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useFiestaCustomerOrders } from '../services/fiestaQueries'; import { useFiestaCustomerOrders } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi';
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react'; import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react';
import OrderDetailsModal from './OrderDetailsModal'; import OrderDetailsModal from './OrderDetailsModal';
import './CustomerDetailPanel.css'; import './CustomerDetailPanel.css';
@@ -70,8 +70,12 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
}; };
}, [orders]); }, [orders]);
const name = fstr(customer.customername) || fstr(customer.name) || 'Unknown Customer'; // firstname/lastname are what the endpoint actually returns; `customername`
// and `name` are not columns on this response, so the old read always produced
// "Unknown Customer" (and "UC" initials) for every customer.
const name = customerName(customer) || 'Unknown Customer';
const phone = fstr(customer.contactno) || fstr(customer.phone) || ''; const phone = fstr(customer.contactno) || fstr(customer.phone) || '';
const storeId = customerStoreId(customer);
const email = fstr(customer.email) || ''; const email = fstr(customer.email) || '';
const address = fstr(customer.address) || fstr(customer.deliveryaddress) || ''; const address = fstr(customer.address) || fstr(customer.deliveryaddress) || '';
@@ -113,10 +117,10 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span> <span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
{fstr(customer.locationname) || fstr(customer.storename)} {fstr(customer.locationname) || fstr(customer.storename)}
</div> </div>
) : fnum(customer.locationid) ? ( ) : storeId ? (
<div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}> <div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}>
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span> <span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
Store {fnum(customer.locationid)} Store {storeId}
</div> </div>
) : null} ) : null}
{phone && ( {phone && (

View File

@@ -406,9 +406,9 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
return createPortal( return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}> <div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} /> <div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4> <h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button> <button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
</div> </div>
<div className="p-4 space-y-4 overflow-y-auto flex-1"> <div className="p-4 space-y-4 overflow-y-auto flex-1">

View File

@@ -8,8 +8,8 @@ import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight,
import DispatchView from './DispatchView'; import DispatchView from './DispatchView';
import OrdersView from './OrdersView'; import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView'; import DeliveriesView from './DeliveriesView';
import { useFiestaDeliveries } from '../services/fiestaQueries'; import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, ymd, num as fnum } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
import type { Row } from '../services/fiestaApi'; import type { Row } from '../services/fiestaApi';
import './DispatchView.css'; // For #hdr and date-chip styles import './DispatchView.css'; // For #hdr and date-chip styles
@@ -34,6 +34,26 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]); const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]);
const totalOrders = rows.length; const totalOrders = rows.length;
// Operating area for the header pill. This was the hardcoded string
// "Coimbatore", shown to every tenant on the platform regardless of where they
// actually trade. The locations query is already cached by DispatchView (same
// key), so reading it here costs no extra request.
const locationsQ = useFiestaTenantLocations(tenantId);
const scopeLabel = useMemo(() => {
const locs = locationsQ.data ?? [];
if (locs.length === 0) return '';
if (locationid) {
// Store user — name the outlet's own city.
const mine = locs.find((l) => fnum(l.locationid) === locationid);
return fstr(mine?.city).trim() || fstr(mine?.state).trim();
}
// Admin — one city if the whole tenant sits in one, otherwise a count.
const cities = [...new Set(locs.map((l) => fstr(l.city).trim()).filter(Boolean))];
if (cities.length === 1) return cities[0];
if (cities.length > 1) return `${cities.length} cities`;
return '';
}, [locationsQ.data, locationid]);
const isToday = date === ymd(today); const isToday = date === ymd(today);
const dateObj = new Date(`${date}T00:00:00`); const dateObj = new Date(`${date}T00:00:00`);
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`; const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
@@ -53,12 +73,14 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-xs sm:text-sm text-white shadow-sm">C</div> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-xs sm:text-sm text-white shadow-sm">C</div>
<div className="text-base sm:text-lg font-extrabold text-slate-800 tracking-tight">Console</div> <div className="text-base sm:text-lg font-extrabold text-slate-800 tracking-tight">Console</div>
<div className="relative inline-block ml-1"> {scopeLabel && (
<span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm"> <div className="relative inline-block ml-1">
<MapPin size={12} className="text-purple-500" /> <span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm">
<span className="max-w-[120px] sm:max-w-[180px] truncate">Coimbatore</span> <MapPin size={12} className="text-purple-500" />
</span> <span className="max-w-[120px] sm:max-w-[180px] truncate">{scopeLabel}</span>
</div> </span>
</div>
)}
</div> </div>
</div> </div>
@@ -77,7 +99,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-emerald-600 bg-emerald-50 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full border border-emerald-100"> <span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-emerald-600 bg-emerald-50 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full border border-emerald-100">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} orders <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} {totalOrders === 1 ? 'delivery' : 'deliveries'}
</span> </span>
)} )}
@@ -140,7 +162,9 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</button> </button>
</div> </div>
{/* Sub-tabs for Map View */} {/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
stay visible (and inert) on Orders and Deliveries. */}
{activeTab === 'map' && (
<div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0"> <div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
<button <button
onClick={() => setMapViewMode('stores')} onClick={() => setMapViewMode('stores')}
@@ -160,6 +184,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer <Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>

View File

@@ -2555,6 +2555,32 @@
transform: translateX(4px); transform: translateX(4px);
} }
/* Idle outlet — listed so the admin can see every store under the tenant, but
visually recessed so the stores actually dispatching today read first. Still
clickable: the focused view explains that nothing has gone out. */
.dispatch-container .rcard.zone-card.is-idle {
background: #fcfcfd;
box-shadow: none;
}
.dispatch-container .rcard.zone-card.is-idle::before {
background: linear-gradient(180deg, #cbd5e1, #94a3b8);
opacity: 0.4;
}
.dispatch-container .rcard.zone-card.is-idle .zone-card-name {
color: var(--text-muted);
}
.dispatch-container .rcard.zone-card.is-idle .zone-card-emoji {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.25);
}
.dispatch-container .rcard.zone-card.is-idle .zone-card-header {
margin-bottom: 0;
}
/* Progress row: status bar + delivered/total counter */ /* Progress row: status bar + delivered/total counter */
.dispatch-container .zone-progress-row { .dispatch-container .zone-progress-row {
display: flex; display: flex;

View File

@@ -22,10 +22,9 @@ import {
Map as MapIcon, Map as MapIcon,
MapPin, MapPin,
Bike, Bike,
Store,
Users, Users,
Phone, Phone,
ShoppingBag, Store,
Truck, Truck,
Package, Package,
Ruler, Ruler,
@@ -36,7 +35,6 @@ import {
Mailbox, Mailbox,
StickyNote, StickyNote,
ArrowLeftRight, ArrowLeftRight,
Calendar,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
List, List,
@@ -44,20 +42,19 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { import {
useFiestaDeliveries, useFiestaDeliveries,
useFiestaRiders,
useFiestaRiderPeriodicLogs,
useFiestaTenantLocations, useFiestaTenantLocations,
useFiestaTenantCustomers, useFiestaTenantCustomers,
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { import {
colorFor, FIESTA_TENANT_ID,
getStatusStyle, num as fnum,
STATUS_STYLES, str as fstr,
extractTimeOnly, customerName,
} from '../services/dispatchShared'; customerStoreId,
type Row,
} from '../services/fiestaApi';
import { colorFor } from '../services/dispatchShared';
import DispatchMap, { type MapPoint } from './DispatchMap'; import DispatchMap, { type MapPoint } from './DispatchMap';
import RiderTelemetryPanel from './RiderTelemetryPanel';
import CustomerDetailPanel from './CustomerDetailPanel'; import CustomerDetailPanel from './CustomerDetailPanel';
import './DispatchView.css'; import './DispatchView.css';
@@ -78,6 +75,25 @@ function statusStyle(s: string): React.CSSProperties {
return { background: `${hex}1f`, color: hex }; 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. */ /** Drop coordinates from a delivery row (several field spellings), or null. */
function dropLatLon(r: Row): [number, number] | null { function dropLatLon(r: Row): [number, number] | null {
const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude); 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; return lat && lon ? [lat, lon] : null;
} }
// ── View modes (match #strat-row tabs) ─────────────────────────────────────────── // ── View modes ──────────────────────────────────────────────────────────────────
type ViewMode = 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens'; // The tab row lives in DispatchHubView; only these two modes are reachable.
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [ type ViewMode = 'stores' | 'customers';
{ id: 'stores', label: 'By Store', icon: Store },
{ id: 'customers', label: 'By Customer', icon: Users },
];
interface Group { interface Group {
id: string; id: string;
@@ -111,21 +124,19 @@ interface Group {
suburbs: Map<string, number>; suburbs: Map<string, number>;
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
raw?: any; raw?: any;
/** Customer view: the outlet this customer is registered against, for the card badge. */
storeName?: string;
} }
interface DispatchViewProps { interface DispatchViewProps {
locationid?: number; locationid?: number;
tenantId?: number; tenantId?: number;
date: string; 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) { export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
setFocusedId(null); setFocusedId(null);
@@ -140,16 +151,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
// Core dispatch data // Core dispatch data
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid }); const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const locationsQ = useFiestaTenantLocations(tenantId); 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 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 // Live deliveries only — no sample/demo fallback. When the feed is empty the
// cockpit shows a genuine empty state rather than fabricated riders/stops. // 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], [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 ──────────────────────────────────────────────────────────────── // ── 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 groups = useMemo<Group[]>(() => {
const map = new Map<string, 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) { 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) { for (const loc of locationsQ.data) {
if (locationid && fnum(loc.locationid) !== locationid) continue; if (locationid && fnum(loc.locationid) !== locationid) continue;
const id = String(fnum(loc.locationid)).toLowerCase(); const id = String(fnum(loc.locationid)).toLowerCase();
const name = fstr(loc.locationname) || `Store ${id}`; map.set(id, blank(id, 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: {} });
} }
} }
if (viewMode === 'customers' && customersQ.data) { if (viewMode === 'customers' && customersQ.data) {
for (const cust of customersQ.data) { for (const cust of customersQ.data) {
if (customerStoreFilter !== 'all') { const storeId = customerStoreId(cust);
const locId = String(fnum(cust.locationid)); // Admin's store dropdown. A store user never sees it (the backend has
if (locId !== customerStoreFilter) continue; // 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 id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`; map.set(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 }); ...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 } => { 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') { if (viewMode === 'stores') {
const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown'; const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown';
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`; const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`;
return { id: locId.toLowerCase(), name }; return { id: locId.toLowerCase(), name };
} }
if (viewMode === 'customers') { const custId = fstr(r.customerid) || fstr(r.deliverycontactno) || 'unknown';
const custId = fstr(r.customerid) || fstr(r.contactno) || fstr(r.deliverycustomerphone) || 'unknown'; return { id: custId.toLowerCase(), name: customerName(r) || `Customer ${custId}` };
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 };
}; };
for (const r of rows) { for (const r of rows) {
const { id, name } = keyOf(r); const { id, name } = keyOf(r);
let g = map.get(id); let g = map.get(id);
if (!g) { 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); map.set(id, g);
} }
g.orders.push(r); g.orders.push(r);
@@ -221,11 +265,11 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
g.profit += fnum(r.profit); g.profit += fnum(r.profit);
const rid = fstr(r.userid) || fstr(r.ridername); const rid = fstr(r.userid) || fstr(r.ridername);
if (rid) g.riders.add(rid); 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); 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)); 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(() => { useEffect(() => {
if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) { 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]); }, [viewMode, locationid, groups, focusedId]);
const focused = groups.find((g) => g.id === focusedId) ?? null; 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(() => { const tripBlocks = useMemo(() => {
if (!focused) return []; if (!focused) return [];
const map = new Map<string, { label: string; color: string; orders: Row[] }>(); const map = new Map<string, { label: string; color: string; orders: Row[] }>();
for (const r of focused.orders) { for (const r of focused.orders) {
let key: string; const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
let label: string; const label = fstr(r.ridername) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
let color: string; let blk = map.get(rid);
if (groupedByRider) { if (!blk) { blk = { label, color: colorFor(rid), orders: [] }; map.set(rid, blk); }
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); blk.orders.push(r);
} }
const blocks = Array.from(map.values()); 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); const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime);
return ta.localeCompare(tb); 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 sa = fnum(a.step);
const sb = fnum(b.step); const sb = fnum(b.step);
if (sa && sb && sa !== sb) return sa - sb; if (sa && sb && sa !== sb) return sa - sb;
@@ -273,7 +307,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
}); });
} }
return blocks; return blocks;
}, [focused, groupedByRider, tripSort]); }, [focused, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop // 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. // 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], lon: ll[1],
step: fnum(r.step) || i + 1, step: fnum(r.step) || i + 1,
color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'), color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'),
title: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`, title: customerName(r) || `Order ${fstr(r.orderid)}`,
subtitle: fstr(r.deliverysuburb) || fstr(r.deliveryaddress), subtitle: areaOf(r) || fstr(r.deliveryaddress),
status: fstr(r.orderstatus), status: fstr(r.orderstatus),
raw: r, 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 firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0];
const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null; 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. // KPI scope.
const totalOrders = rows.length; const totalOrders = rows.length;
const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').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 fmtTime = (raw: unknown): string => {
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/); 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>
<div id="riders-panel"> <div id="riders-panel">
{deliveriesQ.isLoading ? ( {isLoading ? (
<div className="ph">Loading dispatch feed</div> <div className="ph">{viewMode === 'customers' ? 'Loading customers…' : 'Loading dispatch feed…'}</div>
) : focused && viewMode !== 'customers' ? ( ) : focused && viewMode !== 'customers' ? (
<FocusedDetail <FocusedDetail
focused={focused} focused={focused}
tripBlocks={tripBlocks} tripBlocks={tripBlocks}
groupedByRider={groupedByRider}
tripSort={tripSort} tripSort={tripSort}
setTripSort={setTripSort} setTripSort={setTripSort}
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)} onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
fmtTime={fmtTime} fmtTime={fmtTime}
riderLogs={riderLogsQ.data}
riderLogsLoading={riderLogsQ.isLoading}
/> />
) : groups.length === 0 ? ( ) : 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"> <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 && ( {viewMode === 'customers' && !locationid && locationsQ.data && (
<div className="absolute right-0 flex items-center group/filter cursor-pointer"> <div className="absolute right-0 flex items-center group/filter cursor-pointer">
<select <select
@@ -408,30 +446,15 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div> </div>
)} )}
</div> </div>
{groups.map((g) => { {groups.map((g) => (
const isSelected = focusedId === g.id; <React.Fragment key={g.id}>
return ( {viewMode === 'customers' ? (
<React.Fragment key={g.id}> <CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={focusedId === g.id} />
{viewMode === 'customers' ? ( ) : (
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={isSelected} /> <StoreCard g={g} onClick={() => setFocusedId(g.id)} />
) : viewMode === 'riders' ? ( )}
<RiderCard </React.Fragment>
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>
);
})}
</> </>
)} )}
</div> </div>
@@ -480,7 +503,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div> </div>
) : !focused ? ( ) : !focused ? (
<div className="dmp-overlay-note"> <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> </div>
) : null} ) : null}
@@ -511,88 +534,70 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
); );
} }
// ── Rider card ─────────────────────────────────────────────────────────────────── // ── Store card ───────────────────────────────────────────────────────────────────
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) { // One per outlet. Admin sees every outlet under the tenant, a store user sees
const total = g.orders.length; // only its own. Outlets with no deliveries today are deliberately still listed —
const percent = total ? Math.round((g.delivered / total) * 100) : 0; // "which of my stores is idle" is the question the admin list has to answer — so
const isDone = total > 0 && g.delivered === total; // they render a quiet idle state instead of a row of zeroes.
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed'; function StoreCard({ g, onClick }: { g: Group; onClick: () => void }) {
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); const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
const idle = g.orders.length === 0;
return ( 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-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-titles">
<div className="zone-card-name">{g.name}</div> <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> </div>
<span className="zone-card-arrow" aria-hidden="true"></span> <span className="zone-card-arrow" aria-hidden="true"></span>
</div> </div>
{g.orders.length > 0 && ( {!idle && (
<div className="zone-progress-row"> <>
<div className="zone-status-bar"> <div className="zone-progress-row">
{Object.entries(g.statusCounts).map(([s, c]) => ( <div className="zone-status-bar">
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} /> {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>
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div> <div className="zone-stat-pills">
</div> {/* Only render a stat the feed can actually populate — the areas and
)} profit pills used to sit at a permanent 0 for every store. */}
<div className="zone-stat-pills"> {g.suburbs.size > 0 && (
<span className="zone-stat-pill"> <span className="zone-stat-pill">
<span className="zone-stat-icon"><MapPin size={12} /></span> <span className="zone-stat-icon"><MapPin size={12} /></span>
<span className="zone-stat-value">{g.suburbs.size}</span> <span className="zone-stat-value">{g.suburbs.size}</span>
<span className="zone-stat-label">areas</span> <span className="zone-stat-label">{g.suburbs.size === 1 ? 'area' : 'areas'}</span>
</span> </span>
<span className="zone-stat-pill"> )}
<span className="zone-stat-icon"><Ruler size={12} /></span> {g.totalKm > 0 && (
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span> <span className="zone-stat-pill">
<span className="zone-stat-label">km</span> <span className="zone-stat-icon"><Ruler size={12} /></span>
</span> <span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
{g.profit > 0 && ( <span className="zone-stat-label">km</span>
<span className="zone-stat-pill"> </span>
<span className="zone-stat-icon"><Wallet size={12} /></span> )}
<span className="zone-stat-value">{g.profit.toLocaleString('en-IN')}</span> {g.profit > 0 && (
<span className="zone-stat-label">profit</span> <span className="zone-stat-pill">
</span> <span className="zone-stat-icon"><Wallet size={12} /></span>
)} <span className="zone-stat-value">{g.profit.toLocaleString('en-IN')}</span>
</div> <span className="zone-stat-label">profit</span>
{suburbs.length > 0 && ( </span>
<div className="zone-card-suburbs"> )}
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span> </div>
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>} {suburbs.length > 0 && (
</div> <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> </div>
); );
@@ -602,23 +607,17 @@ function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
function FocusedDetail({ function FocusedDetail({
focused, focused,
tripBlocks, tripBlocks,
groupedByRider,
tripSort, tripSort,
setTripSort, setTripSort,
onBack, onBack,
fmtTime, fmtTime,
riderLogs,
riderLogsLoading,
}: { }: {
focused: Group; focused: Group;
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>; tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
groupedByRider: boolean;
tripSort: 'planned' | 'time'; tripSort: 'planned' | 'time';
setTripSort: (v: 'planned' | 'time') => void; setTripSort: (v: 'planned' | 'time') => void;
onBack?: () => void; onBack?: () => void;
fmtTime: (raw: unknown) => string; fmtTime: (raw: unknown) => string;
riderLogs?: Row[];
riderLogsLoading?: boolean;
}) { }) {
return ( return (
<> <>
@@ -628,21 +627,13 @@ function FocusedDetail({
</button> </button>
)} )}
{riderLogs && riderLogs.length > 0 && (
<RiderTelemetryPanel
logs={riderLogs}
riderName={focused.name}
isLoading={riderLogsLoading}
/>
)}
{tripBlocks.length === 0 && ( {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="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"> <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3">
<Package size={20} /> <Package size={20} />
</div> </div>
<p className="font-bold text-slate-700 text-sm">No orders to display</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">There are no deliveries matching this selection for the current date.</p> <p className="text-xs text-slate-500 mt-1">Nothing has been dispatched from this store on the selected date.</p>
</div> </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-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
<div className="zone-order-id-block"> <div className="zone-order-id-block">
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div> <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 className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
)} )}
</div> </div>
@@ -693,12 +684,12 @@ function FocusedDetail({
</div> </div>
</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) && ( {fstr(o.pickupcustomer) && (
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div> <div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
)} )}
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && ( {fstr(o.deliveryaddress) && (
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div> <div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliveryaddress)}</div>
)} )}
{fstr(o.ordernotes) && ( {fstr(o.ordernotes) && (
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div> <div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
@@ -727,8 +718,12 @@ function FocusedDetail({
// ── Customer card ─────────────────────────────────────────────────────────────────── // ── Customer card ───────────────────────────────────────────────────────────────────
function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) { function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) {
const customer = g.raw; 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 phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : '';
const deliveries = g.orders.length;
return ( return (
<div <div
@@ -757,6 +752,20 @@ function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => voi
<Phone size={10} /> {phone} <Phone size={10} /> {phone}
</div> </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> </div>
</div> </div>

View File

@@ -88,11 +88,27 @@ export default function Header({
/> />
</span> </span>
{/* Sidebar toggle (Burger Menu) */} {/* Sidebar toggle (Burger Menu).
ml-auto when open pins it to the trailing edge of the brand cell,
and since that cell's width tracks the sidebar exactly (256px open
/ 64px collapsed) the button lands on the sidebar's own edge — the
seam between the panel and the header. Left to sit inline after
the wordmark it floated in the middle of the open panel instead,
and pushed outside the cell it drifted past the seam by the flex
gap. Anchoring to the cell needs no pixel offsets and holds if
either width changes.
Collapsed the cell is only 64px and centred, so the natural
inline position already sits beside the icon logo, which is itself
aligned over the sidebar's icon rail. */}
<button <button
onClick={onToggleSidebar} onClick={onToggleSidebar}
title="Toggle sidebar" title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white ${isSidebarOpen ? 'p-1.5 sm:p-2' : 'p-1.5'}`} aria-label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
aria-expanded={isSidebarOpen}
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white shrink-0 ${
isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5'
}`}
> >
<Menu size={18} /> <Menu size={18} />
</button> </button>

View File

@@ -273,7 +273,9 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer', name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
phone: fstr(c.contactno) || '—', phone: fstr(c.contactno) || '—',
email: fstr(c.email), email: fstr(c.email),
address: fstr(c.address) || 'Coimbatore', // Was `|| 'Coimbatore'`, which invented an address for every customer
// whose record has none — and for every tenant outside Coimbatore.
address: fstr(c.address) || '—',
ordersCount: Number(c.orderscount) || 0, ordersCount: Number(c.orderscount) || 0,
totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—' totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—'
}; };

View File

@@ -106,6 +106,30 @@ export function num(v: unknown): number {
export const str = (v: unknown): string => (v == null ? '' : String(v)); export const str = (v: unknown): string => (v == null ? '' : String(v));
/**
* Display name for a customer row. `gettenantcustomers` returns `firstname` /
* `lastname` and has NO `customername` or `name` column, so code that read those
* fell through to "Unknown Customer" for every customer on the platform. Delivery
* rows spell the same person `deliverycustomer`, hence the extra fallbacks.
*/
export function customerName(r: Row): string {
const full = `${str(r.firstname).trim()} ${str(r.lastname).trim()}`.trim();
return (
full ||
str(r.deliverycustomer).trim() ||
str(r.customername).trim() ||
str(r.name).trim()
);
}
/**
* The store a customer belongs to. `gettenantcustomers` aliases the
* `tenantcustomers.locationid` link as `tenantlocationid` — plain `locationid`
* is NOT in the response, so filtering on it matched nothing. `deliverylocationid`
* is the saved-address id, a different thing entirely, and must not be used here.
*/
export const customerStoreId = (r: Row): number => num(r.tenantlocationid) || num(r.locationid);
/** Fiesta date params want a bare `YYYY-MM-DD`. */ /** Fiesta date params want a bare `YYYY-MM-DD`. */
export const ymd = (d: Date) => export const ymd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -352,27 +376,48 @@ export async function getDeliveries(opts: {
pageno?: number; pageno?: number;
pagesize?: number; pagesize?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
const rows = toRows( const pagesize = opts.pagesize ?? 200;
await fiestaGet('deliveries/getdeliveries', { const fetchPage = async (pageno: number) =>
tenantid: opts.tenantid, toRows(
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid await fiestaGet('deliveries/getdeliveries', {
// filter on THIS endpoint is broken: passing a real outlet id returns [] tenantid: opts.tenantid,
// (it doesn't match against the row's own `locationid`), even though // NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
// deliverysummary honours the same id and the rows clearly carry it. So we // filter on THIS endpoint is broken: passing a real outlet id returns []
// fetch tenant-wide here and scope by locationid client-side below; the KPI // (it doesn't match against the row's own `locationid`), even though
// strip (deliverysummary) keeps using the working server-side filter. // deliverysummary honours the same id and the rows clearly carry it. So we
applocationid: opts.applocationid, // fetch tenant-wide here and scope by locationid client-side below; the KPI
// The backend treats `status` as a LITERAL orderstatus filter — passing // strip (deliverysummary) keeps using the working server-side filter.
// 'all' matches nothing (returns []). Send empty to fetch every status and applocationid: opts.applocationid,
// let the board filter client-side by its status tabs. // The backend treats `status` as a LITERAL orderstatus filter — passing
status: !opts.status || opts.status === 'all' ? '' : opts.status, // 'all' matches nothing (returns []). Send empty to fetch every status and
fromdate: opts.fromdate, // let the board filter client-side by its status tabs.
todate: opts.todate, status: !opts.status || opts.status === 'all' ? '' : opts.status,
keyword: opts.keyword, fromdate: opts.fromdate,
pageno: opts.pageno ?? 1, todate: opts.todate,
pagesize: opts.pagesize ?? 200, keyword: opts.keyword,
}), pageno,
); pagesize,
}),
);
let rows: Row[];
if (opts.pageno) {
// An explicit page was asked for — honour it and don't walk the rest.
rows = await fetchPage(opts.pageno);
} else {
// Walk every page. The endpoint has no total-count field, so a short page is
// the only end-of-data signal. Previously this fetched page 1 only, which
// silently dropped delivery 201+ for a busy tenant-wide day — the dispatch
// board looked complete while missing stops. MAX_PAGES caps a runaway loop
// if the backend ever ignores `pageno` and keeps returning full pages.
const MAX_PAGES = 25;
rows = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const batch = await fetchPage(page);
rows.push(...batch);
if (batch.length < pagesize) break;
}
}
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows; return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
} }
@@ -758,7 +803,14 @@ export async function getTenantCustomers(opts: {
locationid: opts.locationid, locationid: opts.locationid,
keyword: opts.keyword ?? '', keyword: opts.keyword ?? '',
pageno: opts.pageno ?? 1, pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20, // When a store is named the backend joins `customerlocations`, so it
// returns one row per SAVED ADDRESS and applies LIMIT to those rows — not
// to customers. Live: locationid=1185 → 12 rows → 2 customers (11 of them
// one person's addresses). The old default of 20 therefore showed a store
// roughly three customers. Ask for enough rows that dedupe still has every
// customer to work with; the backend's DISTINCT ON fix makes this generous
// rather than load-bearing.
pagesize: opts.pagesize ?? 500,
}), }),
)); ));
} }

View File

@@ -423,7 +423,11 @@ export function useFiestaRiderPeriodicLogs(opts: {
return useQuery({ return useQuery({
queryKey: fiestaKeys.riderPeriodicLogs(opts), queryKey: fiestaKeys.riderPeriodicLogs(opts),
queryFn: () => getRiderPeriodicLogs(opts), queryFn: () => getRiderPeriodicLogs(opts),
enabled: Boolean(opts.fromdate && opts.todate), // A rider is required. Without this guard the query fired on every page load
// with no rider selected — and `riders/getriderperiodiclogs` 404s in both the
// riders/ and partners/ namespaces (no such backend route exists), so every
// load spent a request on a guaranteed failure.
enabled: Boolean((opts.userid || opts.riderid) && opts.fromdate && opts.todate),
}); });
} }