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

{displayName}

+ + + Active - - - {user?.name ?? '—'} - - - Store user - - - +
+ Store user profile & account information +
- - - - - - - + {!isEditing ? ( +
+ + {problem ? ( +
+
+

+ {problem} +

+
+
+ ) : null} + + {/* ── Cards Grid ────────────────────────────────────────────────── */} +
+ {/* ── 1. Personal information ──────────────────────────────────── */} + } + title="Personal information" + isEditing={isEditing} + isSaved={saved} + isSaving={save.isPending} + onEdit={() => { + setSaved(false); + setIsEditing(true); + }} + onCancel={handleCancel} + onSave={() => save.mutate()} + > + {isEditing ? ( + <> + + { + setSaved(false); + setFirstname(val); + }} + placeholder="Enter first name" + /> + + + { + setSaved(false); + setLastname(val); + }} + placeholder="Enter last name" + /> + + + { + setSaved(false); + setContactno(val); + }} + placeholder="10-digit mobile number" + /> + + + { + setSaved(false); + setEmail(val); + }} + placeholder="name@example.com" + /> + + + ) : ( + <> + + + - {saved ? ( - - Saved - - ) : null} - {problem ? ( - - {problem} - - ) : null} - - + + + )} + - {/* Read-only, and each for a reason. Which shop you work at is the - merchant's decision — made on their people screen, not here. */} - - } label="User id" value={String(user?.userid ?? '—')} /> - } - label="Your shop" - value={current?.locationname ?? 'Not at a shop yet'} - /> - } label="Where" value={where || '—'} /> - - Your shop is set by your store administrator. Ask them if it needs to change. - - - - -
-
+ {/* ── 2. Shop & assignment (Read-only) ─────────────────────────── */} + } + title="Shop & location" + > + + + + + + + {/* ── 3. Account & credentials (Read-only) ─────────────────────── */} + } + title="Account & credentials" + span="full" + > +
+
+ + +
+
+ + + + Active + + } + /> +
+
+
+
+ +
); } -function Line({ - icon, +/* ── UI Helpers ───────────────────────────────────────────────────────────── */ + +function Fact({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +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 ( +
+ {label} + + {isBlank ? empty : value} + +
+ ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +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 ( - - {icon} - - {label} - - - {value} - - +
+
+ + {icon} + +

{title}

+ {badge} + {isSaved && !isEditing ? ( + + + Saved + + ) : null} + {isEditing ? ( +
+
+ ) : onEdit ? ( +
+
{children}
+
); } diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index a6208fd..4665a1d 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -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), 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), 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, }); }