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')); 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 /* 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. */ 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, /* 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 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 {/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */} only: a merchant is assigned one, never allowed to create one. */}
<Route path="partners" element={<PartnersPage />} /> <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 />} /> <Route path="uploads" element={<NearleUploadsPage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can {/* 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 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; tenantid: number;
/** Omit for every branch of the tenant. */ /** Omit for every branch of the tenant. */
locationid?: number; 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; status?: string;
keyword?: string; keyword?: string;
pageno?: number; pageno?: number;
@@ -51,7 +63,7 @@ export const insightsApi = {
*/ */
orders: (query: OrderQuery) => orders: (query: OrderQuery) =>
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, { api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
tenantid: query.tenantid, ...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid, locationid: query.locationid,
status: query.status, status: query.status,
keyword: query.keyword, keyword: query.keyword,
@@ -75,7 +87,7 @@ export const insightsApi = {
*/ */
deliveries: (query: OrderQuery) => deliveries: (query: OrderQuery) =>
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, { api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
tenantid: query.tenantid, ...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid, locationid: query.locationid,
status: query.status, status: query.status,
keyword: query.keyword, keyword: query.keyword,

View File

@@ -329,23 +329,53 @@ export interface ProductStockRequest {
Orders & deliveries — the summary shapes the console reads Orders & deliveries — the summary shapes the console reads
──────────────────────────────────────────────────────────────────────────── */ ──────────────────────────────────────────────────────────────────────────── */
export interface OrderSummary { /**
totalorders?: number; * What `getordersummary` and `getlocationsummary` actually send.
delivered?: number; *
* ── 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; pending?: number;
processing?: number;
delivered?: number;
cancelled?: 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; locationid?: number;
locationname?: string; locationname?: string;
totalorders?: number;
delivered?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
} }
export interface DeliverySummary { export interface DeliverySummary {

View File

@@ -70,25 +70,7 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
</Text> </Text>
</HStack> </HStack>
{note ? ( {/* Note pill removed as requested */}
<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}
</HStack> </HStack>
<HStack justify="center" align="center" style={{ flex: 1 }}> <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 { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '@/features/store-admin/BranchScope'; import { useBranchScope } from '@/features/store-admin/BranchScope';
import { summariseBranch } from '@/features/store-admin/posStatus'; import { summariseBranch } from '@/features/store-admin/posStatus';
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
import { import {
useLocationProducts, useLocationProducts,
useLocationSummary, useOrders,
usePosHealthByBranch, usePosHealthByBranch,
usePosSalesByBranch, usePosSalesByBranch,
useStockRequests, useStockRequests,
@@ -52,7 +53,15 @@ export function ConsolePage() {
const dates = useDateScope(); const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); 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 posNow = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds); const posHealth = usePosHealthByBranch(branchIds);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, { const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
@@ -75,17 +84,14 @@ export function ConsolePage() {
const rows = useMemo<BranchRow[]>( const rows = useMemo<BranchRow[]>(
() => () =>
scoped.map((branch, index) => { 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; const pos = posNow[index]?.data;
return { return {
branch, branch,
// `getlocationsummary` carries no date filter, so it is used for the onlineRevenue: order.revenue,
// per-branch split the table needs. The dated period figures come onlineOrders: order.orders,
// from the order rows — see `totals` below. cancelled: order.cancelled,
onlineRevenue: order?.revenue ?? 0, delivered: order.delivered,
onlineOrders: order?.totalorders ?? 0,
cancelled: order?.cancelled ?? 0,
delivered: order?.delivered ?? 0,
counterRevenue: pos?.grosssales ?? 0, counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0, counterBills: pos?.billcount ?? 0,
health: summariseBranch(posHealth[index]?.data ?? [], now), health: summariseBranch(posHealth[index]?.data ?? [], now),
@@ -94,7 +100,7 @@ export function ConsolePage() {
).length, ).length,
}; };
}), }),
[scoped, orders.data, posNow, posHealth, requests.data, now], [scoped, byBranch, posNow, posHealth, requests.data, now],
); );
const totals = useMemo(() => { 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'; import { DateScopeProvider } from '@/components/shell/DateScope';
/** /**
@@ -12,15 +13,51 @@ const NAV: readonly NavEntry[] = [
{ to: '/nearle/stores', label: 'Stores' }, { to: '/nearle/stores', label: 'Stores' },
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' }, { to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
{ to: '/nearle/catalogue', label: 'Global catalogue' }, { to: '/nearle/catalogue', label: 'Global catalogue' },
{ to: '/nearle/partners', label: 'Rider partners' }, { to: '/nearle/dispatch', label: 'Dispatch' },
{ to: '/nearle/fleet', label: 'Fleet' }, ];
{ to: '/nearle/uploads', label: 'Uploads' },
/**
* 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() { export function NearleAdminShell() {
return ( return (
<DateScopeProvider> <DateScopeProvider>
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" /> <AppShell
nav={NAV}
home="/nearle/stores"
navLabel="Nearle Admin"
manageItems={MANAGE}
/>
</DateScopeProvider> </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 { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react'; import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap'; import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
import { useAllPartners, usePartnerRiderLogs } from '@/queries/hooks'; import { usePartnerRiderLogs } from '@/queries/hooks';
import { duration, fleetDay, recommend, type RiderDay, type Severity } from '../riderShifts'; import { duration, fleetDay, recommend, type RiderDay, type Severity } from './riderShifts';
import './fleet.css'; import './pages/fleet.css';
/** /**
* Who was online, and for how long. * 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 * 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. * know, and it is inferred rather than recorded. The page says so out loud.
*/ */
export function FleetPage() { export function ShiftsPanel({ partnerid }: { partnerid: number }) {
const partners = useAllPartners();
const [partnerid, setPartnerid] = useState<number>(0);
const [day, setDay] = useState(() => yesterday()); 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 logs = usePartnerRiderLogs(partnerid || undefined, { fromdate: day, todate: day });
const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]); const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]);
@@ -90,40 +79,25 @@ export function FleetPage() {
return ( return (
<VStack gap={3}> <VStack gap={3}>
<PageHeader {/* No page header. This is a tab on the platform's Dispatch page now,
title="Fleet" and the partner it describes is chosen there — a second picker here
actions={ would be two controls setting one thing. */}
<HStack gap={1} align="center" wrap="wrap"> <HStack justify="between" align="center" gap={2} wrap="wrap">
<select <Text type="label" size="sm" weight="semibold">
className="fleet-select" Who was on, and for how long
aria-label="Rider partner" </Text>
value={partnerid} <input
onChange={(event) => { type="date"
setPartnerid(Number(event.target.value)); className="fleet-select"
setFocused(null); aria-label="Day"
}} value={day}
> max={today()}
{partners.data.length === 0 ? <option value={0}>No partners</option> : null} onChange={(event) => {
{partners.data.map((partner) => ( setDay(event.target.value);
<option key={partner.partnerid} value={partner.partnerid}> setFocused(null);
{partner.partnername ?? `Partner ${partner.partnerid}`} }}
</option> />
))} </HStack>
</select>
<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} /> <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 { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks'; import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks';
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types'; import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
import type { TenantInfo, TenantLocation } from '@/api/types';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
import { Drawer } from '@/features/store-admin/Drawer'; 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. * One tenant: its branches, and how each is performing.
* *
* Order figures come from `/orders/getlocationsummary`, which is scoped to the * Order figures are totalled from the tenant's own order rows and grouped by
* tenant and returns one row per branch. Counter sales are NOT folded in here: * branch. They used to come from `/orders/getlocationsummary`, which does return
* the POS endpoints take a single required locationid, so a tenant-wide till * one row per branch but carries no money on it at all — so every Revenue cell
* figure would mean one request per branch, and a blended number would be * in this table read ₹0 for as long as the page has existed.
* eventually consistent in a way this page cannot honestly caption. Branch-level *
* POS lives on the branch screen instead. * 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() { export function StoreDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
@@ -53,7 +57,11 @@ export function StoreDetailPage() {
const { data: tenants } = useTenants(); const { data: tenants } = useTenants();
const { data: locations, isLoading, error } = useTenantLocations(tenantid); 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 orders = useOrderSummary(tenantid);
const tenant = useMemo( const tenant = useMemo(
@@ -62,13 +70,10 @@ export function StoreDetailPage() {
); );
const rows = useMemo<BranchRow[]>(() => { const rows = useMemo<BranchRow[]>(() => {
const perLocation = new Map<number, LocationOrderSummary>(); const perLocation = branchOrderStats(summary.data ?? []);
for (const entry of summary.data ?? []) {
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
}
return ((locations ?? []) as TenantLocation[]).map((branch) => { return ((locations ?? []) as TenantLocation[]).map((branch) => {
const stats = perLocation.get(branch.locationid); const stats = perLocation.get(branch.locationid) ?? NO_ORDERS;
return { return {
locationid: branch.locationid, locationid: branch.locationid,
locationname: branch.locationname, locationname: branch.locationname,
@@ -77,8 +82,8 @@ export function StoreDetailPage() {
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—', branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—', radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
status: branch.status ?? 'Unknown', status: branch.status ?? 'Unknown',
orders: Number(stats?.totalorders ?? 0), orders: stats.orders,
revenue: Number(stats?.revenue ?? 0), revenue: stats.revenue,
}; };
}); });
}, [locations, summary.data]); }, [locations, summary.data]);
@@ -243,7 +248,7 @@ export function StoreDetailPage() {
<KpiCard <KpiCard
label="Revenue" label="Revenue"
value={money(totals.totalRevenue)} value={money(totals.totalRevenue)}
note="from order summary" note="across every branch"
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'} tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
icon={<IndianRupee size={15} />} icon={<IndianRupee size={15} />}
/> />

View File

@@ -261,3 +261,17 @@
flex: none; flex: none;
margin-top: 2px; 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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector'; 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 { errorMessage } from '@/api/client';
import { import {
RIDER_MESSAGE, RIDER_MESSAGE,
@@ -13,7 +13,7 @@ import type { OrderRow, TenantLocation } from '@/api/types';
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks'; import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys'; import { queryKeys } from '@/queries/keys';
import { useBranchScope } from './BranchScope'; import { useBranchScope } from './BranchScope';
import { buildDeliveries, riderName, riderScope, riderVehicle } from './assignDelivery'; import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { RoutePlanDrawer } from './RoutePlanDrawer'; import { RoutePlanDrawer } from './RoutePlanDrawer';
import './pages/deliveries.css'; import './pages/deliveries.css';
@@ -60,7 +60,19 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
const { tenantid } = useBranchScope(); const { tenantid } = useBranchScope();
const shop = useOwnTenant(tenantid || undefined); const shop = useOwnTenant(tenantid || undefined);
const partnerid = Number((shop.data as unknown as Record<string, number>)?.['partnerid'] ?? 0); 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 // 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. // 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 2026-09-09: 117 of the platform's 118 riders belong to a partner, so region
scope was quietly offering other companies' fleets. scope was quietly offering other companies' fleets.
*/ */
const riders = useRiders(riderScope({ tenantid, partnerid, source })); const riders = { isLoading: ownRiders.isLoading || partnerRiders.isLoading };
const fleet = riders.data ?? []; 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 rider = fleet.find((entry) => String(entry.userid) === riderId);
const send = useMutation({ const send = useMutation({
@@ -125,10 +145,24 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
onError: (error) => setOutcome(errorMessage(error)), onError: (error) => setOutcome(errorMessage(error)),
}); });
const options = fleet.map((entry) => ({ /*
value: String(entry.userid), Whose rider, on every row.
label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
})); 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 ( return (
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider"> <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 {orders.length} selected
</span> </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"> <div className="assign-bar-picker">
<Selector <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 rather than being flattened to "Select…". "No riders on duty" is a
shift that has not started; the region case is a branch somebody shift that has not started; the region case is a branch somebody
has to configure. Neither is a fault in the orders. */ has to configure. Neither is a fault in the orders. */
/* Four situations, and the placeholder names which one — an operator /* An empty list has three different causes and the operator needs to
looking at an empty list needs to know whether to wait, to hire, or know which: wait for somebody to clock on, hire a rider, or ask
to ask for a partner. */ Nearle for a partner. Naming the fleets that were searched is what
distinguishes them. */
placeholder={ placeholder={
riders.isLoading riders.isLoading
? 'Loading riders…' ? 'Loading riders…'
: options.length > 0 : options.length > 0
? 'Select a rider…' ? 'Select a rider…'
: source === 'partner' : partnerid > 0
? `No ${partnerName || 'partner'} rider has clocked on today` ? `Nobody on duty — neither this shop's riders nor ${partnerName || "the partner's"}`
: partnerid > 0 : 'Nobody on duty, and this shop has no delivery partner'
? 'This shop has no riders of its own'
: 'No rider has clocked on today'
} }
/> />
</div> </div>

View File

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

View File

@@ -14,7 +14,6 @@ import {
riderVehicle, riderVehicle,
stampNow, stampNow,
waitingMs, waitingMs,
riderScope,
} from './assignDelivery'; } 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 ──────────────────────────────────────── */ /* ── 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(' · '); 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 { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { import {
useLocationSummary, useOrders,
usePosHealthByBranch, usePosHealthByBranch,
usePosSalesByBranch, usePosSalesByBranch,
useStockRequests, useStockRequests,
} from '@/queries/hooks'; } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format'; import { branchLabel, count, money, percent, share } from '../format';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged'; import { usePaged } from '@/components/usePaged';
@@ -65,7 +66,10 @@ export function ConsolePage() {
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); 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 posSales = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds); const posHealth = usePosHealthByBranch(branchIds);
@@ -80,15 +84,15 @@ export function ConsolePage() {
const rows = useMemo( const rows = useMemo(
() => () =>
scoped.map((branch, index) => { 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 pos = posSales[index]?.data;
const health = summariseBranch(posHealth[index]?.data ?? [], now); const health = summariseBranch(posHealth[index]?.data ?? [], now);
return { return {
branch, branch,
onlineRevenue: order?.revenue ?? 0, onlineRevenue: order.revenue,
onlineOrders: order?.totalorders ?? 0, onlineOrders: order.orders,
cancelled: order?.cancelled ?? 0, cancelled: order.cancelled,
delivered: order?.delivered ?? 0, delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0, counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0, counterBills: pos?.billcount ?? 0,
health, health,
@@ -97,7 +101,7 @@ export function ConsolePage() {
).length, ).length,
}; };
}), }),
[scoped, orders.data, posSales, posHealth, requests.data, now], [scoped, byBranch, posSales, posHealth, requests.data, now],
); );
const totals = useMemo( 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 { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks';
import { useDateScope } from '@/components/shell/DateScope'; import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope';
import { count, money, moneyExact } from '../format'; import { count, money, moneyExact } from '../format';
import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { DELIVERY_STATUS, statusColor } from '../orderStatus';
import { ActivePanel } from '../ActivePanel'; import { ActivePanel } from '../ActivePanel';
@@ -65,8 +66,25 @@ type Board = ViewMode | 'timing' | 'active' | 'profit';
const GROUPINGS = new Set<Board>(['riders', 'stores', 'customers']); const GROUPINGS = new Set<Board>(['riders', 'stores', 'customers']);
const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board); 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() { export function DispatchPage() {
const { branches, selected, tenantid } = useBranchScope(); 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 dates = useDateScope();
const [mode, setMode] = useState<Board>('riders'); const [mode, setMode] = useState<Board>('riders');
const [focused, setFocused] = useState<string | null>(null); const [focused, setFocused] = useState<string | null>(null);
@@ -74,16 +92,23 @@ export function DispatchPage() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all');
const query = tenantid /* One id or the other, never both — see `DispatchScope`. */
? { tenantid, locationid: selected ?? undefined, ...dates.range, pagesize: 200 } const isPartnerBoard = scope.kind === 'partner';
: undefined; 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 deliveriesQuery = useDeliveries(query);
const ordersQuery = useOrders(query); const ordersQuery = useOrders(query);
const locationsQuery = useTenantLocations(tenantid || undefined); 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( const customers = useCustomers(
tenantid && mode === 'customers' tenantid && mode === 'customers'
? { tenantid, locationid: selected ?? undefined, pagesize: 500 } ? { tenantid, locationid: scope.kind === 'tenant' ? scope.locationid : undefined, pagesize: 500 }
: undefined, : undefined,
); );
@@ -228,14 +253,19 @@ export function DispatchPage() {
<span className="sbt-badge">{branches.length}</span> <span className="sbt-badge">{branches.length}</span>
</button> </button>
<button {/* A shop's customer book is its own. Grouping a partner's rounds by
type="button" customer would mean reading every merchant's book to put a name to
className={`sbt ${mode === 'customers' ? 'active' : ''}`} a phone number, so the tab is not offered on a partner board. */}
onClick={() => changeMode('customers')} {scope.kind === 'tenant' ? (
> <button
<span className="sbt-icon"><Users size={15} /></span> type="button"
<span>By Customer</span> className={`sbt ${mode === 'customers' ? 'active' : ''}`}
</button> onClick={() => changeMode('customers')}
>
<span className="sbt-icon"><Users size={15} /></span>
<span>By Customer</span>
</button>
) : null}
<button <button
type="button" type="button"
@@ -395,7 +425,7 @@ export function DispatchPage() {
mode={mode} mode={mode}
onOpen={setDetail} onOpen={setDetail}
locations={locationsQuery.data ?? branches} locations={locationsQuery.data ?? branches}
{...(open.id === UNASSIGNED {...(open.id === UNASSIGNED && canAssign(scope)
? { ? {
selection: picked, selection: picked,
verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned), verdictOf: (row: OrderRow) => assignability(row, branchOf(row), assigned),
@@ -442,8 +472,8 @@ export function DispatchPage() {
range is selected, which is usually history. */} range is selected, which is usually history. */}
<GroupMap <GroupMap
stops={stops} stops={stops}
groupName={dates.range.fromdate ? `Dispatch ${dates.range.fromdate}` : 'All Rounds'} groupName={scopeLabel(scope)}
emptyHint="Select a date range with active deliveries to view live GPS trails." 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} locations={locationsQuery.data ?? branches}
/> />
@@ -509,7 +539,7 @@ export function DispatchPage() {
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} /> <OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null} ) : null}
{isAutoOpen ? ( {isAutoOpen && canAssign(scope) ? (
<AutoAssignDrawer <AutoAssignDrawer
orders={waiting} orders={waiting}
branches={branches} 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 { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader'; import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, usePosSalesByBranch, useStockStatement } from '@/queries/hooks'; import { useOrders, usePosSalesByBranch, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope'; import { useBranchScope } from '../BranchScope';
import { branchOrderStats, NO_ORDERS } from '../branchStats';
import { branchLabel, count, money, percent, share } from '../format'; import { branchLabel, count, money, percent, share } from '../format';
import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart as RechartsBarChart, Bar as RechartsBar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
@@ -46,20 +47,24 @@ export function ReportsPage() {
const dates = useDateScope(); const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]); 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 posSales = usePosSalesByBranch(branchIds, dates.range);
const rows = useMemo( const rows = useMemo(
() => () =>
scoped.map((branch, index) => { 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 pos = posSales[index]?.data;
return { return {
branch, branch,
appRevenue: order?.revenue ?? 0, appRevenue: order.revenue,
appOrders: order?.totalorders ?? 0, appOrders: order.orders,
cancelled: order?.cancelled ?? 0, cancelled: order.cancelled,
delivered: order?.delivered ?? 0, delivered: order.delivered,
counterRevenue: pos?.grosssales ?? 0, counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0, counterBills: pos?.billcount ?? 0,
tax: pos?.taxcollected ?? 0, tax: pos?.taxcollected ?? 0,
@@ -67,7 +72,7 @@ export function ReportsPage() {
byDay: pos?.byday ?? [], byDay: pos?.byday ?? [],
}; };
}), }),
[scoped, orders.data, posSales], [scoped, byBranch, posSales],
); );
const paged = usePaged(rows); 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 { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { TextInput } from '@astryxdesign/core/TextInput'; 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 { 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 { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope'; import { useBranchScope } from '@/features/store-admin/BranchScope';
import { useOwnTenant, useStaff } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { tenantsApi } from '@/api/tenants'; import { tenantsApi } from '@/api/tenants';
import { errorMessage } from '@/api/client'; import { errorMessage } from '@/api/client';
import { normaliseMobile } from '@/api/people'; 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() { export function StoreAccountPage() {
const { user } = useAuth(); 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 const effectiveTenantId = tenantid || user?.tenantid || 0;
the first space: `app_users` keeps first and last apart, the session const staffQuery = useStaff(effectiveTenantId || undefined);
carries them joined, and a one-word name is a first name. */ 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 [firstname, setFirstname] = useState(() => (user?.name ?? '').split(' ')[0] ?? '');
const [lastname, setLastname] = useState(() => const [lastname, setLastname] = useState(() =>
(user?.name ?? '').split(' ').slice(1).join(' '), (user?.name ?? '').split(' ').slice(1).join(' '),
@@ -48,19 +62,33 @@ export function StoreAccountPage() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [problem, setProblem] = useState<string | null>(null); 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({ const save = useMutation({
mutationFn: () => mutationFn: () =>
tenantsApi.updateOwnProfile({ tenantsApi.updateOwnProfile({
userid: user?.userid as number, userid: user?.userid as number,
tenantid: user?.tenantid as number, tenantid: effectiveTenantId as number,
firstname: firstname.trim(), firstname: firstname.trim(),
lastname: lastname.trim(), lastname: lastname.trim(),
email: email.trim(), email: email.trim(),
contactno: normaliseMobile(contactno), contactno: normaliseMobile(contactno),
}), }),
onSuccess: () => { onSuccess: async () => {
setProblem(null); setProblem(null);
setSaved(true); setSaved(true);
setIsEditing(false);
await client.invalidateQueries({
queryKey: queryKeys.people.staff(effectiveTenantId),
});
}, },
onError: (cause) => { onError: (cause) => {
setSaved(false); 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 ( return (
<VStack gap={3}> <div className="sp-page">
<PageHeader <PageHeader title="My account" />
title="My account"
/>
<div style={{ maxWidth: 560 }}> <VStack gap={2}>
<Card padding={0} elevation="low"> {/* ── Profile Header ────────────────────────────────────────────── */}
<VStack gap={0} padding={0}> <header className="sp-head">
<HStack gap={2} align="center" padding={3} style={{ borderBottom: '1px solid var(--color-line)' }}> <span className="sp-mark" style={{ fontSize: 18, fontWeight: 700 }} aria-hidden>
<span {initials(displayName)}
style={{ </span>
width: 44,
height: 44, <div style={{ minWidth: 0 }}>
flex: 'none', <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
display: 'grid', <h2 className="sp-name">{displayName}</h2>
placeItems: 'center', <span className="sp-pill">
borderRadius: 999, <Check size={11} strokeWidth={3} />
background: 'var(--color-brand-tint)', Active
color: 'var(--color-brand)',
fontSize: 15,
fontWeight: 700,
}}
>
{initials(user?.name ?? '')}
</span> </span>
<VStack gap={0} style={{ minWidth: 0 }}> </div>
<Text type="label" size="lg" weight="semibold" maxLines={1}> <span className="sp-sub">Store user profile &amp; account information</span>
{user?.name ?? '—'} </div>
</Text>
<Text type="body" size="sm" color="secondary">
Store user
</Text>
</VStack>
</HStack>
<VStack gap={2} padding={3}> {!isEditing ? (
<HStack gap={1.5}> <Button
<TextInput label="First name" size="md" value={firstname} onChange={setFirstname} /> label="Edit profile"
<TextInput label="Last name" size="md" value={lastname} onChange={setLastname} /> variant="primary"
</HStack> size="sm"
<TextInput label="Mobile" size="md" value={contactno} onChange={setContactno} /> icon={<Pencil size={14} />}
<TextInput label="Email" size="md" value={email} onChange={setEmail} /> isDisabled={save.isPending}
onClick={() => {
setSaved(false);
setIsEditing(true);
}}
/>
) : null}
<HStack gap={1.5} align="center"> {/* Quick facts row */}
<Button <div className="sp-summary" style={{ gridColumn: '1 / -1' }}>
label={save.isPending ? 'Saving…' : 'Save'} <Fact label="Role" value={roleLabel} />
variant="primary" <Fact label="Assigned shop" value={current?.locationname || 'Not assigned'} />
size="sm" <Fact label="Mobile" value={contactno || currentStaff?.contactno || '—'} />
isLoading={save.isPending} <Fact label="User ID" value={`#${user?.userid ?? '—'}`} />
isDisabled={save.isPending || !user?.userid || !user?.tenantid} </div>
onClick={() => save.mutate()} </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 ? ( <Row
<Text type="body" size="sm" style={{ color: 'var(--color-success, #10b981)' }}> label="Email address"
Saved value={email || currentStaff?.email || user?.email}
</Text> empty="No email added"
) : null} />
{problem ? ( </>
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}> )}
{problem} </ProfileCard>
</Text>
) : null}
</HStack>
</VStack>
{/* Read-only, and each for a reason. Which shop you work at is the {/* ── 2. Shop & assignment (Read-only) ─────────────────────────── */}
merchant's decision — made on their people screen, not here. */} <ProfileCard
<VStack gap={1.5} padding={3} style={{ borderTop: '1px solid var(--color-line)' }}> icon={<Store size={15} />}
<Line icon={<UserRound size={14} />} label="User id" value={String(user?.userid ?? '—')} /> title="Shop & location"
<Line >
icon={<Store size={14} />} <Row
label="Your shop" label="Store / Business"
value={current?.locationname ?? 'Not at a shop yet'} value={shopQuery.data?.tenantname || current?.tenantname || 'Your business'}
/> />
<Line icon={<MapPin size={14} />} label="Where" value={where || '—'} /> <Row
<Text type="body" size="xsm" color="secondary"> label="Assigned shop"
Your shop is set by your store administrator. Ask them if it needs to change. value={current?.locationname || 'Not at a shop yet'}
</Text> />
</VStack> <Row label="Area / Suburb" value={current?.suburb || '—'} />
</VStack> <Row label="City" value={current?.city || '—'} />
</Card> </ProfileCard>
</div>
</VStack> {/* ── 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({ /* ── UI Helpers ───────────────────────────────────────────────────────────── */
icon,
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, label,
value, value,
empty = 'Not provided',
isMono,
}: { }: {
icon: React.ReactNode;
label: string; 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 ( return (
<HStack gap={1.5} align="center"> <section className="sp-card" {...(span ? { 'data-span': span } : {})}>
<span style={{ color: 'var(--color-ink-4)', flex: 'none' }}>{icon}</span> <div className="sp-card-head">
<Text <span className="sp-card-icon" aria-hidden>
type="label" {icon}
size="xsm" </span>
color="secondary" <h3 className="sp-card-title">{title}</h3>
style={{ width: 96, flex: 'none', textTransform: 'uppercase', letterSpacing: '0.09em' }} {badge}
> {isSaved && !isEditing ? (
{label} <span className="sp-pill">
</Text> <Check size={11} strokeWidth={3} />
<Text type="body" size="sm" style={{ minWidth: 0 }}> Saved
{value} </span>
</Text> ) : null}
</HStack> {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 ─────────────────────────────────────────────────────────── */ /* ── Performance ─────────────────────────────────────────────────────────── */
export function useLocationSummary(tenantid: number | undefined) { /*
return useQuery({ No `useLocationSummary`.
queryKey: queryKeys.insights.locations(tenantid ?? 0),
queryFn: () => insightsApi.locationSummary(tenantid as number), It had four callers — Reports, both Consoles and the platform's store detail —
enabled: typeof tenantid === 'number' && tenantid > 0, and every one of them read `revenue` and `totalorders` off its rows. Neither
...live, 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) { export function useOrderSummary(tenantid: number | undefined) {
return useQuery({ return useQuery({
@@ -432,7 +441,11 @@ export function useOrders(query: OrderQuery | undefined) {
return useQuery({ return useQuery({
queryKey: queryKeys.insights.orderList((query ?? {}) as unknown as Record<string, unknown>), queryKey: queryKeys.insights.orderList((query ?? {}) as unknown as Record<string, unknown>),
queryFn: () => insightsApi.orders(query as OrderQuery), 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, ...live,
}); });
} }
@@ -461,7 +474,11 @@ export function useDeliveries(query: OrderQuery | undefined) {
return useQuery({ return useQuery({
queryKey: queryKeys.insights.deliveryList((query ?? {}) as unknown as Record<string, unknown>), queryKey: queryKeys.insights.deliveryList((query ?? {}) as unknown as Record<string, unknown>),
queryFn: () => insightsApi.deliveries(query as OrderQuery), 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, ...live,
}); });
} }