diff --git a/README.md b/README.md index 0d5222e..422409f 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,36 @@ deployed build set `VITE_API_BASE` (see `.env.example`). ## What's built -Only the **Nearle Admin** workspace so far: +All three workspaces. Which one opens is decided by the account, not chosen — +see `src/auth/roles.ts`. + +**Nearle Admin** (`issuperadmin`) — the platform operator: - `/nearle/stores` — every tenant, branch counts, per-tenant performance - `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue -- `/nearle/onboard/tenant` — provision a merchant group -- `/nearle/onboard/branch` — commission an outlet with its delivery thresholds +- `/nearle/onboard/tenant` — provision a merchant group and its first outlet - `/nearle/catalogue` — the global catalogue, plus both product-import paths +- `/nearle/partners` — rider partners, and the riders under each +- `/nearle/dispatch` — every partner's live work and shifts +- `/nearle/uploads` — spreadsheets sent to the catalogue service -`/admin/*` (Store Admin) and `/store/*` (Store Manager) render a named -placeholder rather than a 404, so those roles land somewhere that explains -itself. +**Store Admin** (roleid 1 and 3) — one merchant, every branch: + +- `/admin/console` — online and counter sales side by side, per branch +- `/admin/sales` · `/admin/dispatch` · `/admin/inventory` · `/admin/reports` +- `/admin/branches/new` — commission an outlet with its delivery thresholds +- `/admin/users` — back-office people and till accounts +- `/admin/terminals` · `/admin/uploads` · `/admin/profile` · `/admin/onboarding` + +**Store user** (everything else) — one branch, scoped to it: + +- `/store/console` · `/store/products` · `/store/sales` · `/store/dispatch` +- `/store/reports` · `/store/customers` · `/store/terminals` · `/store/staff` +- `/store/uploads` · `/store/account` · `/store/setup` + +Two routes are redirects rather than pages, and deliberately: +`/nearle/onboard/branch` → `/nearle/stores` (a branch is commissioned from the +tenant that will own it), and `/nearle/fleet` → `/nearle/dispatch`. ## Things about the backend that shape this code diff --git a/src/api/products.ts b/src/api/products.ts index 36442d0..7eb1f38 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -71,6 +71,11 @@ export const productsApi = { * SKU lookup in `importSheetProducts` then read as a product with no * `productid`: every sheet import resolved zero ids and wrote no locations * and no stock. Flattened here so no caller sees the grouping. + * + * Nothing calls this today — the importer that did now gets its ids from the + * create response. Kept because it is the only wrapper for a real endpoint + * and the grouping above is the sort of thing the next caller would be + * caught by all over again. */ allProducts: (tenantid: number) => api @@ -93,7 +98,18 @@ export const productsApi = { importFromCatalogue: (rows: ImportCatalogueProductRequest[]) => api.post(`${WEB}/products/importcatalogueproduct`, rows), - /** Single product only — the backend parses one object, not an array. */ + /** + * Single product only — the backend parses one object, not an array. + * + * Answers with the created row, `productid` included. It used to echo back + * the request body, which meant `productid: 0` every time: the id is + * assigned by the database and nothing read it back. Callers that needed it + * — and pricing and stocking a product both do — had to re-read the + * catalogue and find their own row again by SKU. + * + * The payload arrives under `data` rather than `details`, which the client + * already handles. + */ createProduct: (product: Partial) => api.post(`${WEB}/products/create`, product), @@ -267,9 +283,12 @@ export async function importSheetProducts( const subcategoryIdFor = (row: SheetProductRow): number => aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0; + const locationRows: ProductLocationRequest[] = []; + const stockRows: ProductStockRequest[] = []; + for (const [index, row] of rows.entries()) { try { - await productsApi.createProduct({ + const created = await productsApi.createProduct({ tenantid, productname: row.productname, productsku: row.productsku, @@ -286,51 +305,61 @@ export async function importSheetProducts( productdesc: row.productdesc, productstatus: 'Active', }); + + /* + The id comes back from the create now. + + This loop used to collect SKUs, then read the tenant's ENTIRE catalogue + back, build a SKU→product map and match its own rows against it, because + `POST /products/create` answered `productid: 0`. That is fixed on the + backend — the id is the database's and it is returned — so the second + read and the matching are both gone. + + Worth saying what the old way actually cost, because it was not only the + extra request. Matching on SKU means matching on a column nothing + enforces: this importer creates duplicates on re-upload by design, and + the map kept the LAST row for a SKU, so a second upload sent the new + product's price and stock to whichever copy happened to win. A row whose + SKU was blank, or trimmed differently by the sheet, could not be found at + all and was reported as "Created, but could not be found again by SKU" — + a message about the console's own bookkeeping that a shop could do + nothing with. + + A zero here would be worse than the old behaviour, so it is checked + rather than assumed: the product exists either way, and saying so is more + use than silently pricing product 0. + */ + if (!created?.productid) { + failures.push({ + row, + reason: 'Created, but the server did not return its id — price and stock were not set', + }); + onProgress?.(index + 1, rows.length); + continue; + } + createdSkus.push(row.productsku); + locationRows.push({ + tenantid, + locationid, + productid: created.productid, + price: row.retailprice, + status: 'available', + }); + stockRows.push({ + tenantid, + locationid, + productid: created.productid, + quantity: row.quantity, + stocktype: 'in', + status: 'Active', + }); } catch (error) { failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' }); } onProgress?.(index + 1, rows.length); } - if (createdSkus.length === 0) { - return { created: 0, linked: 0, stocked: 0, failures }; - } - - // Resolve the ids the create endpoint refused to hand back. - const all = await productsApi.allProducts(tenantid); - const bySku = new Map(); - for (const product of all) { - if (product.productsku) bySku.set(product.productsku, product); - } - - const locationRows: ProductLocationRequest[] = []; - const stockRows: ProductStockRequest[] = []; - - for (const row of rows) { - if (!createdSkus.includes(row.productsku)) continue; - const product = bySku.get(row.productsku); - if (!product) { - failures.push({ row, reason: 'Created, but could not be found again by SKU' }); - continue; - } - locationRows.push({ - tenantid, - locationid, - productid: product.productid, - price: row.retailprice, - status: 'available', - }); - stockRows.push({ - tenantid, - locationid, - productid: product.productid, - quantity: row.quantity, - stocktype: 'in', - status: 'Active', - }); - } - if (locationRows.length > 0) await productsApi.createProductLocations(locationRows); if (stockRows.length > 0) await productsApi.createProductStock(stockRows); diff --git a/src/api/types.ts b/src/api/types.ts index 47426c4..9f9357d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -118,6 +118,19 @@ export interface TenantInfo { /** Capitalised on the wire. */ Accountname?: string; status: string; + /** + * How many outlets this merchant has. + * + * Sent by `getalltenants` only, so it is optional — every other endpoint + * returning a `TenantInfo` leaves it out. + * + * The store list used to work this out for itself, by counting how many + * times a tenantid appeared, on the belief that the endpoint returned one + * row per tenant-location pair. It returns one row per tenant and always + * has, so the count was always 1 and the platform's "Branches" total was + * really its tenant total. + */ + branchcount?: number; } export interface TenantLocation { @@ -211,6 +224,21 @@ export interface Product { productimage?: string; /** A JSON-encoded array of URLs, held as a string. Parse before use. */ productimages?: string; + /** + * What the global catalogue said about this product when it was imported — + * a JSON-encoded object, held as a string. Parse with `catalogueFactsOf`. + * + * Carries the fields the product table has no columns for: the FSSAI + * licence, nutrition, highlights, providers, the typical retail range and + * the variant key. The import used to drop all of them and the drawer went + * back to the catalogue on every open, which stopped working the moment a + * re-scrape retired the source row — taking a licence number off a product + * the shop was still selling. + * + * Absent on anything that did not come from the catalogue. The drawer still + * falls back to the live lookup for those. + */ + cataloguefacts?: string; productdesc?: string; productsku?: string; brandid?: number; diff --git a/src/features/console/ConsolePage.tsx b/src/features/console/ConsolePage.tsx index 30c3b69..da7d1a8 100644 --- a/src/features/console/ConsolePage.tsx +++ b/src/features/console/ConsolePage.tsx @@ -92,8 +92,11 @@ export function ConsolePage() { onlineOrders: order.orders, cancelled: order.cancelled, delivered: order.delivered, - counterRevenue: pos?.grosssales ?? 0, - counterBills: pos?.billcount ?? 0, + // Both routes to a counter sale — a till that synced, and a till + // that was offline whose day was imported as OFFLINE-tagged orders. + // See the note in the Store Admin console, which had the same gap. + counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue, + counterBills: (pos?.billcount ?? 0) + order.counterOrders, health: summariseBranch(posHealth[index]?.data ?? [], now), pendingRequests: (requests.data ?? []).filter( (entry) => entry.locationid === branch.locationid, diff --git a/src/features/console/useConsoleSeries.ts b/src/features/console/useConsoleSeries.ts index cdda5dc..5d47266 100644 --- a/src/features/console/useConsoleSeries.ts +++ b/src/features/console/useConsoleSeries.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { DateRange } from '@/api/insights'; import { useOrders } from '@/queries/hooks'; import type { PosSalesSummary } from '@/api/types'; +import { isCounterSale } from '@/features/store-admin/branchStats'; /** * The day-by-day series behind the chart and the donut. @@ -63,6 +64,20 @@ export function useConsoleSeries( const amount = amountOf(order); const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled'; + // A counter bill imported from a till sheet is in `orders` like any + // other row, tagged OFFLINE by the backend, and counting it here put + // the shop's counter takings on the app line of this chart and in the + // "Online Sales" figure above it. The POS loop below adds the tills + // that synced; these are the same money arriving the other way. + if (isCounterSale(order)) { + if (day && !isCancelled) { + const row = at(day); + row.counter += amount; + row.counterBills += 1; + } + continue; + } + onlineOrders += 1; if (isCancelled) cancelled += 1; else onlineRevenue += amount; diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx index b2fb5e7..50a9db3 100644 --- a/src/features/nearle-admin/pages/StoreDetailPage.tsx +++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx @@ -82,8 +82,13 @@ 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: stats.orders, - revenue: stats.revenue, + // Both channels, because this page answers "how is this merchant + // doing" rather than "through which door". `branchOrderStats` splits + // app orders from imported counter bills — a split the Store Admin + // console needs and this page does not — so they are added back here + // rather than left half-counted. + orders: stats.orders + stats.counterOrders, + revenue: stats.revenue + stats.counterRevenue, }; }); }, [locations, summary.data]); diff --git a/src/features/nearle-admin/pages/StoresPage.tsx b/src/features/nearle-admin/pages/StoresPage.tsx index cb17811..9ac7b97 100644 --- a/src/features/nearle-admin/pages/StoresPage.tsx +++ b/src/features/nearle-admin/pages/StoresPage.tsx @@ -1,489 +1,506 @@ -import { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Badge } from '@astryxdesign/core/Badge'; -import { Button } from '@astryxdesign/core/Button'; -import { Card } from '@astryxdesign/core/Card'; -import { Table, type TableColumn } from '@astryxdesign/core/Table'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Building2, Plus, Store, Users } from 'lucide-react'; -import { DataState } from '@/components/DataState'; -import { KpiCard } from '@/components/KpiCard'; -import { PageHeader } from '@/components/PageHeader'; -import { SectionHeader } from '@/components/SectionHeader'; -import { useTenants, useTenantsByApproval } from '@/queries/hooks'; -import type { TenantInfo } from '@/api/types'; - -/** A tenant, with its branches folded in. */ -interface TenantRow extends Record { - tenantid: number; - tenantname: string; - companyname: string; - city: string; - branches: number; - status: string; - primaryemail: string; -} - -/** - * The Nearle Admin's home: every tenant on the platform, and how many branches - * sit under each. - * - * `getalltenants` returns one row per tenant-location pair, so the rows are - * grouped by tenantid here rather than shown raw — otherwise a tenant with six - * branches reads as six tenants. - */ -type Tab = 'directory' | 'pending'; - -/** Rows per page. One more than this is fetched, to know whether there is a next. */ -const PAGE_SIZE = 50; - -export function StoresPage() { - const [tab, setTab] = useState('directory'); - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - - /** - * A page at a time, newest first — `getalltenants` orders by `tenantid DESC` - * and has no total, so paging is "ask for one more than we show and see if it - * comes back". A platform list read whole is fine at twenty tenants and not - * at two thousand. - */ - const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 }); - - /** - * The queue of merchants awaiting approval. - * - * A separate endpoint, not a filter: `approved = 0` rows do not appear in - * `getalltenants` at all, so without this they are invisible. Nothing here - * can approve one — `approved` is writable only at creation — so this lists - * and says so. - */ - const pending = useTenantsByApproval('pending'); - - const rows = useMemo(() => { - if (!data) return []; - const grouped = new Map(); - - for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) { - const existing = grouped.get(tenant.tenantid); - if (existing) { - existing.branches += 1; - continue; - } - grouped.set(tenant.tenantid, { - tenantid: tenant.tenantid, - tenantname: tenant.tenantname, - companyname: tenant.companyname ?? '', - city: tenant.city ?? '', - branches: 1, - status: tenant.status ?? 'Unknown', - primaryemail: tenant.primaryemail ?? '', - }); - } - - const all = [...grouped.values()]; - const term = search.trim().toLowerCase(); - if (!term) return all; - return all.filter( - (row) => - row.tenantname.toLowerCase().includes(term) || - row.companyname.toLowerCase().includes(term) || - row.city.toLowerCase().includes(term), - ); - }, [data, search]); - - const totals = useMemo(() => { - const tenants = rows.length; - const branches = rows.reduce((sum, row) => sum + row.branches, 0); - const active = rows.filter((row) => row.status.toLowerCase() === 'active').length; - return { tenants, branches, active }; - }, [rows]); - - const columns: TableColumn[] = [ - { - key: 'tenantname', - header: 'Tenant', - width: { type: 'proportional', value: 3 }, - renderCell: (row) => ( - - - {row.tenantname} - - - {row.companyname || '—'} - - - ), - }, - { - key: 'city', - header: 'City', - width: { type: 'proportional', value: 1.5 }, - renderCell: (row) => {row.city || '—'}, - }, - { - key: 'branches', - header: 'Branches', - align: 'end', - width: { type: 'pixel', value: 110 }, - renderCell: (row) => ( - - {row.branches} - - ), - }, - { - key: 'primaryemail', - header: 'Primary admin', - width: { type: 'proportional', value: 2 }, - renderCell: (row) => ( - - {row.primaryemail || '—'} - - ), - }, - { - key: 'status', - header: 'Status', - align: 'end', - width: { type: 'pixel', value: 120 }, - renderCell: (row) => ( - - ), - }, - { - key: 'actions', - header: '', - align: 'end', - width: { type: 'pixel', value: 110 }, - renderCell: (row) => ( - - Open → - - ), - }, - ]; - - return ( - - {/* No `isLive` here. Both of this page's reads — `useTenants` and - `useTenantsByApproval` — use the `stable` query options: a 5-minute - staleTime and no refetchInterval. The pill claimed a freshness the page - does not have. The pages that keep it (Console, Sales, Counters, store - detail) poll on a real interval. */} - } - href="/nearle/onboard/tenant" - as={Link} - /> - } - /> - -
- } - fill={totals.tenants ? totals.active / totals.tenants : 0} - /> - } - /> - } - /> -
- - - setTab('directory')} - /> - setTab('pending')} - /> - - - {tab === 'pending' ? ( - - ) : ( - - - - - } - /> - - - - ) - } - > - {/* Columns carry meaning, so the table scrolls sideways rather - than dropping any of them. The page itself never scrolls wide. */} -
- - data={rows} - columns={columns} - idKey="tenantid" - density="balanced" - hasHover - dividers="rows" - /> -
-
-
- - - - Page {page} - {search ? ` · filtered from ${rows.length} on this page` : ''} - - - - ); -} - -function Th({ children }: { children?: React.ReactNode }) { - return ( - - {children} - - ); -} - -function Td({ - children, - isMuted, - isStrong, -}: { - children: React.ReactNode; - isMuted?: boolean; - isStrong?: boolean; -}) { - return ( - - {children} - - ); -} +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Badge } from '@astryxdesign/core/Badge'; +import { Button } from '@astryxdesign/core/Button'; +import { Card } from '@astryxdesign/core/Card'; +import { Table, type TableColumn } from '@astryxdesign/core/Table'; +import { HStack } from '@astryxdesign/core/HStack'; +import { Text } from '@astryxdesign/core/Text'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Building2, Plus, Store, Users } from 'lucide-react'; +import { DataState } from '@/components/DataState'; +import { KpiCard } from '@/components/KpiCard'; +import { PageHeader } from '@/components/PageHeader'; +import { SectionHeader } from '@/components/SectionHeader'; +import { useTenants, useTenantsByApproval } from '@/queries/hooks'; +import type { TenantInfo } from '@/api/types'; + +/** A tenant, with its branches folded in. */ +interface TenantRow extends Record { + tenantid: number; + tenantname: string; + companyname: string; + city: string; + branches: number; + status: string; + primaryemail: string; +} + +/** + * The Nearle Admin's home: every tenant on the platform, and how many branches + * sit under each. + * + * ── The branch count comes from the server ────────────────────────────────── + * + * This file used to say `getalltenants` returns one row per tenant-location + * pair, and counted repeated tenantids to get the number of branches. That was + * never true: the query is `SELECT … FROM tenants`, with no join to + * tenantlocations anywhere in it, so a tenantid has never appeared twice — and + * the count was therefore 1 for every merchant on the platform, however many + * shops they ran. The "Branches" and "Avg branches" tiles above this list were + * the tenant count under two other names, and a tenant's own detail page + * contradicted the row that opened it. + * + * The endpoint now returns `branchcount` and this reads it. The grouping by + * tenantid is kept: it costs nothing, and it is the one thing standing between + * a future join on that query and six rows for a six-branch merchant. + */ +type Tab = 'directory' | 'pending'; + +/** Rows per page. One more than this is fetched, to know whether there is a next. */ +const PAGE_SIZE = 50; + +export function StoresPage() { + const [tab, setTab] = useState('directory'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + + /** + * A page at a time, newest first — `getalltenants` orders by `tenantid DESC` + * and has no total, so paging is "ask for one more than we show and see if it + * comes back". A platform list read whole is fine at twenty tenants and not + * at two thousand. + */ + const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 }); + + /** + * The queue of merchants awaiting approval. + * + * A separate endpoint, not a filter: `approved = 0` rows do not appear in + * `getalltenants` at all, so without this they are invisible. Nothing here + * can approve one — `approved` is writable only at creation — so this lists + * and says so. + */ + const pending = useTenantsByApproval('pending'); + + const rows = useMemo(() => { + if (!data) return []; + const grouped = new Map(); + + for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) { + const existing = grouped.get(tenant.tenantid); + if (existing) { + // Only reachable if the endpoint ever starts joining locations. Then + // the rows ARE per-pair and counting them is right again. + existing.branches += 1; + continue; + } + grouped.set(tenant.tenantid, { + tenantid: tenant.tenantid, + tenantname: tenant.tenantname, + companyname: tenant.companyname ?? '', + city: tenant.city ?? '', + // `?? 1` rather than `?? 0`: against a backend that does not send the + // field yet, one branch is the safer guess — every merchant has at + // least the outlet they were onboarded with, and showing 0 next to a + // shop that plainly exists reads as broken rather than as unknown. + branches: tenant.branchcount ?? 1, + status: tenant.status ?? 'Unknown', + primaryemail: tenant.primaryemail ?? '', + }); + } + + const all = [...grouped.values()]; + const term = search.trim().toLowerCase(); + if (!term) return all; + return all.filter( + (row) => + row.tenantname.toLowerCase().includes(term) || + row.companyname.toLowerCase().includes(term) || + row.city.toLowerCase().includes(term), + ); + }, [data, search]); + + const totals = useMemo(() => { + const tenants = rows.length; + const branches = rows.reduce((sum, row) => sum + row.branches, 0); + const active = rows.filter((row) => row.status.toLowerCase() === 'active').length; + return { tenants, branches, active }; + }, [rows]); + + const columns: TableColumn[] = [ + { + key: 'tenantname', + header: 'Tenant', + width: { type: 'proportional', value: 3 }, + renderCell: (row) => ( + + + {row.tenantname} + + + {row.companyname || '—'} + + + ), + }, + { + key: 'city', + header: 'City', + width: { type: 'proportional', value: 1.5 }, + renderCell: (row) => {row.city || '—'}, + }, + { + key: 'branches', + header: 'Branches', + align: 'end', + width: { type: 'pixel', value: 110 }, + renderCell: (row) => ( + + {row.branches} + + ), + }, + { + key: 'primaryemail', + header: 'Primary admin', + width: { type: 'proportional', value: 2 }, + renderCell: (row) => ( + + {row.primaryemail || '—'} + + ), + }, + { + key: 'status', + header: 'Status', + align: 'end', + width: { type: 'pixel', value: 120 }, + renderCell: (row) => ( + + ), + }, + { + key: 'actions', + header: '', + align: 'end', + width: { type: 'pixel', value: 110 }, + renderCell: (row) => ( + + Open → + + ), + }, + ]; + + return ( + + {/* No `isLive` here. Both of this page's reads — `useTenants` and + `useTenantsByApproval` — use the `stable` query options: a 5-minute + staleTime and no refetchInterval. The pill claimed a freshness the page + does not have. The pages that keep it (Console, Sales, Counters, store + detail) poll on a real interval. */} + } + href="/nearle/onboard/tenant" + as={Link} + /> + } + /> + +
+ } + fill={totals.tenants ? totals.active / totals.tenants : 0} + /> + } + /> + } + /> +
+ + + setTab('directory')} + /> + setTab('pending')} + /> + + + {tab === 'pending' ? ( + + ) : ( + + + + + } + /> + + + + ) + } + > + {/* Columns carry meaning, so the table scrolls sideways rather + than dropping any of them. The page itself never scrolls wide. */} +
+ + data={rows} + columns={columns} + idKey="tenantid" + density="balanced" + hasHover + dividers="rows" + /> +
+
+
+ + + + Page {page} + {search ? ` · filtered from ${rows.length} on this page` : ''} + + + + ); +} + +function Th({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ + children, + isMuted, + isStrong, +}: { + children: React.ReactNode; + isMuted?: boolean; + isStrong?: boolean; +}) { + return ( + + {children} + + ); +} diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx index 8722ce2..6f739c1 100644 --- a/src/features/store-admin/ProductDrawer.tsx +++ b/src/features/store-admin/ProductDrawer.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react'; import { productsApi } from '@/api/products'; -import type { Product } from '@/api/types'; +import type { CatalogueProduct, Product } from '@/api/types'; import { queryKeys } from '@/queries/keys'; import { useAppAisles, useCatalogueProduct } from '@/queries/hooks'; import { useBranchScope } from './BranchScope'; @@ -30,20 +30,29 @@ import { STATE_COLOR, STATE_LABEL, stateOf, + catalogueFactsOf, } from './productState'; /** * One product, in full. * - * The import copies seven of the global catalogue's nineteen fields — name, - * description, sku, brand, catalogueid, size and images — and leaves the rest - * behind: highlights, nutrition, FSSAI licence, providers, price range, - * variant key. Those are exactly what someone needs when deciding what to - * charge, so this drawer goes back to the catalogue for them. + * ── Where the catalogue panel gets its data ───────────────────────────────── * - * That lookup often finds nothing, and that is expected rather than an error: - * the tenant's product is a SNAPSHOT, so it outlives its source row whenever a - * re-scrape retires a variant. The panel says so instead of showing a blank. + * The import used to copy eight of the global catalogue's eighteen fields and + * leave the rest behind — highlights, nutrition, the FSSAI licence, providers, + * the price range, the variant key. Those are exactly what someone needs when + * deciding what to charge, so this drawer went back to the catalogue for them + * on every open. + * + * That lookup could not be relied on, and the failure was silent and permanent: + * a tenant's product is a SNAPSHOT and outlives its source row, so the first + * re-scrape to retire a variant took the licence number and the nutrition panel + * off a product the shop was still selling, with no way to get them back. + * + * The import now keeps them, in `products.cataloguefacts`, and this reads that + * first. The lookup remains as the fallback for the two cases with nothing + * stored: products imported before that landed, and sheet-imported products, + * which never had a catalogue row but are still matched on brand and SKU. */ export function ProductDrawer({ product, @@ -74,7 +83,23 @@ export function ProductDrawer({ (aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ?? null; - const source = useCatalogueProduct(product.productbrand, product.productsku); + /* + What the catalogue said, preferred from the product's own copy. + + The import now keeps these fields on the product, so the usual case needs no + request at all and — the point of storing them — still reads correctly after + a re-scrape has retired the source row. + + The live lookup stays for everything with nothing stored: products imported + before this landed, and sheet-imported products, which never had a catalogue + row but are still matched on brand and SKU. Both are queried the same way as + before, so neither loses anything. + */ + const stored = catalogueFactsOf(product); + const source = useCatalogueProduct( + stored ? undefined : product.productbrand, + stored ? undefined : product.productsku, + ); const images = imagesOf(product); const state = stateOf(product); const reason = blockedReason(product); @@ -225,16 +250,19 @@ export function ProductDrawer({ - {/* ── The fields the import left behind ─────────────────────────────── */} - {source.isLoading ? ( + {/* ── The catalogue's reference data ────────────────────────────────── */} + {stored ? ( + + ) : source.isLoading ? ( Looking up the full catalogue entry… ) : source.data ? ( ) : (
- Not available — this item has been revised in the global catalogue since you imported - it. Your copy is unaffected; only the extra reference details are gone. + Not available — this item did not come from the global catalogue, or it has been + revised there since you imported it. Your copy is unaffected; only the extra reference + details are missing.
)} @@ -282,7 +310,15 @@ export function ProductDrawer({ function CatalogueExtras({ data, }: { - data: NonNullable['data']>; + /** + * Partial, because the two sources carry different amounts. + * + * A live lookup returns the whole catalogue row; the copy kept at import + * holds only the fields worth keeping, and omits anything the catalogue did + * not state. Everything below is already written to skip what is absent, so + * the wider type costs nothing and is the honest one. + */ + data: Partial; }) { const lines: [string, string][] = []; if (data.category) lines.push(['Catalogue category', data.category]); diff --git a/src/features/store-admin/branchStats.test.ts b/src/features/store-admin/branchStats.test.ts index f781f07..fcd3446 100644 --- a/src/features/store-admin/branchStats.test.ts +++ b/src/features/store-admin/branchStats.test.ts @@ -93,5 +93,73 @@ test('falls through to the money field the row actually has', () => { 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 }); + assert.deepEqual(NO_ORDERS, { + revenue: 0, + orders: 0, + counterRevenue: 0, + counterOrders: 0, + counterTax: 0, + delivered: 0, + cancelled: 0, + }); +}); + +test('tax on an imported bill is counted, since no till ever saw it', () => { + const stats = branchOrderStats([ + row({ ordervalue: 1185, taxamount: 56.43, deliverytype: 'OFFLINE' }), + row({ ordervalue: 200, taxamount: 10 }), + ]); + assert.equal(stats.get(1185)?.counterTax, 56.43); +}); + +/* ── Counter bills are not app sales ──────────────────────────────────────── + * + * `uploadofflinesales` writes imported till bills into `orders`, tagged + * `deliverytype = 'OFFLINE'`. Nothing here read that tag, so a merchant who + * imported their counter sheet saw the day's takings reported as app revenue + * with "Counter Sales ₹0" printed beside it. + */ + +test('an OFFLINE row is counter revenue, not app revenue', () => { + const stats = branchOrderStats([ + row({ ordervalue: 400 }), + row({ ordervalue: 1185, deliverytype: 'OFFLINE' }), + ]); + assert.equal(stats.get(1185)?.revenue, 400); + assert.equal(stats.get(1185)?.orders, 1); + assert.equal(stats.get(1185)?.counterRevenue, 1185); + assert.equal(stats.get(1185)?.counterOrders, 1); +}); + +test('the tag is read however the column is cased or padded', () => { + // `deliverytype` is free text, like every other status column read in this + // folder, and the import is not the only thing that has ever written it. + const stats = branchOrderStats([ + row({ ordervalue: 10, deliverytype: ' offline ' }), + row({ ordervalue: 20, deliverytype: 'Offline' }), + ]); + assert.equal(stats.get(1185)?.counterRevenue, 30); + assert.equal(stats.get(1185)?.revenue, 0); +}); + +test('a counter bill is not counted as a delivery', () => { + // Imported bills arrive already `delivered` — they were handed over before + // anyone uploaded them — so counting them would report deliveries that never + // went out and dilute the cancellation rate shown next to them. + const stats = branchOrderStats([ + row({ ordervalue: 500, orderstatus: 'delivered', deliverytype: 'OFFLINE' }), + row({ ordervalue: 100, orderstatus: 'delivered' }), + ]); + assert.equal(stats.get(1185)?.delivered, 1); +}); + +test('the ordinary delivery types are still app orders', () => { + // 'B' and 'C' are what the dispatch pipeline selects on; neither is counter. + const stats = branchOrderStats([ + row({ ordervalue: 60, deliverytype: 'B' }), + row({ ordervalue: 40, deliverytype: 'C' }), + row({ ordervalue: 25, deliverytype: '' }), + ]); + assert.equal(stats.get(1185)?.revenue, 125); + assert.equal(stats.get(1185)?.counterRevenue, 0); }); diff --git a/src/features/store-admin/branchStats.ts b/src/features/store-admin/branchStats.ts index 0d4a9af..b6eb518 100644 --- a/src/features/store-admin/branchStats.ts +++ b/src/features/store-admin/branchStats.ts @@ -29,9 +29,40 @@ import { matchesStatus, orderValue } from './orderStatus'; * from the same rows and agree. */ export interface BranchOrderStats { - /** Sum of `orderValue` over the branch's rows, in rupees. */ + /** + * Sum of `orderValue` over the branch's APP orders, in rupees. + * + * Counter bills are not in here — see `counterRevenue`. This used to be + * every row, which meant a bill rung up at a till and imported from a + * spreadsheet was reported as an app sale. + */ revenue: number; orders: number; + /** + * Counter bills that reached `orders` through the offline-sales import. + * + * `uploadofflinesales` writes into `orders`, not into `pos_orders`, and tags + * each row `deliverytype = 'OFFLINE'` — the backend's own constant, set so + * these stay out of the dispatch pipeline while remaining visible to revenue + * reporting. Nothing on this side read the tag, so the console's counter + * figure came only from the POS tables and a merchant who imported their + * till sheet saw the whole day's takings attributed to the app, with + * "Counter Sales ₹0" beside it. + * + * Kept separate from the POS figure rather than merged here: these two + * arrive by different routes, and a caller that wants the counter total adds + * them itself, where the addition is visible. + */ + counterRevenue: number; + counterOrders: number; + /** + * Tax on those imported bills. + * + * Carried because Reports prints a "Tax collected" figure for the counter + * channel and takes it from the POS summary, which by definition cannot see + * a sale that never went through a till. + */ + counterTax: number; delivered: number; cancelled: number; } @@ -39,10 +70,27 @@ export interface BranchOrderStats { export const NO_ORDERS: BranchOrderStats = { revenue: 0, orders: 0, + counterRevenue: 0, + counterOrders: 0, + counterTax: 0, delivered: 0, cancelled: 0, }; +/** + * The backend's tag for a bill rung up at a counter. + * + * `offlineDeliveryType` in `repositories/orderRepository.go`. Compared + * case-insensitively and trimmed for the same reason every other status read + * in this folder is: the column is free text. + */ +const OFFLINE_DELIVERY_TYPE = 'offline'; + +/** True when this row is an imported counter bill rather than an app order. */ +export function isCounterSale(row: OrderRow): boolean { + return String(row.deliverytype ?? '').trim().toLowerCase() === OFFLINE_DELIVERY_TYPE; +} + /** * Group order rows by branch. * @@ -61,13 +109,30 @@ export function branchOrderStats(rows: readonly OrderRow[]): Map = {}): Product => + ({ productid: 1, productname: 'Test Rice 5kg', ...over }) as Product; + +test('the stored facts are read back as the catalogue stated them', () => { + const facts = catalogueFactsOf( + product({ + cataloguefacts: JSON.stringify({ + fssai_license: '12345678901234', + highlights: ['Aged 12 months'], + nutrients: ['Energy 350kcal', 'Protein 7g'], + providers: ['bigbasket'], + price_range: '380-420', + variant_key: 'rice-5kg', + }), + }), + ); + + assert.equal(facts?.fssai_license, '12345678901234'); + assert.deepEqual(facts?.nutrients, ['Energy 350kcal', 'Protein 7g']); + assert.deepEqual(facts?.providers, ['bigbasket']); + assert.equal(facts?.price_range, '380-420'); +}); + +test('pack size comes from the column, not the blob', () => { + // `size` already has a home — the import writes it to `productunit` — so + // storing it twice would be the one field able to disagree with itself. + const facts = catalogueFactsOf( + product({ productunit: '5 kg', cataloguefacts: '{"fssai_license":"1"}' }), + ); + assert.equal(facts?.size, '5 kg'); +}); + +test('a product that never came from the catalogue has no facts', () => { + // A sheet-imported product. Null is the drawer's signal to try the live + // lookup instead, so this must not be an empty object. + assert.equal(catalogueFactsOf(product()), null); + assert.equal(catalogueFactsOf(product({ cataloguefacts: '' })), null); + assert.equal(catalogueFactsOf(product({ cataloguefacts: ' ' })), null); +}); + +test('an empty object reads the same as nothing stored', () => { + // `{}` is what the backend writes when the catalogue stated none of them, + // and a panel with no rows in it is worse than the fallback. + assert.equal(catalogueFactsOf(product({ cataloguefacts: '{}' })), null); +}); + +test('a malformed value degrades to none rather than throwing', () => { + // The column is written by another process and read here. A drawer that + // throws on it takes the whole product with it. + assert.equal(catalogueFactsOf(product({ cataloguefacts: 'not json' })), null); + assert.equal(catalogueFactsOf(product({ cataloguefacts: '{"unclosed":' })), null); +}); + +test('a JSON value that is not an object is refused', () => { + // Valid JSON, wrong shape — an array would spread into numeric keys and + // render a row per character index. + assert.equal(catalogueFactsOf(product({ cataloguefacts: '["a","b"]' })), null); + assert.equal(catalogueFactsOf(product({ cataloguefacts: 'null' })), null); + assert.equal(catalogueFactsOf(product({ cataloguefacts: '42' })), null); +}); diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx index ad757ab..b3b9435 100644 --- a/src/features/store-admin/pages/ConsolePage.tsx +++ b/src/features/store-admin/pages/ConsolePage.tsx @@ -93,8 +93,14 @@ export function ConsolePage() { onlineOrders: order.orders, cancelled: order.cancelled, delivered: order.delivered, - counterRevenue: pos?.grosssales ?? 0, - counterBills: pos?.billcount ?? 0, + // Counter takings arrive by two routes and this is the only place + // that sees both: tills that synced write `pos_orders`, and tills + // that were offline have their day imported from a spreadsheet into + // `orders` tagged OFFLINE. Only the first was counted here, so an + // imported bill showed up under Online Sales and the counter tile + // read ₹0 next to it. + counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue, + counterBills: (pos?.billcount ?? 0) + order.counterOrders, health, pendingRequests: (requests.data ?? []).filter( (entry) => entry.locationid === branch.locationid, diff --git a/src/features/store-admin/pages/ReportsPage.tsx b/src/features/store-admin/pages/ReportsPage.tsx index 1a49c0a..8968eb5 100644 --- a/src/features/store-admin/pages/ReportsPage.tsx +++ b/src/features/store-admin/pages/ReportsPage.tsx @@ -65,9 +65,12 @@ export function ReportsPage() { appOrders: order.orders, cancelled: order.cancelled, delivered: order.delivered, - counterRevenue: pos?.grosssales ?? 0, - counterBills: pos?.billcount ?? 0, - tax: pos?.taxcollected ?? 0, + // Synced tills and imported spreadsheets are both counter sales. + // Reading only the POS tables put every imported bill on the app + // side of the App-versus-counter split this page is built around. + counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue, + counterBills: (pos?.billcount ?? 0) + order.counterOrders, + tax: (pos?.taxcollected ?? 0) + order.counterTax, discount: pos?.discountgiven ?? 0, byDay: pos?.byday ?? [], }; diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index 3fda906..d9b5575 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -39,10 +39,25 @@ import { assignability, assignedFrom } from '../assignDelivery'; import { useSelection } from '@/components/useSelection'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; +import { isCounterSale } from '../branchStats'; import './deliveries.css'; type Tab = 'orders' | 'deliveries' | 'counter'; +/** + * The bill number a counter sale was imported under. + * + * The importer records it twice — `remarks` as `OFFLINE:`, and + * `ordernotes` as prose — but `getorders` returns only `ordernotes`, so that + * is what this reads. The shop knows the sale by the number printed on the + * slip, and matching it back to their own sheet is the whole point of showing + * it; the platform's `orderid` means nothing to them and is only the fallback. + */ +function offlineBillNumber(order: OrderRow): string { + const found = /\(bill\s+(.+?)\)\s*$/i.exec(String(order.ordernotes ?? '')); + return found?.[1]?.trim() || String(order.orderid ?? ''); +} + /** * Sales — app orders, delivery jobs and counter bills. * @@ -94,7 +109,18 @@ export function SalesPage() { const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 }); const posSummary = usePosSalesByBranch(branchIds, dates.range); - const allOrders = orders.data ?? []; + /** + * App orders and imported counter bills, told apart. + * + * `uploadofflinesales` writes a till's day into `orders` like any other row, + * tagged `deliverytype = 'OFFLINE'`. Everything on this page treated the + * whole response as online, so an imported bill was counted under "Online + * Orders", added to Order value and Average order, and was missing from the + * Counter sales tab that exists to show it. + */ + const everyOrder = orders.data ?? []; + const allOrders = useMemo(() => everyOrder.filter((row) => !isCounterSale(row)), [everyOrder]); + const importedBills = useMemo(() => everyOrder.filter(isCounterSale), [everyOrder]); const allDeliveries = deliveries.data ?? []; const orderRows = useMemo( @@ -115,6 +141,31 @@ export function SalesPage() { out.push({ bill, branch: branch?.locationname ?? `Branch ${branchId}` }); } }); + + /* Imported bills belong in this table too — they are counter sales that + reached us on a spreadsheet instead of over MQTT. + + `terminalid` and `cashiername` are left undefined rather than filled + with a placeholder: a sheet does not say which till rang the sale or who + was on it, and inventing either would put a name against a transaction + that has none. The table already renders a dash for what is missing. */ + for (const order of importedBills) { + const branch = branches.find((entry) => entry.locationid === Number(order.locationid)); + out.push({ + bill: { + locationid: Number(order.locationid ?? 0), + invoicenumber: offlineBillNumber(order), + billedat: order.orderdate, + businessdate: order.orderdate, + total: orderValue(order), + taxamount: Number(order.taxamount ?? 0), + itemcount: Number(order.itemcount ?? 0), + customername: order.deliverycustomer ?? '', + customermobile: order.deliverycontactno ?? '', + }, + branch: branch?.locationname ?? order.locationname ?? `Branch ${order.locationid}`, + }); + } const term = keyword.trim().toLowerCase(); return out .filter(({ bill }) => @@ -129,7 +180,7 @@ export function SalesPage() { adrift of the rest, so newest-first was newest-first only within a terminal. */ .sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0)); - }, [billPages, branchIds, branches, keyword]); + }, [billPages, branchIds, branches, keyword, importedBills]); /** * The tab strip for whichever sub-tab is open. @@ -152,18 +203,28 @@ export function SalesPage() { ) as Record; }, [tab, allOrders, allDeliveries]); - const counterTotals = useMemo( - () => - posSummary.reduce( - (acc, page) => ({ - bills: acc.bills + (page.data?.billcount ?? 0), - gross: acc.gross + (page.data?.grosssales ?? 0), - tax: acc.tax + (page.data?.taxcollected ?? 0), - }), - { bills: 0, gross: 0, tax: 0 }, - ), - [posSummary], - ); + const counterTotals = useMemo(() => { + const fromTills = posSummary.reduce( + (acc, page) => ({ + bills: acc.bills + (page.data?.billcount ?? 0), + gross: acc.gross + (page.data?.grosssales ?? 0), + tax: acc.tax + (page.data?.taxcollected ?? 0), + }), + { bills: 0, gross: 0, tax: 0 }, + ); + + // Plus the days that arrived on a spreadsheet. The POS summary cannot see + // these — they were never in `pos_orders` — so a shop that imports rather + // than syncs had "Counter revenue ₹0" over a table of its own bills. + return importedBills.reduce( + (acc, order) => ({ + bills: acc.bills + 1, + gross: acc.gross + orderValue(order), + tax: acc.tax + Number(order.taxamount ?? 0), + }), + fromTills, + ); + }, [posSummary, importedBills]); const orderTotals = useMemo(() => { const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0); diff --git a/src/features/store-admin/productState.ts b/src/features/store-admin/productState.ts index 39d1ac6..8bbe236 100644 --- a/src/features/store-admin/productState.ts +++ b/src/features/store-admin/productState.ts @@ -1,4 +1,4 @@ -import type { Product } from '@/api/types'; +import type { CatalogueProduct, Product } from '@/api/types'; /** * Where a product actually sits in the flow. @@ -101,3 +101,43 @@ export function imagesOf(product: Product): string[] { return first; } } + +/** + * What the global catalogue said about this product, kept at import time. + * + * Shaped as a partial `CatalogueProduct` on purpose: the backend writes the + * catalogue's own wire names into `cataloguefacts`, so the same renderer works + * whether the facts were stored at import or fetched live just now, with no + * translation between the two. + * + * Returns null when there is nothing stored — a sheet-imported product has no + * catalogue record — which is the caller's signal to fall back to the lookup. + * A malformed value is treated as absent rather than thrown: the drawer is + * worth showing without its reference panel. + */ +export function catalogueFactsOf(product: Product): Partial | null { + const raw = (product.cataloguefacts ?? '').trim(); + if (!raw) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + + const facts = parsed as Partial; + // `{}` is what the backend writes when the catalogue said nothing, so an + // empty object means the same as no object: there is nothing to show. + if (Object.keys(facts).length === 0) return null; + + /* + `size` is not in the stored object because it already has a column. + + The import puts the catalogue's `size` into `productunit` — "1 L", "5 kg" — + so storing it twice would be the one field that could disagree with itself. + Filled in here so the renderer sees the same shape either way. + */ + return product.productunit ? { size: product.productunit, ...facts } : facts; +}