This commit is contained in:
2026-09-11 16:32:50 +05:30
parent 81b7d32672
commit 0a11f7543c
24 changed files with 2036 additions and 798 deletions

View File

@@ -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. */}
<Route path="partners" element={<PartnersPage />} />
<Route path="fleet" element={<FleetPage />} />
<Route path="dispatch" element={<NearleDispatchPage />} />
{/* The platform's rider tracking was a "Fleet" page showing presence
only. It is a tab on Dispatch now; the old link still works. */}
<Route path="fleet" element={<Navigate to="/nearle/dispatch" replace />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* 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

View File

@@ -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<OrderRow>(`${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<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
tenantid: query.tenantid,
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,

View File

@@ -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 {

View File

@@ -70,25 +70,7 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
</Text>
</HStack>
{note ? (
<span
style={{
flex: 'none',
maxWidth: '58%',
background: 'var(--color-slate-100)',
color: 'var(--color-slate-500)',
borderRadius: 12,
padding: '2px 8px',
fontSize: 10.5,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{note}
</span>
) : null}
{/* Note pill removed as requested */}
</HStack>
<HStack justify="center" align="center" style={{ flex: 1 }}>

View File

@@ -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<BranchRow[]>(
() =>
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(() => {

View File

@@ -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: <Bike size={13} />,
note: 'Who we are contracted with',
},
{
to: '/nearle/uploads',
label: 'Uploads',
icon: <FileSpreadsheet size={13} />,
note: 'Spreadsheets sent to the catalogue',
},
];
export function NearleAdminShell() {
return (
<DateScopeProvider>
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />
<AppShell
nav={NAV}
home="/nearle/stores"
navLabel="Nearle Admin"
manageItems={MANAGE}
/>
</DateScopeProvider>
);
}

View File

@@ -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<number>(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 (
<VStack gap={3}>
<PageHeader
title="Fleet"
actions={
<HStack gap={1} align="center" wrap="wrap">
<select
className="fleet-select"
aria-label="Rider partner"
value={partnerid}
onChange={(event) => {
setPartnerid(Number(event.target.value));
setFocused(null);
}}
>
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
{partners.data.map((partner) => (
<option key={partner.partnerid} value={partner.partnerid}>
{partner.partnername ?? `Partner ${partner.partnerid}`}
</option>
))}
</select>
<input
type="date"
className="fleet-select"
aria-label="Day"
value={day}
max={today()}
onChange={(event) => {
setDay(event.target.value);
setFocused(null);
}}
/>
</HStack>
}
/>
{/* 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. */}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Who was on, and for how long
</Text>
<input
type="date"
className="fleet-select"
aria-label="Day"
value={day}
max={today()}
onChange={(event) => {
setDay(event.target.value);
setFocused(null);
}}
/>
</HStack>
<Banner advice={advice} />

View File

@@ -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<number>(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 (
<VStack gap={3}>
<PageHeader
isTabsInline
title="Dispatch"
actions={
<HStack gap={1} align="center" wrap="wrap">
<select
className="fleet-select"
aria-label="Rider partner"
value={partnerid}
onChange={(event) => setPartnerid(Number(event.target.value))}
>
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
{partners.data.map((partner) => (
<option key={partner.partnerid} value={partner.partnerid}>
{partner.partnername ?? `Partner ${partner.partnerid}`}
</option>
))}
</select>
</HStack>
}
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. */
<div className="nearle-view-tabs">
<button
type="button"
className={view === 'work' ? 'sbt active' : 'sbt'}
onClick={() => setView('work')}
>
<span className="sbt-icon">
<Bike size={15} />
</span>
<span>The work</span>
</button>
<button
type="button"
className={view === 'shifts' ? 'sbt active' : 'sbt'}
onClick={() => setView('shifts')}
>
<span className="sbt-icon">
<Clock size={15} />
</span>
<span>Shifts</span>
</button>
</div>
}
/>
{partnerid === 0 ? (
<Text type="body" size="sm" color="secondary">
{partners.isLoading
? 'Reading partners…'
: 'No rider partners are onboarded yet. Onboard one from Rider partners, in the account menu, to see their dispatch.'}
</Text>
) : view === 'work' ? (
/* The shops' own board, against a partner. Nothing about it is
duplicated here — this is the same component. */
<DispatchBoard scope={scope} />
) : (
<ShiftsPanel partnerid={partnerid} />
)}
</VStack>
);
}

View File

@@ -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<BranchRow[]>(() => {
const perLocation = new Map<number, LocationOrderSummary>();
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() {
<KpiCard
label="Revenue"
value={money(totals.totalRevenue)}
note="from order summary"
note="across every branch"
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
icon={<IndianRupee size={15} />}
/>

View File

@@ -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;
}

View File

@@ -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<string, number>)?.['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 (
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
@@ -137,37 +171,6 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
{orders.length} selected
</span>
{/* 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 ? (
<div className="assign-source" role="group" aria-label="Which riders to offer">
<button
type="button"
className="assign-source-btn"
aria-pressed={source === 'own'}
onClick={() => {
setSource('own');
setRiderId('');
}}
>
<Bike size={13} />
Own riders
</button>
<button
type="button"
className="assign-source-btn"
aria-pressed={source === 'partner'}
onClick={() => {
setSource('partner');
setRiderId('');
}}
>
<Truck size={13} />
{partnerName || 'Partner riders'}
</button>
</div>
) : null}
<div className="assign-bar-picker">
<Selector
@@ -186,19 +189,18 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
rather than being flattened to "Select…". "No riders on duty" is a
shift that has not started; the region case is a branch somebody
has to configure. Neither is a fault in the orders. */
/* Four situations, and the placeholder names which one — an operator
looking at an empty list needs to know whether to wait, to hire, or
to ask for a partner. */
/* An empty list has three different causes and the operator needs to
know which: wait for somebody to clock on, hire a rider, or ask
Nearle for a partner. Naming the fleets that were searched is what
distinguishes them. */
placeholder={
riders.isLoading
? 'Loading riders…'
: options.length > 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'
}
/>
</div>

View File

@@ -152,41 +152,24 @@ export function ProductsPanel({
}
/>
{rows.length > 0 ? (
<div className="bulkbar" data-active={selection.count > 0 ? 'yes' : 'no'}>
{selection.count > 0 ? (
<div className="bulkbar" data-active="yes">
<HStack justify="between" align="center" gap={2} wrap="wrap">
<label className="bulkbar-all">
<input
type="checkbox"
checked={selection.allChosen}
ref={(el) => {
if (el) el.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
aria-label={
selection.allChosen ? 'Clear selection' : 'Select every product shown'
<Text type="body" size="sm" weight="semibold">
{count(selection.count)} product{selection.count === 1 ? '' : 's'} selected
</Text>
<HStack gap={1} align="center" wrap="wrap">
<Button label="Clear" variant="ghost" size="sm" onClick={selection.clear} />
<Button
label={`Price and release ${selection.count}`}
variant="primary"
size="sm"
onClick={() =>
setPublishing(rows.filter((product) => selection.has(product.productid)))
}
/>
<Text type="body" size="sm">
{selection.count > 0
? `${selection.count} selected`
: `Select all ${rows.length}`}
</Text>
</label>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button label="Clear" variant="ghost" size="sm" onClick={selection.clear} />
<Button
label={`Price and release ${selection.count}`}
variant="primary"
size="sm"
onClick={() =>
setPublishing(rows.filter((product) => selection.has(product.productid)))
}
/>
</HStack>
) : null}
</HStack>
</HStack>
</div>
) : null}
@@ -321,7 +304,20 @@ function ProductTable({
</colgroup>
<thead>
<tr>
<Th />
<Th>
<input
type="checkbox"
className="bulkbar-tick"
checked={selection.allChosen}
ref={(el) => {
if (el) el.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
aria-label={
selection.allChosen ? 'Clear selection' : 'Select all products shown'
}
/>
</Th>
<Th>Product</Th>
<Th>Category</Th>
<Th>Unit</Th>

View File

@@ -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 });
});

View File

@@ -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 };
}

View File

@@ -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>): 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 });
});

View File

@@ -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<number, BranchOrderStats> {
const byBranch = new Map<number, BranchOrderStats>();
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;
}

View File

@@ -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'));
});

View File

@@ -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';
}

View File

@@ -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(

View File

@@ -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<Board>(['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 (
<DispatchBoard
scope={{ kind: 'tenant', tenantid, locationid: selected ?? undefined, branches }}
/>
);
}
export function DispatchBoard({ scope }: { scope: DispatchScope }) {
const dates = useDateScope();
const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(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() {
<span className="sbt-badge">{branches.length}</span>
</button>
<button
type="button"
className={`sbt ${mode === 'customers' ? 'active' : ''}`}
onClick={() => changeMode('customers')}
>
<span className="sbt-icon"><Users size={15} /></span>
<span>By Customer</span>
</button>
{/* 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' ? (
<button
type="button"
className={`sbt ${mode === 'customers' ? 'active' : ''}`}
onClick={() => changeMode('customers')}
>
<span className="sbt-icon"><Users size={15} /></span>
<span>By Customer</span>
</button>
) : null}
<button
type="button"
@@ -395,7 +425,7 @@ export function DispatchPage() {
mode={mode}
onOpen={setDetail}
locations={locationsQuery.data ?? branches}
{...(open.id === UNASSIGNED
{...(open.id === UNASSIGNED && canAssign(scope)
? {
selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
@@ -442,8 +472,8 @@ export function DispatchPage() {
range is selected, which is usually history. */}
<GroupMap
stops={stops}
groupName={dates.range.fromdate ? `Dispatch ${dates.range.fromdate}` : 'All Rounds'}
emptyHint="Select a date range with active deliveries to view live GPS trails."
groupName={scopeLabel(scope)}
emptyHint="Change the date range in the top bar — the board opens on the current month, and a quiet month has nothing to draw."
locations={locationsQuery.data ?? branches}
/>
@@ -509,7 +539,7 @@ export function DispatchPage() {
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null}
{isAutoOpen ? (
{isAutoOpen && canAssign(scope) ? (
<AutoAssignDrawer
orders={waiting}
branches={branches}

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,9 @@ import {
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { TablePager } from '@/components/TablePager';
@@ -46,20 +47,24 @@ export function ReportsPage() {
const dates = useDateScope();
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`. These are the rows
Sales totals, so the two pages now agree. */
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 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;
return {
branch,
appRevenue: order?.revenue ?? 0,
appOrders: order?.totalorders ?? 0,
cancelled: order?.cancelled ?? 0,
delivered: order?.delivered ?? 0,
appRevenue: order.revenue,
appOrders: order.orders,
cancelled: order.cancelled,
delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
tax: pos?.taxcollected ?? 0,
@@ -67,7 +72,7 @@ export function ReportsPage() {
byDay: pos?.byday ?? [],
};
}),
[scoped, orders.data, posSales],
[scoped, byBranch, posSales],
);
const paged = usePaged(rows);

