- {/* 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' && (
+ )}
diff --git a/src/components/DispatchView.css b/src/components/DispatchView.css
index e2a72da..a47667e 100644
--- a/src/components/DispatchView.css
+++ b/src/components/DispatchView.css
@@ -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;
diff --git a/src/components/DispatchView.tsx b/src/components/DispatchView.tsx
index 84b7f33..8cbd72f 100644
--- a/src/components/DispatchView.tsx
+++ b/src/components/DispatchView.tsx
@@ -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;
statusCounts: Record;
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(null);
- const [focusedRiderId, setFocusedRiderId] = useState(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();
+ 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(() => {
const map = new Map();
-
+ 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();
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,
- Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
+ Select a store to draw its route.
- );
-}
-
-// ── 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 (
-
+ )}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 738dd0a..5f2f347 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -88,11 +88,27 @@ export default function Header({
/>
- {/* 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. */}
diff --git a/src/components/StoreDetailView.tsx b/src/components/StoreDetailView.tsx
index 8154f21..8a7fbca 100644
--- a/src/components/StoreDetailView.tsx
+++ b/src/components/StoreDetailView.tsx
@@ -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')}` : '—'
};
diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts
index 8213c35..9a4ef59 100644
--- a/src/services/fiestaApi.ts
+++ b/src/services/fiestaApi.ts
@@ -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 {
- 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,
}),
));
}
diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts
index a088710..c16ca24 100644
--- a/src/services/fiestaQueries.ts
+++ b/src/services/fiestaQueries.ts
@@ -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),
});
}