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

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react';
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 OrderDetailsModal from './OrderDetailsModal';
import './CustomerDetailPanel.css';
@@ -70,8 +70,12 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
};
}, [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 storeId = customerStoreId(customer);
const email = fstr(customer.email) || '';
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>
{fstr(customer.locationname) || fstr(customer.storename)}
</div>
) : fnum(customer.locationid) ? (
) : storeId ? (
<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>
Store {fnum(customer.locationid)}
Store {storeId}
</div>
) : null}
{phone && (

View File

@@ -406,9 +406,9 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
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="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 }}>
<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>
</div>
<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 OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView';
import { useFiestaDeliveries } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, ymd, num as fnum } from '../services/fiestaApi';
import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
import type { Row } from '../services/fiestaApi';
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 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 dateObj = new Date(`${date}T00:00:00`);
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="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="relative inline-block ml-1">
<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">
<MapPin size={12} className="text-purple-500" />
<span className="max-w-[120px] sm:max-w-[180px] truncate">Coimbatore</span>
</span>
</div>
{scopeLabel && (
<div className="relative inline-block ml-1">
<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">
<MapPin size={12} className="text-purple-500" />
<span className="max-w-[120px] sm:max-w-[180px] truncate">{scopeLabel}</span>
</span>
</div>
)}
</div>
</div>
@@ -77,7 +99,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</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="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>
)}
@@ -140,7 +162,9 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</button>
</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">
<button
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
</button>
</div>
)}
</div>
</div>

View File

@@ -2555,6 +2555,32 @@
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 */
.dispatch-container .zone-progress-row {
display: flex;

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>

View File

@@ -88,11 +88,27 @@ export default function Header({
/>
</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
onClick={onToggleSidebar}
title="Toggle sidebar"
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white ${isSidebarOpen ? 'p-1.5 sm:p-2' : 'p-1.5'}`}
title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
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} />
</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',
phone: fstr(c.contactno) || '—',
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,
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));
/**
* 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`. */
export const ymd = (d: Date) =>
`${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;
pagesize?: number;
}): Promise<Row[]> {
const rows = toRows(
await fiestaGet('deliveries/getdeliveries', {
tenantid: opts.tenantid,
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
// filter on THIS endpoint is broken: passing a real outlet id returns []
// (it doesn't match against the row's own `locationid`), even though
// deliverysummary honours the same id and the rows clearly carry it. So we
// fetch tenant-wide here and scope by locationid client-side below; the KPI
// strip (deliverysummary) keeps using the working server-side filter.
applocationid: opts.applocationid,
// The backend treats `status` as a LITERAL orderstatus filter — passing
// 'all' matches nothing (returns []). Send empty to fetch every status and
// let the board filter client-side by its status tabs.
status: !opts.status || opts.status === 'all' ? '' : opts.status,
fromdate: opts.fromdate,
todate: opts.todate,
keyword: opts.keyword,
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 200,
}),
);
const pagesize = opts.pagesize ?? 200;
const fetchPage = async (pageno: number) =>
toRows(
await fiestaGet('deliveries/getdeliveries', {
tenantid: opts.tenantid,
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
// filter on THIS endpoint is broken: passing a real outlet id returns []
// (it doesn't match against the row's own `locationid`), even though
// deliverysummary honours the same id and the rows clearly carry it. So we
// fetch tenant-wide here and scope by locationid client-side below; the KPI
// strip (deliverysummary) keeps using the working server-side filter.
applocationid: opts.applocationid,
// The backend treats `status` as a LITERAL orderstatus filter — passing
// 'all' matches nothing (returns []). Send empty to fetch every status and
// let the board filter client-side by its status tabs.
status: !opts.status || opts.status === 'all' ? '' : opts.status,
fromdate: opts.fromdate,
todate: opts.todate,
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;
}
@@ -758,7 +803,14 @@ export async function getTenantCustomers(opts: {
locationid: opts.locationid,
keyword: opts.keyword ?? '',
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({
queryKey: fiestaKeys.riderPeriodicLogs(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),
});
}