View File

@@ -1,44 +1,58 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
/**
* My account — personal profile & store assignment.
*
* ── What this is, and what it is not ────────────────────────────────────────
*
* A STORE USER's personal account. The record represents the individual: their
* own name, mobile, email, access role, and their branch binding.
*
* ── Redesigned to match the console design system ───────────────────────────
*
* Previously rendered as a single narrow generic card with raw form inputs
* always open. It now shares the unified `.sp-page` profile design system:
*
* - Header with user avatar initials, full name, active status pill, and
* quick summary facts (Role, Assigned shop, Mobile, User ID).
* - Read-first cards with aligned labelled values and clean hairlines.
* - On-demand editing with Cancel / Save actions, loading states, and feedback.
* - Clear boundary on read-only fields: store branch assignment and role
* are merchant actions and explained with inline guidance.
*/
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { TextInput } from '@astryxdesign/core/TextInput';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { MapPin, Store, UserRound } from 'lucide-react';
import { Check, Pencil, ShieldCheck, Store, UserRound, X } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { useOwnTenant, useStaff } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { tenantsApi } from '@/api/tenants';
import { errorMessage } from '@/api/client';
import { normaliseMobile } from '@/api/people';
/**
* My account.
*
* Editable now, and it was not. `PUT /users/update` is a GORM struct update —
* every zero value skipped, so a field cannot be cleared — and it takes a bare
* `userid` with NO tenant check, so a form built on it would let anybody write
* anybody's account. `app_users` also keeps identity beside authorisation, so
* the same struct write covers `roleid`, `locationid` and `status`: a branch
* user could have promoted themselves or moved shop.
*
* `tenants/updateownprofile` fixes both. It is scoped to the caller's own
* account AND their own business in the WHERE clause, and writes four identity
* fields and nothing else.
*
* What stays read-only is deliberate: your shop, and your user id. Which branch
* somebody works at is the merchant's decision, made on the people screen —
* that is the whole point of hiring first and placing second.
*/
export function StoreAccountPage() {
const { user } = useAuth();
const { current } = useBranchScope();
const { current, tenantid } = useBranchScope();
const client = useQueryClient();
/* Seeded from the session, which is what the person signed in as. Split on
the first space: `app_users` keeps first and last apart, the session
carries them joined, and a one-word name is a first name. */
const effectiveTenantId = tenantid || user?.tenantid || 0;
const staffQuery = useStaff(effectiveTenantId || undefined);
const shopQuery = useOwnTenant(effectiveTenantId || undefined);
// Locate the current user's full staff record if present
const currentStaff = useMemo(
() => staffQuery.data?.find((s) => s.userid === user?.userid),
[staffQuery.data, user?.userid],
);
const [isEditing, setIsEditing] = useState(false);
/* Seeded from the staff query when available, falling back to session user */
const [firstname, setFirstname] = useState(() => (user?.name ?? '').split(' ')[0] ?? '');
const [lastname, setLastname] = useState(() =>
(user?.name ?? '').split(' ').slice(1).join(' '),
@@ -48,19 +62,33 @@ export function StoreAccountPage() {
const [saved, setSaved] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
// Sync state when staff data arrives if not actively editing
useEffect(() => {
if (!isEditing && currentStaff) {
if (currentStaff.firstname) setFirstname(currentStaff.firstname);
if (currentStaff.lastname) setLastname(currentStaff.lastname);
if (currentStaff.email) setEmail(currentStaff.email);
if (currentStaff.contactno) setContactno(currentStaff.contactno);
}
}, [currentStaff, isEditing]);
const save = useMutation({
mutationFn: () =>
tenantsApi.updateOwnProfile({
userid: user?.userid as number,
tenantid: user?.tenantid as number,
tenantid: effectiveTenantId as number,
firstname: firstname.trim(),
lastname: lastname.trim(),
email: email.trim(),
contactno: normaliseMobile(contactno),
}),
onSuccess: () => {
onSuccess: async () => {
setProblem(null);
setSaved(true);
setIsEditing(false);
await client.invalidateQueries({
queryKey: queryKeys.people.staff(effectiveTenantId),
});
},
onError: (cause) => {
setSaved(false);
@@ -68,119 +96,345 @@ export function StoreAccountPage() {
},
});
const where = [current?.suburb, current?.city].filter(Boolean).join(', ');
function handleCancel() {
setIsEditing(false);
setProblem(null);
// Reset back to current values
setFirstname(currentStaff?.firstname || (user?.name ?? '').split(' ')[0] || '');
setLastname(
currentStaff?.lastname || (user?.name ?? '').split(' ').slice(1).join(' ') || '',
);
setEmail(currentStaff?.email || user?.email || '');
setContactno(currentStaff?.contactno || '');
}
const displayName =
[firstname, lastname].filter(Boolean).join(' ').trim() ||
currentStaff?.fullname ||
user?.name ||
'Store user';
const roleLabel =
currentStaff?.rolename || (user?.role === 'store-manager' ? 'Store Manager' : 'Store user');
return (
<VStack gap={3}>
<PageHeader
title="My account"
/>
<div className="sp-page">
<PageHeader title="My account" />
<div style={{ maxWidth: 560 }}>
<Card padding={0} elevation="low">
<VStack gap={0} padding={0}>
<HStack gap={2} align="center" padding={3} style={{ borderBottom: '1px solid var(--color-line)' }}>
<span
style={{
width: 44,
height: 44,
flex: 'none',
display: 'grid',
placeItems: 'center',
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
fontSize: 15,
fontWeight: 700,
}}
>
{initials(user?.name ?? '')}
<VStack gap={2}>
{/* ── Profile Header ────────────────────────────────────────────── */}
<header className="sp-head">
<span className="sp-mark" style={{ fontSize: 18, fontWeight: 700 }} aria-hidden>
{initials(displayName)}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h2 className="sp-name">{displayName}</h2>
<span className="sp-pill">
<Check size={11} strokeWidth={3} />
Active
</span>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="lg" weight="semibold" maxLines={1}>
{user?.name ?? '—'}
</Text>
<Text type="body" size="sm" color="secondary">
Store user
</Text>
</VStack>
</HStack>
</div>
<span className="sp-sub">Store user profile &amp; account information</span>
</div>
<VStack gap={2} padding={3}>
<HStack gap={1.5}>
<TextInput label="First name" size="md" value={firstname} onChange={setFirstname} />
<TextInput label="Last name" size="md" value={lastname} onChange={setLastname} />
</HStack>
<TextInput label="Mobile" size="md" value={contactno} onChange={setContactno} />
<TextInput label="Email" size="md" value={email} onChange={setEmail} />
{!isEditing ? (
<Button
label="Edit profile"
variant="primary"
size="sm"
icon={<Pencil size={14} />}
isDisabled={save.isPending}
onClick={() => {
setSaved(false);
setIsEditing(true);
}}
/>
) : null}
<HStack gap={1.5} align="center">
<Button
label={save.isPending ? 'Saving…' : 'Save'}
variant="primary"
size="sm"
isLoading={save.isPending}
isDisabled={save.isPending || !user?.userid || !user?.tenantid}
onClick={() => save.mutate()}
{/* Quick facts row */}
<div className="sp-summary" style={{ gridColumn: '1 / -1' }}>
<Fact label="Role" value={roleLabel} />
<Fact label="Assigned shop" value={current?.locationname || 'Not assigned'} />
<Fact label="Mobile" value={contactno || currentStaff?.contactno || '—'} />
<Fact label="User ID" value={`#${user?.userid ?? '—'}`} />
</div>
</header>
{problem ? (
<div className="sp-card">
<div className="sp-card-body">
<p className="sp-empty" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</p>
</div>
</div>
) : null}
{/* ── Cards Grid ────────────────────────────────────────────────── */}
<div className="sp-cards" style={{ alignItems: 'stretch' }}>
{/* ── 1. Personal information ──────────────────────────────────── */}
<ProfileCard
icon={<UserRound size={15} />}
title="Personal information"
isEditing={isEditing}
isSaved={saved}
isSaving={save.isPending}
onEdit={() => {
setSaved(false);
setIsEditing(true);
}}
onCancel={handleCancel}
onSave={() => save.mutate()}
>
{isEditing ? (
<>
<Field label="First name">
<TextInput
label="First name"
isLabelHidden
size="sm"
value={firstname}
onChange={(val) => {
setSaved(false);
setFirstname(val);
}}
placeholder="Enter first name"
/>
</Field>
<Field label="Last name">
<TextInput
label="Last name"
isLabelHidden
size="sm"
value={lastname}
onChange={(val) => {
setSaved(false);
setLastname(val);
}}
placeholder="Enter last name"
/>
</Field>
<Field label="Mobile number">
<TextInput
label="Mobile number"
isLabelHidden
size="sm"
value={contactno}
onChange={(val) => {
setSaved(false);
setContactno(val);
}}
placeholder="10-digit mobile number"
/>
</Field>
<Field label="Email address">
<TextInput
label="Email address"
isLabelHidden
size="sm"
value={email}
onChange={(val) => {
setSaved(false);
setEmail(val);
}}
placeholder="name@example.com"
/>
</Field>
</>
) : (
<>
<Row label="First name" value={firstname || currentStaff?.firstname} />
<Row label="Last name" value={lastname || currentStaff?.lastname} />
<Row
label="Mobile number"
value={contactno || currentStaff?.contactno}
isMono
empty="No mobile number added"
/>
{saved ? (
<Text type="body" size="sm" style={{ color: 'var(--color-success, #10b981)' }}>
Saved
</Text>
) : null}
{problem ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : null}
</HStack>
</VStack>
<Row
label="Email address"
value={email || currentStaff?.email || user?.email}
empty="No email added"
/>
</>
)}
</ProfileCard>
{/* Read-only, and each for a reason. Which shop you work at is the
merchant's decision — made on their people screen, not here. */}
<VStack gap={1.5} padding={3} style={{ borderTop: '1px solid var(--color-line)' }}>
<Line icon={<UserRound size={14} />} label="User id" value={String(user?.userid ?? '—')} />
<Line
icon={<Store size={14} />}
label="Your shop"
value={current?.locationname ?? 'Not at a shop yet'}
/>
<Line icon={<MapPin size={14} />} label="Where" value={where || '—'} />
<Text type="body" size="xsm" color="secondary">
Your shop is set by your store administrator. Ask them if it needs to change.
</Text>
</VStack>
</VStack>
</Card>
</div>
</VStack>
{/* ── 2. Shop & assignment (Read-only) ─────────────────────────── */}
<ProfileCard
icon={<Store size={15} />}
title="Shop & location"
>
<Row
label="Store / Business"
value={shopQuery.data?.tenantname || current?.tenantname || 'Your business'}
/>
<Row
label="Assigned shop"
value={current?.locationname || 'Not at a shop yet'}
/>
<Row label="Area / Suburb" value={current?.suburb || '—'} />
<Row label="City" value={current?.city || '—'} />
</ProfileCard>
{/* ── 3. Account & credentials (Read-only) ─────────────────────── */}
<ProfileCard
icon={<ShieldCheck size={15} />}
title="Account & credentials"
span="full"
>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: '0 24px',
}}
>
<div>
<Row label="User ID" value={String(user?.userid ?? '—')} isMono />
<Row label="Role & access" value={roleLabel} />
</div>
<div>
<Row
label="Sign-in username"
value={user?.email || currentStaff?.email || '—'}
/>
<Row
label="Account status"
value={
<span className="sp-pill">
<Check size={11} strokeWidth={3} />
Active
</span>
}
/>
</div>
</div>
</ProfileCard>
</div>
</VStack>
</div>
);
}
function Line({
icon,
/* ── UI Helpers ───────────────────────────────────────────────────────────── */
function Fact({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
<span className="sp-summary-label">{label}</span>
<span className="sp-summary-value">{value}</span>
</div>
);
}
function Row({
label,
value,
empty = 'Not provided',
isMono,
}: {
icon: React.ReactNode;
label: string;
value: string;
value: ReactNode;
empty?: string;
isMono?: boolean;
}) {
const isBlank = typeof value !== 'object' && String(value ?? '').trim() === '';
return (
<div className="sp-row">
<span className="sp-label">{label}</span>
<span
className="sp-value"
{...(isBlank ? { 'data-empty': 'yes' } : {})}
{...(isMono && !isBlank ? { 'data-mono': 'yes' } : {})}
>
{isBlank ? empty : value}
</span>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="sp-field">
<span className="sp-field-label">{label}</span>
{children}
</div>
);
}
function ProfileCard({
icon,
title,
badge,
span,
isEditing = false,
isSaved = false,
isSaving = false,
onEdit,
onCancel,
onSave,
children,
}: {
icon: ReactNode;
title: string;
badge?: ReactNode;
span?: 'full';
isEditing?: boolean;
isSaved?: boolean;
isSaving?: boolean;
onEdit?: () => void;
onCancel?: () => void;
onSave?: () => void;
children: ReactNode;
}) {
return (
<HStack gap={1.5} align="center">
<span style={{ color: 'var(--color-ink-4)', flex: 'none' }}>{icon}</span>
<Text
type="label"
size="xsm"
color="secondary"
style={{ width: 96, flex: 'none', textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{label}
</Text>
<Text type="body" size="sm" style={{ minWidth: 0 }}>
{value}
</Text>
</HStack>
<section className="sp-card" {...(span ? { 'data-span': span } : {})}>
<div className="sp-card-head">
<span className="sp-card-icon" aria-hidden>
{icon}
</span>
<h3 className="sp-card-title">{title}</h3>
{badge}
{isSaved && !isEditing ? (
<span className="sp-pill">
<Check size={11} strokeWidth={3} />
Saved
</span>
) : null}
{isEditing ? (
<div style={{ display: 'flex', gap: 8 }}>
<Button
label="Cancel"
variant="ghost"
size="sm"
icon={<X size={14} />}
isDisabled={isSaving}
onClick={onCancel}
/>
<Button
label={isSaving ? 'Saving…' : 'Save'}
variant="primary"
size="sm"
isLoading={isSaving}
isDisabled={isSaving}
onClick={onSave}
/>
</div>
) : onEdit ? (
<Button
label="Edit"
variant="secondary"
size="sm"
icon={<Pencil size={13} />}
onClick={onEdit}
/>
) : null}
</div>
<div className="sp-card-body">{children}</div>
</section>
);
}

View File

@@ -340,14 +340,23 @@ export function useLocationProducts(
/* ── Performance ─────────────────────────────────────────────────────────── */
export function useLocationSummary(tenantid: number | undefined) {
return useQuery({
queryKey: queryKeys.insights.locations(tenantid ?? 0),
queryFn: () => insightsApi.locationSummary(tenantid as number),
enabled: typeof tenantid === 'number' && tenantid > 0,
...live,
});
}
/*
No `useLocationSummary`.
It had four callers — Reports, both Consoles and the platform's store detail —
and every one of them read `revenue` and `totalorders` off its rows. Neither
field is in the response; it carries counts under different names and no money
at all. So every "App revenue" on this console read ₹0 against shops with real
takings, and nothing went red because both fields were optional on a type with
an index signature.
It also ignores `fromdate`/`todate`, so even the counts it does return are
all-time whatever the date picker says.
Per-branch figures now come from the order rows via `branchOrderStats`, which
has both the money and a working date filter. `insightsApi.locationSummary`
stays in the API map, correctly typed, if the counts are ever wanted.
*/
export function useOrderSummary(tenantid: number | undefined) {
return useQuery({
@@ -432,7 +441,11 @@ export function useOrders(query: OrderQuery | undefined) {
return useQuery({
queryKey: queryKeys.insights.orderList((query ?? {}) as unknown as Record<string, unknown>),
queryFn: () => insightsApi.orders(query as OrderQuery),
enabled: Boolean(query?.tenantid),
/* Either scope runs it. `tenantid: 0` is what a partner-scoped read sends
for the unused field (see `scopeQuery`), and gating on the tenant alone
meant the platform board fetched nothing at all — the map came up empty
against a partner whose API rows numbered 376. */
enabled: Boolean(query?.tenantid || query?.partnerid),
...live,
});
}
@@ -461,7 +474,11 @@ export function useDeliveries(query: OrderQuery | undefined) {
return useQuery({
queryKey: queryKeys.insights.deliveryList((query ?? {}) as unknown as Record<string, unknown>),
queryFn: () => insightsApi.deliveries(query as OrderQuery),
enabled: Boolean(query?.tenantid),
/* Either scope runs it. `tenantid: 0` is what a partner-scoped read sends
for the unused field (see `scopeQuery`), and gating on the tenant alone
meant the platform board fetched nothing at all — the map came up empty
against a partner whose API rows numbered 376. */
enabled: Boolean(query?.tenantid || query?.partnerid),
...live,
});
}