diff --git a/src/App.tsx b/src/App.tsx
index 38bad89..4a3261e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -36,7 +36,7 @@ const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
its stylesheet, which nobody who never opens the fleet map should download. */
-const FleetPage = named('FleetPage', () => import('@/features/nearle-admin/pages/FleetPage'));
+const NearleDispatchPage = named('NearleDispatchPage', () => import('@/features/nearle-admin/pages/DispatchPage'));
/* One Console for both workspaces — it reads its own scope from BranchScope,
which pins a store user to their outlet and lets an admin choose. Both routes
@@ -111,7 +111,10 @@ export function App() {
{/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */}
} />
- } />
+ } />
+ {/* The platform's rider tracking was a "Fleet" page showing presence
+ only. It is a tab on Dispatch now; the old link still works. */}
+ } />
} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
never bounce out to a HOME_ROUTE that points back into this
diff --git a/src/api/insights.ts b/src/api/insights.ts
index ab150ee..239340e 100644
--- a/src/api/insights.ts
+++ b/src/api/insights.ts
@@ -29,6 +29,18 @@ export interface OrderQuery extends DateRange {
tenantid: number;
/** Omit for every branch of the tenant. */
locationid?: number;
+ /**
+ * One delivery partner's work, ACROSS every merchant they serve.
+ *
+ * The platform's own view of dispatch: a partner's riders carry for many
+ * shops at once — partner 60 answered with 376 deliveries spanning 12
+ * merchants — and no tenant-scoped read can show that. Verified live on
+ * 2026-09-11.
+ *
+ * Never sent alongside a tenantid. The endpoint treats the two as separate
+ * doors onto the same table, not as filters that combine.
+ */
+ partnerid?: number;
status?: string;
keyword?: string;
pageno?: number;
@@ -51,7 +63,7 @@ export const insightsApi = {
*/
orders: (query: OrderQuery) =>
api.list(`${WEB}/orders/tenant/getorders`, {
- tenantid: query.tenantid,
+ ...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
@@ -75,7 +87,7 @@ export const insightsApi = {
*/
deliveries: (query: OrderQuery) =>
api.list(`${WEB}/deliveries/getdeliveries`, {
- tenantid: query.tenantid,
+ ...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
diff --git a/src/api/types.ts b/src/api/types.ts
index 92bcad8..47426c4 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -329,23 +329,53 @@ export interface ProductStockRequest {
Orders & deliveries — the summary shapes the console reads
──────────────────────────────────────────────────────────────────────────── */
-export interface OrderSummary {
- totalorders?: number;
- delivered?: number;
+/**
+ * What `getordersummary` and `getlocationsummary` actually send.
+ *
+ * ── These two used to declare fields the backend has never sent ─────────────
+ *
+ * Both carried `totalorders?: number` and `revenue?: number`. Neither exists in
+ * either response. Verified live on 2026-09-11 against tenant 1147 — the whole
+ * body is:
+ *
+ * {"total":168,"created":84,"pending":1,"processing":0,
+ * "delivered":1,"cancelled":82,"locationid":1185,"locationname":"R mart "}
+ *
+ * The count is `total`, not `totalorders`, and there is no money on it at all.
+ * Because both were optional AND the interface had an `[key: string]: unknown`
+ * index signature, `summary.revenue ?? 0` type-checked perfectly and evaluated
+ * to 0 forever. Four pages read those two names — Reports, both Consoles and
+ * the platform's store detail — so every "App revenue" and "Online orders"
+ * figure on this console read ₹0 / 0 against a shop with 84 real orders worth
+ * ₹25,728 in the same range.
+ *
+ * The index signature is gone with them. It is what let the names drift from
+ * the wire in the first place: with it, a typo and a renamed column are both
+ * legal, and neither shows up until someone looks at a screen and sees a zero.
+ *
+ * Revenue is not here because it is not available here. It comes from the order
+ * rows — see `branchOrderStats` in `features/store-admin/branchStats.ts`.
+ */
+interface OrderCounts {
+ /** Every order in scope, whatever its status. The other counts sum to this. */
+ total?: number;
+ created?: number;
pending?: number;
+ processing?: number;
+ delivered?: number;
cancelled?: number;
- revenue?: number;
- [key: string]: unknown;
}
-export interface LocationOrderSummary {
+export interface OrderSummary extends OrderCounts {
+ tenantid?: number;
+ tenantname?: string;
+ locationid?: number;
+ locationname?: string;
+}
+
+export interface LocationOrderSummary extends OrderCounts {
locationid?: number;
locationname?: string;
- totalorders?: number;
- delivered?: number;
- cancelled?: number;
- revenue?: number;
- [key: string]: unknown;
}
export interface DeliverySummary {
diff --git a/src/components/KpiCard.tsx b/src/components/KpiCard.tsx
index 8ccce03..59e9769 100644
--- a/src/components/KpiCard.tsx
+++ b/src/components/KpiCard.tsx
@@ -70,25 +70,7 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
- {note ? (
-
- {note}
-
- ) : null}
+ {/* Note pill removed as requested */}
diff --git a/src/features/console/ConsolePage.tsx b/src/features/console/ConsolePage.tsx
index 30d55a1..30c3b69 100644
--- a/src/features/console/ConsolePage.tsx
+++ b/src/features/console/ConsolePage.tsx
@@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { summariseBranch } from '@/features/store-admin/posStatus';
+import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
import {
useLocationProducts,
- useLocationSummary,
+ useOrders,
usePosHealthByBranch,
usePosSalesByBranch,
useStockRequests,
@@ -52,7 +53,15 @@ export function ConsolePage() {
const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
- const orders = useLocationSummary(tenantid || undefined);
+ /* The same rows `useConsoleSeries` reads, so this shares its cache entry
+ rather than adding a request. `getlocationsummary`, which used to feed this
+ table, carries no money and ignores the date picker — see `branchStats.ts`. */
+ const orders = useOrders(
+ tenantid
+ ? { tenantid, ...(selected ? { locationid: selected } : {}), ...dates.range, pagesize: 500 }
+ : undefined,
+ );
+ const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]);
const posNow = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
@@ -75,17 +84,14 @@ export function ConsolePage() {
const rows = useMemo(
() =>
scoped.map((branch, index) => {
- const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
+ const order = byBranch.get(branch.locationid) ?? NO_ORDERS;
const pos = posNow[index]?.data;
return {
branch,
- // `getlocationsummary` carries no date filter, so it is used for the
- // per-branch split the table needs. The dated period figures come
- // from the order rows — see `totals` below.
- onlineRevenue: order?.revenue ?? 0,
- onlineOrders: order?.totalorders ?? 0,
- cancelled: order?.cancelled ?? 0,
- delivered: order?.delivered ?? 0,
+ onlineRevenue: order.revenue,
+ onlineOrders: order.orders,
+ cancelled: order.cancelled,
+ delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
health: summariseBranch(posHealth[index]?.data ?? [], now),
@@ -94,7 +100,7 @@ export function ConsolePage() {
).length,
};
}),
- [scoped, orders.data, posNow, posHealth, requests.data, now],
+ [scoped, byBranch, posNow, posHealth, requests.data, now],
);
const totals = useMemo(() => {
diff --git a/src/features/nearle-admin/NearleAdminShell.tsx b/src/features/nearle-admin/NearleAdminShell.tsx
index a18bcec..6f02225 100644
--- a/src/features/nearle-admin/NearleAdminShell.tsx
+++ b/src/features/nearle-admin/NearleAdminShell.tsx
@@ -1,4 +1,5 @@
-import { AppShell, type NavEntry } from '@/components/shell/AppShell';
+import { FileSpreadsheet, Bike } from 'lucide-react';
+import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { DateScopeProvider } from '@/components/shell/DateScope';
/**
@@ -12,15 +13,51 @@ const NAV: readonly NavEntry[] = [
{ to: '/nearle/stores', label: 'Stores' },
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
{ to: '/nearle/catalogue', label: 'Global catalogue' },
- { to: '/nearle/partners', label: 'Rider partners' },
- { to: '/nearle/fleet', label: 'Fleet' },
- { to: '/nearle/uploads', label: 'Uploads' },
+ { to: '/nearle/dispatch', label: 'Dispatch' },
+];
+
+/**
+ * Setup destinations, in the account menu rather than the nav.
+ *
+ * Both of these used to hold a nav slot beside Stores and Dispatch, and neither
+ * earns one: a partner is signed up once and then edited when a contract
+ * changes, and an upload is checked in the days after it was sent. The nav's
+ * four remaining slots are the places the platform actually works from.
+ *
+ * Onboarding a partner and watching their riders are also not the same job.
+ * Dispatch answers "what is happening on the road right now", every day; Rider
+ * partners answers "who are we contracted with", a handful of times a year. Two
+ * adjacent nav entries made them look like two halves of one screen — and now
+ * that Dispatch reads a partner's whole book of work, the one that matters
+ * daily is the one that stayed.
+ *
+ * This mirrors the Store Admin's own menu, where Uploads sits for exactly the
+ * same reason. See `AppShellProps.manageItems`.
+ */
+const MANAGE: readonly MenuEntry[] = [
+ {
+ to: '/nearle/partners',
+ label: 'Rider partners',
+ icon: ,
+ note: 'Who we are contracted with',
+ },
+ {
+ to: '/nearle/uploads',
+ label: 'Uploads',
+ icon: ,
+ note: 'Spreadsheets sent to the catalogue',
+ },
];
export function NearleAdminShell() {
return (
-
+
);
}
diff --git a/src/features/nearle-admin/pages/FleetPage.tsx b/src/features/nearle-admin/ShiftsPanel.tsx
similarity index 88%
rename from src/features/nearle-admin/pages/FleetPage.tsx
rename to src/features/nearle-admin/ShiftsPanel.tsx
index 16d5e12..794acd4 100644
--- a/src/features/nearle-admin/pages/FleetPage.tsx
+++ b/src/features/nearle-admin/ShiftsPanel.tsx
@@ -1,15 +1,14 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react';
import { KpiCard } from '@/components/KpiCard';
-import { PageHeader } from '@/components/PageHeader';
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
-import { useAllPartners, usePartnerRiderLogs } from '@/queries/hooks';
-import { duration, fleetDay, recommend, type RiderDay, type Severity } from '../riderShifts';
-import './fleet.css';
+import { usePartnerRiderLogs } from '@/queries/hooks';
+import { duration, fleetDay, recommend, type RiderDay, type Severity } from './riderShifts';
+import './pages/fleet.css';
/**
* Who was online, and for how long.
@@ -42,19 +41,9 @@ import './fleet.css';
* empty or zero on all 320,132 rows read for August, so this is the only way to
* know, and it is inferred rather than recorded. The page says so out loud.
*/
-export function FleetPage() {
- const partners = useAllPartners();
- const [partnerid, setPartnerid] = useState(0);
+export function ShiftsPanel({ partnerid }: { partnerid: number }) {
const [day, setDay] = useState(() => yesterday());
- // The first partner, once they load — an empty page with a dropdown on it
- // makes the reader do a step the page could have done.
- useEffect(() => {
- if (partnerid === 0 && partners.data.length > 0) {
- setPartnerid(partners.data[0]?.partnerid ?? 0);
- }
- }, [partnerid, partners.data]);
-
const logs = usePartnerRiderLogs(partnerid || undefined, { fromdate: day, todate: day });
const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]);
@@ -90,40 +79,25 @@ export function FleetPage() {
return (
-
-
- {
- setDay(event.target.value);
- setFocused(null);
- }}
- />
-
- }
- />
+ {/* No page header. This is a tab on the platform's Dispatch page now,
+ and the partner it describes is chosen there — a second picker here
+ would be two controls setting one thing. */}
+
+
+ Who was on, and for how long
+
+ {
+ setDay(event.target.value);
+ setFocused(null);
+ }}
+ />
+
diff --git a/src/features/nearle-admin/pages/DispatchPage.tsx b/src/features/nearle-admin/pages/DispatchPage.tsx
new file mode 100644
index 0000000..cb1aaf0
--- /dev/null
+++ b/src/features/nearle-admin/pages/DispatchPage.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useMemo, useState } from 'react';
+import { HStack } from '@astryxdesign/core/HStack';
+import { Text } from '@astryxdesign/core/Text';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Bike, Clock } from 'lucide-react';
+import { PageHeader } from '@/components/PageHeader';
+import { useAllPartners } from '@/queries/hooks';
+import { DispatchBoard } from '@/features/store-admin/pages/DispatchPage';
+import { ShiftsPanel } from '../ShiftsPanel';
+import './fleet.css';
+
+/**
+ * The platform's dispatch board — one delivery partner at a time.
+ *
+ * ── Why this replaced the Fleet page ────────────────────────────────────────
+ *
+ * There were two pages answering halves of one question. "Fleet" read
+ * `getriderlogs` and showed presence — who clocked on, for how long — while the
+ * shops' "Dispatch" read `getdeliveries` and showed the work itself. So the
+ * platform could see that a partner's riders were online and had no way to see
+ * what they delivered.
+ *
+ * `getdeliveries` takes a `partnerid`. Verified live on 2026-09-11: partner 60
+ * answered with 376 deliveries spanning 12 merchants and 12 riders. That is the
+ * platform's real view of dispatch and it was one query parameter away the
+ * whole time.
+ *
+ * So this is the same board the shops use — same tabs, same cards, same map,
+ * same component — scoped to a partner instead of a tenant. Presence survives
+ * as the Shifts tab rather than as a page of its own.
+ *
+ * ── What the platform deliberately cannot do here ───────────────────────────
+ *
+ * See a merchant's OWN riders, and assign anything.
+ *
+ * Both fall out of the scope type rather than being hidden: `DispatchScope`'s
+ * partner variant has no field to put a tenantid in, so the reads cannot be
+ * widened by accident, and `canAssign` is false for it, so the assign bar, the
+ * auto-assign drawer and the queue's tick boxes are all absent. The platform
+ * watches partners; it does not dispatch on a shop's behalf.
+ */
+export function NearleDispatchPage() {
+ const partners = useAllPartners();
+ const [partnerid, setPartnerid] = useState(0);
+ const [view, setView] = useState<'work' | 'shifts'>('work');
+
+ // The first partner, once they load. An empty page with a dropdown on it
+ // makes the reader do a step the page could have done.
+ useEffect(() => {
+ if (partnerid === 0 && partners.data.length > 0) {
+ setPartnerid(partners.data[0]?.partnerid ?? 0);
+ }
+ }, [partnerid, partners.data]);
+
+ const chosen = useMemo(
+ () => partners.data.find((partner) => partner.partnerid === partnerid),
+ [partners.data, partnerid],
+ );
+
+ const scope = useMemo(
+ () =>
+ ({
+ kind: 'partner' as const,
+ partnerid,
+ partnername: chosen?.partnername ?? `Partner ${partnerid}`,
+ }),
+ [partnerid, chosen],
+ );
+
+ return (
+
+
+
+
+ }
+ tabs={
+ /* The store board's own tab styling — `.sbt`, defined in
+ `dispatch.css`, which this page already loads through
+ `DispatchBoard`.
+
+ These were written against a `.mode-tab` class that exists in no
+ stylesheet, so they rendered as bare browser buttons directly above
+ a row of properly styled ones. Nothing catches that: the markup is
+ valid, the class is just never matched. */
+
+
+
+
+ }
+ />
+
+ {partnerid === 0 ? (
+
+ {partners.isLoading
+ ? 'Reading partners…'
+ : 'No rider partners are onboarded yet. Onboard one from Rider partners, in the account menu, to see their dispatch.'}
+
+ ) : view === 'work' ? (
+ /* The shops' own board, against a partner. Nothing about it is
+ duplicated here — this is the same component. */
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx
index 186d466..b2fb5e7 100644
--- a/src/features/nearle-admin/pages/StoreDetailPage.tsx
+++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx
@@ -13,8 +13,9 @@ import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
-import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
-import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
+import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
+import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
+import type { TenantInfo, TenantLocation } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { Drawer } from '@/features/store-admin/Drawer';
@@ -40,12 +41,15 @@ function money(value: number | undefined): string {
/**
* One tenant: its branches, and how each is performing.
*
- * Order figures come from `/orders/getlocationsummary`, which is scoped to the
- * tenant and returns one row per branch. Counter sales are NOT folded in here:
- * the POS endpoints take a single required locationid, so a tenant-wide till
- * figure would mean one request per branch, and a blended number would be
- * eventually consistent in a way this page cannot honestly caption. Branch-level
- * POS lives on the branch screen instead.
+ * Order figures are totalled from the tenant's own order rows and grouped by
+ * branch. They used to come from `/orders/getlocationsummary`, which does return
+ * one row per branch but carries no money on it at all — so every Revenue cell
+ * in this table read ₹0 for as long as the page has existed.
+ *
+ * Counter sales are NOT folded in here: the POS endpoints take a single required
+ * locationid, so a tenant-wide till figure would mean one request per branch,
+ * and a blended number would be eventually consistent in a way this page cannot
+ * honestly caption. Branch-level POS lives on the branch screen instead.
*/
export function StoreDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -53,7 +57,11 @@ export function StoreDetailPage() {
const { data: tenants } = useTenants();
const { data: locations, isLoading, error } = useTenantLocations(tenantid);
- const summary = useLocationSummary(tenantid);
+ /* Per-branch takings come from the order rows. `getlocationsummary`, which
+ used to feed this table, sends counts and no money at all — see
+ `branchStats.ts`. No date range here on purpose: this is the platform's
+ all-time view of a merchant, not a trading-day board. */
+ const summary = useOrders(tenantid ? { tenantid, pagesize: 500 } : undefined);
const orders = useOrderSummary(tenantid);
const tenant = useMemo(
@@ -62,13 +70,10 @@ export function StoreDetailPage() {
);
const rows = useMemo(() => {
- const perLocation = new Map();
- for (const entry of summary.data ?? []) {
- if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
- }
+ const perLocation = branchOrderStats(summary.data ?? []);
return ((locations ?? []) as TenantLocation[]).map((branch) => {
- const stats = perLocation.get(branch.locationid);
+ const stats = perLocation.get(branch.locationid) ?? NO_ORDERS;
return {
locationid: branch.locationid,
locationname: branch.locationname,
@@ -77,8 +82,8 @@ export function StoreDetailPage() {
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
status: branch.status ?? 'Unknown',
- orders: Number(stats?.totalorders ?? 0),
- revenue: Number(stats?.revenue ?? 0),
+ orders: stats.orders,
+ revenue: stats.revenue,
};
});
}, [locations, summary.data]);
@@ -243,7 +248,7 @@ export function StoreDetailPage() {
0 ? 'success' : 'neutral'}
icon={}
/>
diff --git a/src/features/nearle-admin/pages/fleet.css b/src/features/nearle-admin/pages/fleet.css
index 1182b97..d460fca 100644
--- a/src/features/nearle-admin/pages/fleet.css
+++ b/src/features/nearle-admin/pages/fleet.css
@@ -261,3 +261,17 @@
flex: none;
margin-top: 2px;
}
+
+/* ── The dispatch page's two view tabs ───────────────────────────────────── */
+
+/* The buttons themselves are `.sbt` from the store board's `dispatch.css`, so
+ the platform's tabs and the shops' tabs are the same control rather than two
+ that happen to look alike. Only the row they sit in belongs here: the store
+ board wraps its tabs in `.strat-row`, a bordered bar that also carries an ops
+ cluster on the right, and there is nothing to put on the right of these two. */
+.nearle-view-tabs {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ flex-wrap: wrap;
+}
diff --git a/src/features/store-admin/AssignBar.tsx b/src/features/store-admin/AssignBar.tsx
index 8c44a07..d5ae01f 100644
--- a/src/features/store-admin/AssignBar.tsx
+++ b/src/features/store-admin/AssignBar.tsx
@@ -1,8 +1,8 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
-import { Bike, Route, Truck, UserCheck } from 'lucide-react';
+import { Route, UserCheck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
RIDER_MESSAGE,
@@ -13,7 +13,7 @@ import type { OrderRow, TenantLocation } from '@/api/types';
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { useBranchScope } from './BranchScope';
-import { buildDeliveries, riderName, riderScope, riderVehicle } from './assignDelivery';
+import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { RoutePlanDrawer } from './RoutePlanDrawer';
import './pages/deliveries.css';
@@ -60,7 +60,19 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
const { tenantid } = useBranchScope();
const shop = useOwnTenant(tenantid || undefined);
const partnerid = Number((shop.data as unknown as Record)?.['partnerid'] ?? 0);
- const [source, setSource] = useState<'own' | 'partner'>(partnerid > 0 ? 'partner' : 'own');
+ /*
+ Both fleets are read, and the picker lists them together.
+
+ This used to be an either/or toggle, so a shop with a partner had to switch
+ back and forth to compare who was free — and the whole question at this
+ moment is "who can take this", across everybody available. Two reads and one
+ grouped list answers it in a glance instead.
+
+ Each rider still shows whose they are, because they are different people with
+ different employers and an operator handing over a parcel should know which.
+ */
+ const ownRiders = useRiders(tenantid ? { tenantid } : {});
+ const partnerRiders = useRiders(partnerid ? { partnerid } : {});
// The partner's NAME on the tab, not "Partner riders". An operator handing an
// order to Xpress-Cbe-Main should read that, not a category.
@@ -76,8 +88,16 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
2026-09-09: 117 of the platform's 118 riders belong to a partner, so region
scope was quietly offering other companies' fleets.
*/
- const riders = useRiders(riderScope({ tenantid, partnerid, source }));
- const fleet = riders.data ?? [];
+ const riders = { isLoading: ownRiders.isLoading || partnerRiders.isLoading };
+ const fleet = useMemo(
+ () => [...(ownRiders.data ?? []), ...(partnerRiders.data ?? [])],
+ [ownRiders.data, partnerRiders.data],
+ );
+ /** Whose rider this is, for the label beside their name. */
+ const ownIds = useMemo(
+ () => new Set((ownRiders.data ?? []).map((entry) => entry.userid)),
+ [ownRiders.data],
+ );
const rider = fleet.find((entry) => String(entry.userid) === riderId);
const send = useMutation({
@@ -125,10 +145,24 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
onError: (error) => setOutcome(errorMessage(error)),
});
- const options = fleet.map((entry) => ({
- value: String(entry.userid),
- label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
- }));
+ /*
+ Whose rider, on every row.
+
+ The two fleets are one list now, so the label has to carry the employer —
+ otherwise a shop with a partner reads eleven names and cannot tell which of
+ them it pays. The vehicle stays because it is the other thing an operator
+ picks on.
+ */
+ const options = fleet.map((entry) => {
+ const whose = ownIds.has(entry.userid) ? 'yours' : partnerName || 'partner';
+ const vehicle = riderVehicle(entry);
+ return {
+ value: String(entry.userid),
+ label: vehicle
+ ? `${riderName(entry)} · ${vehicle} · ${whose}`
+ : `${riderName(entry)} · ${whose}`,
+ };
+ });
return (
@@ -137,37 +171,6 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
{orders.length} selected
- {/* Only when there is a choice to make. A shop with no partner has one
- source, and a toggle with one option is a control that asks a question
- it already knows the answer to. */}
- {partnerid > 0 ? (
-
-
-
-
- ) : null}
0
? 'Select a rider…'
- : source === 'partner'
- ? `No ${partnerName || 'partner'} rider has clocked on today`
- : partnerid > 0
- ? 'This shop has no riders of its own'
- : 'No rider has clocked on today'
+ : partnerid > 0
+ ? `Nobody on duty — neither this shop's riders nor ${partnerName || "the partner's"}`
+ : 'Nobody on duty, and this shop has no delivery partner'
}
/>
diff --git a/src/features/store-admin/assignDelivery.test.ts b/src/features/store-admin/assignDelivery.test.ts
index 07e7db9..2539f3f 100644
--- a/src/features/store-admin/assignDelivery.test.ts
+++ b/src/features/store-admin/assignDelivery.test.ts
@@ -14,7 +14,6 @@ import {
riderVehicle,
stampNow,
waitingMs,
- riderScope,
} from './assignDelivery';
/*
@@ -326,35 +325,4 @@ test('the vehicle line is empty rather than a stray separator when nothing is re
/* ── Which fleet an order is offered ──────────────────────────────────────── */
-/*
-Measured on 2026-09-09: 118 riders across three regions, and 117 of them belong
-to a delivery partner — 75 to partner 44 alone. Exactly one is a merchant's own.
-So asking `getriders` by REGION, which is what the picker did, offered a
-merchant every on-duty rider in their city: other merchants' own riders and
-every other partner's fleet. The scope has to name an owner.
-*/
-
-test('a fleet scope names an owner, never a city', () => {
- assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 44, source: 'partner' }), {
- partnerid: 44,
- });
- assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 44, source: 'own' }), {
- tenantid: 1147,
- });
-});
-
-// A shop with no partner has one source, and choosing "partner" cannot happen
-// — but if it somehow did, it must not fall back to the city.
-test('no partner means own riders, not everybody in the city', () => {
- assert.deepEqual(riderScope({ tenantid: 1147, partnerid: 0, source: 'partner' }), {
- tenantid: 1147,
- });
-});
-
-// R mart today: no partner, no riders of its own. The honest answer is an empty
-// picker, not 82 riders it cannot use.
-test('a shop with neither gets an empty fleet, not a borrowed one', () => {
- const scope = riderScope({ tenantid: 0, partnerid: 0, source: 'own' });
- assert.deepEqual(scope, { tenantid: undefined });
-});
diff --git a/src/features/store-admin/assignDelivery.ts b/src/features/store-admin/assignDelivery.ts
index 3135417..0534688 100644
--- a/src/features/store-admin/assignDelivery.ts
+++ b/src/features/store-admin/assignDelivery.ts
@@ -350,19 +350,3 @@ export function riderVehicle(rider: RiderInfo): string {
return [rider.vehiclename, rider.vehicleno].map((part) => (part ?? '').trim()).filter(Boolean).join(' · ');
}
-/**
- * Which fleet to ask for, given the merchant and the source they chose.
- *
- * Its own function so the rule is testable and so the picker and anything that
- * follows it cannot drift apart on it. Never returns a region: `getriders`
- * scoped by city answers "who is on duty in Coimbatore", which for a merchant
- * is 82 riders belonging to other companies.
- */
-export function riderScope(input: {
- tenantid: number;
- partnerid: number;
- source: 'own' | 'partner';
-}): { tenantid?: number | undefined; partnerid?: number | undefined } {
- if (input.source === 'partner' && input.partnerid > 0) return { partnerid: input.partnerid };
- return { tenantid: input.tenantid || undefined };
-}
diff --git a/src/features/store-admin/branchStats.test.ts b/src/features/store-admin/branchStats.test.ts
new file mode 100644
index 0000000..f781f07
--- /dev/null
+++ b/src/features/store-admin/branchStats.test.ts
@@ -0,0 +1,97 @@
+/**
+ * Per-branch figures, from the order rows.
+ *
+ * These exist because the thing they replaced could not fail visibly. The
+ * pages read `summary.revenue`, the endpoint never sent it, the optional field
+ * plus an index signature made that legal, and every shop's revenue read ₹0
+ * with nothing anywhere going red. So the assertions below are mostly about
+ * reading the fields the wire actually carries.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { OrderRow } from '@/api/types';
+import { branchOrderStats, NO_ORDERS } from './branchStats';
+
+const row = (over: Partial): OrderRow => ({
+ orderid: 'X',
+ locationid: 1185,
+ orderstatus: 'created',
+ ordervalue: 100,
+ ...over,
+}) as OrderRow;
+
+test('revenue is the sum of the rows, not a field off a summary', () => {
+ const stats = branchOrderStats([
+ row({ ordervalue: 392 }),
+ row({ ordervalue: 392 }),
+ row({ ordervalue: 108 }),
+ ]);
+ assert.equal(stats.get(1185)?.revenue, 892);
+ assert.equal(stats.get(1185)?.orders, 3);
+});
+
+test('branches are kept apart', () => {
+ const stats = branchOrderStats([
+ row({ locationid: 1185, ordervalue: 100 }),
+ row({ locationid: 1186, ordervalue: 250 }),
+ row({ locationid: 1185, ordervalue: 50 }),
+ ]);
+ assert.equal(stats.get(1185)?.revenue, 150);
+ assert.equal(stats.get(1186)?.revenue, 250);
+ assert.equal(stats.size, 2);
+});
+
+// Attributing a row to the wrong outlet is worse than dropping it, and 0 is a
+// key a real branch could collide with.
+test('a row with no branch is dropped, never pooled under zero', () => {
+ const stats = branchOrderStats([
+ row({ locationid: undefined, ordervalue: 999 }),
+ row({ locationid: 0, ordervalue: 999 }),
+ row({ locationid: 1185, ordervalue: 10 }),
+ ]);
+ assert.equal(stats.has(0), false);
+ assert.equal(stats.get(1185)?.revenue, 10);
+ assert.equal(stats.size, 1);
+});
+
+/* ── The status field ────────────────────────────────────────────────────── */
+
+// `deliverystatus` is an empty string on all 181 of tenant 1147's rows, so a
+// count taken from it reports nothing delivered and nothing cancelled for
+// every shop on the platform.
+test('counts come from orderstatus, which is the one that is filled in', () => {
+ const stats = branchOrderStats([
+ row({ orderstatus: 'delivered', deliverystatus: '' }),
+ row({ orderstatus: 'cancelled', deliverystatus: '' }),
+ row({ orderstatus: 'cancelled', deliverystatus: '' }),
+ row({ orderstatus: 'created', deliverystatus: '' }),
+ ]);
+ assert.equal(stats.get(1185)?.delivered, 1);
+ assert.equal(stats.get(1185)?.cancelled, 2);
+ assert.equal(stats.get(1185)?.orders, 4);
+});
+
+// Revenue is what was ordered; cancellations sit beside it rather than being
+// quietly netted out, so the reader can see how much of it fell through.
+test('a cancelled order still counts toward what was ordered', () => {
+ const stats = branchOrderStats([row({ orderstatus: 'cancelled', ordervalue: 500 })]);
+ assert.equal(stats.get(1185)?.revenue, 500);
+ assert.equal(stats.get(1185)?.cancelled, 1);
+});
+
+/* ── Money ───────────────────────────────────────────────────────────────── */
+
+// `orderValue` walks ordervalue → orderamount → deliveryamt because which one
+// is populated depends on the endpoint that produced the row.
+test('falls through to the money field the row actually has', () => {
+ const stats = branchOrderStats([
+ row({ ordervalue: undefined, orderamount: 300 }),
+ row({ ordervalue: undefined, orderamount: undefined, deliveryamt: 45 }),
+ ]);
+ assert.equal(stats.get(1185)?.revenue, 345);
+});
+
+test('no rows is an empty map, and callers have a zero to fall back on', () => {
+ assert.equal(branchOrderStats([]).size, 0);
+ assert.deepEqual(NO_ORDERS, { revenue: 0, orders: 0, delivered: 0, cancelled: 0 });
+});
diff --git a/src/features/store-admin/branchStats.ts b/src/features/store-admin/branchStats.ts
new file mode 100644
index 0000000..0d4a9af
--- /dev/null
+++ b/src/features/store-admin/branchStats.ts
@@ -0,0 +1,75 @@
+import type { OrderRow } from '@/api/types';
+import { matchesStatus, orderValue } from './orderStatus';
+
+/**
+ * What one branch did, from the order rows themselves.
+ *
+ * ── Why this is computed here rather than read from a summary ───────────────
+ *
+ * Because the summary endpoints do not carry it. `getlocationsummary` and
+ * `getordersummary` both answer with counts and nothing else — verified live on
+ * 2026-09-11 against tenant 1147:
+ *
+ * {"total":168,"created":84,"pending":1,"processing":0,
+ * "delivered":1,"cancelled":82,"locationid":1185,"locationname":"R mart "}
+ *
+ * There is no money on either response. Four pages nonetheless read a
+ * `revenue` field off them, and got `undefined` every time — see the note on
+ * `LocationOrderSummary` in `api/types.ts` for how that type-checked.
+ *
+ * The second reason is the date range. `getlocationsummary` IGNORES `fromdate`
+ * and `todate`: asked for 2026-09-01..09-11 it returned the same 168 as the
+ * unfiltered call, so its counts are all-time whatever the picker says. (
+ * `getordersummary` does honour the range — 168 drops to 84 — but it has no
+ * per-branch breakdown, which is what these pages are built around.)
+ *
+ * Order rows have neither problem: they carry `ordervalue` and a `locationid`,
+ * and `getorders` filters by date properly. It is also the source Sales already
+ * totals, so a branch's revenue on Reports and its revenue on Sales now come
+ * from the same rows and agree.
+ */
+export interface BranchOrderStats {
+ /** Sum of `orderValue` over the branch's rows, in rupees. */
+ revenue: number;
+ orders: number;
+ delivered: number;
+ cancelled: number;
+}
+
+export const NO_ORDERS: BranchOrderStats = {
+ revenue: 0,
+ orders: 0,
+ delivered: 0,
+ cancelled: 0,
+};
+
+/**
+ * Group order rows by branch.
+ *
+ * Rows with no `locationid` are dropped rather than pooled under 0: a branch
+ * page that showed an unattributable row would be attributing it to whichever
+ * outlet happens to sit at that key, and a wrong branch is worse than a missing
+ * one. Cancelled orders still count toward `revenue` being reported separately
+ * — `revenue` here is what was ORDERED, and `cancelled` sits beside it so the
+ * reader can see how much of it fell through.
+ */
+export function branchOrderStats(rows: readonly OrderRow[]): Map {
+ const byBranch = new Map();
+
+ for (const row of rows) {
+ const locationid = Number(row.locationid ?? 0);
+ if (!locationid) continue;
+
+ const stats = byBranch.get(locationid) ?? { ...NO_ORDERS };
+ stats.revenue += orderValue(row);
+ stats.orders += 1;
+ // `orderstatus`, not `deliverystatus`: the latter is an empty string on
+ // every one of the 181 rows tenant 1147 has, so counting on it would report
+ // nothing delivered and nothing cancelled for every shop on the platform.
+ if (matchesStatus('delivered', row.orderstatus)) stats.delivered += 1;
+ if (matchesStatus('cancelled', row.orderstatus)) stats.cancelled += 1;
+ byBranch.set(locationid, stats);
+ }
+
+ return byBranch;
+}
diff --git a/src/features/store-admin/dispatchScope.test.ts b/src/features/store-admin/dispatchScope.test.ts
new file mode 100644
index 0000000..4827fdc
--- /dev/null
+++ b/src/features/store-admin/dispatchScope.test.ts
@@ -0,0 +1,121 @@
+/**
+ * Whose dispatch a board shows.
+ *
+ * The tests that matter here are the ones about what a scope CANNOT do. One
+ * board now serves the platform and the shops, so the only thing standing
+ * between the Nearle Admin and a merchant's private fleet is this type — and
+ * the only thing stopping the platform dispatching on a shop's behalf is
+ * `canAssign`. Both are asserted rather than trusted to the reviewer's eye.
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { TenantLocation } from '@/api/types';
+import {
+ canAssign,
+ scopeBranches,
+ scopeLabel,
+ scopeQuery,
+ type DispatchScope,
+} from './dispatchScope';
+
+const branches = [
+ { locationid: 1097, tenantid: 1087, locationname: 'Ragul stores' },
+ { locationid: 1098, tenantid: 1087, locationname: 'Ragul Peelamedu' },
+] as TenantLocation[];
+
+const shop: DispatchScope = { kind: 'tenant', tenantid: 1087, branches };
+const oneBranch: DispatchScope = { kind: 'tenant', tenantid: 1087, locationid: 1097, branches };
+const partner: DispatchScope = { kind: 'partner', partnerid: 60, partnername: 'Xpress-Mdu-Main' };
+
+const RANGE = { fromdate: '2026-08-01', todate: '2026-08-31' };
+
+/* ── The query each scope becomes ────────────────────────────────────────── */
+
+test('a shop reads its own work, every branch', () => {
+ const query = scopeQuery(shop, RANGE);
+ assert.equal(query.tenantid, 1087);
+ assert.equal(query.locationid, undefined, 'no branch means all of them');
+ assert.equal(query.partnerid, undefined);
+ assert.equal(query.fromdate, '2026-08-01');
+});
+
+test('a branch narrows the same read', () => {
+ assert.equal(scopeQuery(oneBranch, RANGE).locationid, 1097);
+});
+
+// The whole reason one board can serve both: `getdeliveries` takes a partnerid,
+// and partner 60 answered with 376 deliveries across 12 merchants.
+test('a partner reads across every merchant they serve', () => {
+ const query = scopeQuery(partner, RANGE);
+ assert.equal(query.partnerid, 60);
+ assert.equal(query.locationid, undefined, 'a partner has no branch');
+});
+
+// THE guarantee. If a partner query ever carried a real tenantid, the platform
+// board would be reading one shop's private work — including riders the shop
+// hired itself, which the platform has no business seeing.
+test('a partner query never carries a real tenant id', () => {
+ const query = scopeQuery(partner, RANGE);
+ assert.equal(query.tenantid, 0, 'a zero is the field being unused, not a tenant');
+});
+
+test('the range is passed through untouched, both ways', () => {
+ for (const scope of [shop, partner]) {
+ const query = scopeQuery(scope, RANGE);
+ assert.equal(query.fromdate, '2026-08-01');
+ assert.equal(query.todate, '2026-08-31');
+ }
+});
+
+test('an absent range stays absent rather than becoming a date', () => {
+ const query = scopeQuery(partner, { fromdate: undefined, todate: undefined });
+ assert.equal(query.fromdate, undefined);
+ assert.equal(query.todate, undefined);
+});
+
+test('the page size is the caller’s, with a default', () => {
+ assert.equal(scopeQuery(shop, RANGE).pagesize, 200);
+ assert.equal(scopeQuery(shop, RANGE, 500).pagesize, 500);
+});
+
+/* ── Who may assign ──────────────────────────────────────────────────────── */
+
+// The platform watches partners across every merchant they serve. It is not the
+// party that decides which rider takes a given shop's parcel, and a console
+// that let it would let one operator dispatch for a business they do not run.
+test('only a shop may hand work to a rider', () => {
+ assert.equal(canAssign(shop), true);
+ assert.equal(canAssign(oneBranch), true);
+ assert.equal(canAssign(partner), false);
+});
+
+/* ── Branches ────────────────────────────────────────────────────────────── */
+
+test('a shop groups by its branches', () => {
+ assert.equal(scopeBranches(shop).length, 2);
+});
+
+// A partner's rounds span merchants, not branches, so there is no branch list
+// to group by — and handing one over would be handing over a shop's outlets.
+test('a partner has no branches to group by', () => {
+ assert.deepEqual(scopeBranches(partner), []);
+});
+
+/* ── Labels ──────────────────────────────────────────────────────────────── */
+
+test('the empty state names what is in scope', () => {
+ assert.equal(scopeLabel(partner), 'Xpress-Mdu-Main');
+ assert.equal(scopeLabel(shop), 'this shop');
+});
+
+/* ── The type itself ─────────────────────────────────────────────────────── */
+
+// Not a runtime assertion so much as a record of the design: the illegal state
+// is unrepresentable, which is what makes the guarantee above structural rather
+// than a check somebody has to remember to write.
+test('a partner scope has no field to put a tenant id in', () => {
+ const keys = Object.keys(partner);
+ assert.deepEqual(keys.sort(), ['kind', 'partnerid', 'partnername']);
+ assert.ok(!keys.includes('tenantid'));
+ assert.ok(!keys.includes('locationid'));
+});
diff --git a/src/features/store-admin/dispatchScope.ts b/src/features/store-admin/dispatchScope.ts
new file mode 100644
index 0000000..d5fbfda
--- /dev/null
+++ b/src/features/store-admin/dispatchScope.ts
@@ -0,0 +1,86 @@
+import type { TenantLocation } from '@/api/types';
+
+/**
+ * Whose dispatch a board is showing.
+ *
+ * ── Why this is a type and not three pages ──────────────────────────────────
+ *
+ * The platform and a shop ask the same questions of the same table — which
+ * rounds went out, who carried them, what is still waiting — and until this
+ * existed they were answered by two different pages. The Nearle Admin had a
+ * "Fleet" page reading `getriderlogs` (presence: hours and shifts) while the
+ * shops had "Dispatch" reading `getdeliveries` (the work itself), so the
+ * platform could see who was clocked on but not what they delivered.
+ *
+ * `getdeliveries` takes a `partnerid` as readily as a `tenantid`. Verified live
+ * on 2026-09-11: partner 60 answered with 376 deliveries spanning 12 merchants
+ * and 12 riders, 346 of them with drop coordinates. So one board serves both,
+ * and the scope is the only thing that differs.
+ *
+ * ── Partner and tenant are never both set ───────────────────────────────────
+ *
+ * A discriminated union rather than two optional ids, because "both" has no
+ * meaning here: the endpoint treats them as separate doors onto the table, not
+ * as filters that narrow together. Making the illegal state unrepresentable is
+ * also what guarantees the platform board cannot accidentally read a shop's own
+ * riders — there is no field on the partner variant to put a tenantid in.
+ */
+export type DispatchScope =
+ | {
+ kind: 'tenant';
+ tenantid: number;
+ /** One branch, or undefined for every branch under the tenant. */
+ locationid?: number | undefined;
+ /** The branches in scope, for the rail's store grouping. */
+ branches: readonly TenantLocation[];
+ }
+ | {
+ kind: 'partner';
+ partnerid: number;
+ partnername: string;
+ };
+
+/** The query a scope turns into. Shape is `OrderQuery`; the scope picks the id. */
+export function scopeQuery(
+ scope: DispatchScope,
+ range: { fromdate?: string | undefined; todate?: string | undefined },
+ pagesize = 200,
+): {
+ tenantid: number;
+ partnerid?: number;
+ locationid?: number | undefined;
+ fromdate?: string | undefined;
+ todate?: string | undefined;
+ pagesize: number;
+} {
+ if (scope.kind === 'partner') {
+ // `tenantid: 0` because `OrderQuery` requires the field; the reader sends
+ // the partner id instead and never puts a zero tenant on the wire.
+ return { tenantid: 0, partnerid: scope.partnerid, ...range, pagesize };
+ }
+ return { tenantid: scope.tenantid, locationid: scope.locationid, ...range, pagesize };
+}
+
+/** True when this board may hand work to a rider. */
+export function canAssign(scope: DispatchScope): boolean {
+ /*
+ Only a shop assigns its own orders.
+
+ The platform watches partners across every merchant they serve; it is not the
+ party that decides which rider takes a given shop's parcel, and a console that
+ let it would be a console where one operator could dispatch on behalf of a
+ business they do not run. So the assign bar, the auto-assign drawer and the
+ waiting queue's tick boxes are all gated on this rather than hidden by CSS.
+ */
+ return scope.kind === 'tenant';
+}
+
+/** The branches this board can group by. A partner board groups by merchant. */
+export function scopeBranches(scope: DispatchScope): readonly TenantLocation[] {
+ return scope.kind === 'tenant' ? scope.branches : [];
+}
+
+/** What the board calls the thing it is scoped to, for empty states and labels. */
+export function scopeLabel(scope: DispatchScope): string {
+ return scope.kind === 'partner' ? scope.partnername : 'this shop';
+}
diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx
index 9b2cc9a..ad757ab 100644
--- a/src/features/store-admin/pages/ConsolePage.tsx
+++ b/src/features/store-admin/pages/ConsolePage.tsx
@@ -21,12 +21,13 @@ import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
- useLocationSummary,
+ useOrders,
usePosHealthByBranch,
usePosSalesByBranch,
useStockRequests,
} from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
+import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
@@ -65,7 +66,10 @@ export function ConsolePage() {
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
- const orders = useLocationSummary(tenantid || undefined);
+ /* The order rows, not `getlocationsummary`. That endpoint carries no money at
+ all and ignores the date picker — see `branchStats.ts`. */
+ const orders = useOrders(tenantid ? { tenantid, ...dates.range, pagesize: 500 } : undefined);
+ const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]);
const posSales = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds);
@@ -80,15 +84,15 @@ export function ConsolePage() {
const rows = useMemo(
() =>
scoped.map((branch, index) => {
- const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
+ const order = byBranch.get(branch.locationid) ?? NO_ORDERS;
const pos = posSales[index]?.data;
const health = summariseBranch(posHealth[index]?.data ?? [], now);
return {
branch,
- onlineRevenue: order?.revenue ?? 0,
- onlineOrders: order?.totalorders ?? 0,
- cancelled: order?.cancelled ?? 0,
- delivered: order?.delivered ?? 0,
+ onlineRevenue: order.revenue,
+ onlineOrders: order.orders,
+ cancelled: order.cancelled,
+ delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
health,
@@ -97,7 +101,7 @@ export function ConsolePage() {
).length,
};
}),
- [scoped, orders.data, posSales, posHealth, requests.data, now],
+ [scoped, byBranch, posSales, posHealth, requests.data, now],
);
const totals = useMemo(
diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx
index 33929d7..620b64c 100644
--- a/src/features/store-admin/pages/DispatchPage.tsx
+++ b/src/features/store-admin/pages/DispatchPage.tsx
@@ -30,6 +30,7 @@ import { assignability, assignedFrom, type Assignability } from '../assignDelive
import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope';
+import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope';
import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { ActivePanel } from '../ActivePanel';
@@ -65,8 +66,25 @@ type Board = ViewMode | 'timing' | 'active' | 'profit';
const GROUPINGS = new Set(['riders', 'stores', 'customers']);
const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board);
+/**
+ * The shop's own dispatch — a thin wrapper that reads the branch scope from
+ * context and hands the board a tenant scope.
+ *
+ * The board itself knows nothing about logins. That is what lets the Nearle
+ * Admin render the same component against a partner instead, and why the
+ * platform board cannot reach a shop's own riders: there is no field on the
+ * partner scope to put a tenantid in.
+ */
export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope();
+ return (
+
+ );
+}
+
+export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const dates = useDateScope();
const [mode, setMode] = useState('riders');
const [focused, setFocused] = useState(null);
@@ -74,16 +92,23 @@ export function DispatchPage() {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all');
- const query = tenantid
- ? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 }
- : undefined;
+ /* One id or the other, never both — see `DispatchScope`. */
+ const isPartnerBoard = scope.kind === 'partner';
+ const tenantid = scope.kind === 'tenant' ? scope.tenantid : 0;
+ const branches = scopeBranches(scope);
+
+ const query =
+ isPartnerBoard || tenantid ? scopeQuery(scope, dates.range) : undefined;
const deliveriesQuery = useDeliveries(query);
const ordersQuery = useOrders(query);
const locationsQuery = useTenantLocations(tenantid || undefined);
+ /* The customer book is a tenant's own list, so a partner board has none —
+ and grouping a partner's rounds by customer would mean reading every
+ merchant's book to name them. The tab is not offered there. */
const customers = useCustomers(
tenantid && mode === 'customers'
- ? { tenantid, locationid: selected ?? undefined, pagesize: 500 }
+ ? { tenantid, locationid: scope.kind === 'tenant' ? scope.locationid : undefined, pagesize: 500 }
: undefined,
);
@@ -228,14 +253,19 @@ export function DispatchPage() {
{branches.length}
-
+ {/* A shop's customer book is its own. Grouping a partner's rounds by
+ customer would mean reading every merchant's book to put a name to
+ a phone number, so the tab is not offered on a partner board. */}
+ {scope.kind === 'tenant' ? (
+
+ ) : null}