redesign
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
141
src/features/nearle-admin/pages/DispatchPage.tsx
Normal file
141
src/features/nearle-admin/pages/DispatchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
97
src/features/store-admin/branchStats.test.ts
Normal file
97
src/features/store-admin/branchStats.test.ts
Normal 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 });
|
||||
});
|
||||
75
src/features/store-admin/branchStats.ts
Normal file
75
src/features/store-admin/branchStats.ts
Normal 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;
|
||||
}
|
||||
121
src/features/store-admin/dispatchScope.test.ts
Normal file
121
src/features/store-admin/dispatchScope.test.ts
Normal 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'));
|
||||
});
|
||||
86
src/features/store-admin/dispatchScope.ts
Normal file
86
src/features/store-admin/dispatchScope.ts
Normal 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';
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user