e2e changes
This commit is contained in:
31
README.md
31
README.md
@@ -35,17 +35,36 @@ deployed build set `VITE_API_BASE` (see `.env.example`).
|
|||||||
|
|
||||||
## What's built
|
## 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` — every tenant, branch counts, per-tenant performance
|
||||||
- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue
|
- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue
|
||||||
- `/nearle/onboard/tenant` — provision a merchant group
|
- `/nearle/onboard/tenant` — provision a merchant group and its first outlet
|
||||||
- `/nearle/onboard/branch` — commission an outlet with its delivery thresholds
|
|
||||||
- `/nearle/catalogue` — the global catalogue, plus both product-import paths
|
- `/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
|
**Store Admin** (roleid 1 and 3) — one merchant, every branch:
|
||||||
placeholder rather than a 404, so those roles land somewhere that explains
|
|
||||||
itself.
|
- `/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
|
## Things about the backend that shape this code
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const productsApi = {
|
|||||||
* SKU lookup in `importSheetProducts` then read as a product with no
|
* SKU lookup in `importSheetProducts` then read as a product with no
|
||||||
* `productid`: every sheet import resolved zero ids and wrote no locations
|
* `productid`: every sheet import resolved zero ids and wrote no locations
|
||||||
* and no stock. Flattened here so no caller sees the grouping.
|
* 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) =>
|
allProducts: (tenantid: number) =>
|
||||||
api
|
api
|
||||||
@@ -93,7 +98,18 @@ export const productsApi = {
|
|||||||
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
||||||
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
|
api.post<unknown>(`${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<Product>) =>
|
createProduct: (product: Partial<Product>) =>
|
||||||
api.post<Product>(`${WEB}/products/create`, product),
|
api.post<Product>(`${WEB}/products/create`, product),
|
||||||
|
|
||||||
@@ -267,9 +283,12 @@ export async function importSheetProducts(
|
|||||||
const subcategoryIdFor = (row: SheetProductRow): number =>
|
const subcategoryIdFor = (row: SheetProductRow): number =>
|
||||||
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
|
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
|
||||||
|
|
||||||
|
const locationRows: ProductLocationRequest[] = [];
|
||||||
|
const stockRows: ProductStockRequest[] = [];
|
||||||
|
|
||||||
for (const [index, row] of rows.entries()) {
|
for (const [index, row] of rows.entries()) {
|
||||||
try {
|
try {
|
||||||
await productsApi.createProduct({
|
const created = await productsApi.createProduct({
|
||||||
tenantid,
|
tenantid,
|
||||||
productname: row.productname,
|
productname: row.productname,
|
||||||
productsku: row.productsku,
|
productsku: row.productsku,
|
||||||
@@ -286,51 +305,61 @@ export async function importSheetProducts(
|
|||||||
productdesc: row.productdesc,
|
productdesc: row.productdesc,
|
||||||
productstatus: 'Active',
|
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);
|
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) {
|
} catch (error) {
|
||||||
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
|
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
|
||||||
}
|
}
|
||||||
onProgress?.(index + 1, rows.length);
|
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<string, Product>();
|
|
||||||
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 (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
|
||||||
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
|
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,19 @@ export interface TenantInfo {
|
|||||||
/** Capitalised on the wire. */
|
/** Capitalised on the wire. */
|
||||||
Accountname?: string;
|
Accountname?: string;
|
||||||
status: 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 {
|
export interface TenantLocation {
|
||||||
@@ -211,6 +224,21 @@ export interface Product {
|
|||||||
productimage?: string;
|
productimage?: string;
|
||||||
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
||||||
productimages?: string;
|
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;
|
productdesc?: string;
|
||||||
productsku?: string;
|
productsku?: string;
|
||||||
brandid?: number;
|
brandid?: number;
|
||||||
|
|||||||
@@ -92,8 +92,11 @@ export function ConsolePage() {
|
|||||||
onlineOrders: order.orders,
|
onlineOrders: order.orders,
|
||||||
cancelled: order.cancelled,
|
cancelled: order.cancelled,
|
||||||
delivered: order.delivered,
|
delivered: order.delivered,
|
||||||
counterRevenue: pos?.grosssales ?? 0,
|
// Both routes to a counter sale — a till that synced, and a till
|
||||||
counterBills: pos?.billcount ?? 0,
|
// 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),
|
health: summariseBranch(posHealth[index]?.data ?? [], now),
|
||||||
pendingRequests: (requests.data ?? []).filter(
|
pendingRequests: (requests.data ?? []).filter(
|
||||||
(entry) => entry.locationid === branch.locationid,
|
(entry) => entry.locationid === branch.locationid,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import type { DateRange } from '@/api/insights';
|
import type { DateRange } from '@/api/insights';
|
||||||
import { useOrders } from '@/queries/hooks';
|
import { useOrders } from '@/queries/hooks';
|
||||||
import type { PosSalesSummary } from '@/api/types';
|
import type { PosSalesSummary } from '@/api/types';
|
||||||
|
import { isCounterSale } from '@/features/store-admin/branchStats';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The day-by-day series behind the chart and the donut.
|
* The day-by-day series behind the chart and the donut.
|
||||||
@@ -63,6 +64,20 @@ export function useConsoleSeries(
|
|||||||
const amount = amountOf(order);
|
const amount = amountOf(order);
|
||||||
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
|
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;
|
onlineOrders += 1;
|
||||||
if (isCancelled) cancelled += 1;
|
if (isCancelled) cancelled += 1;
|
||||||
else onlineRevenue += amount;
|
else onlineRevenue += amount;
|
||||||
|
|||||||
@@ -82,8 +82,13 @@ export function StoreDetailPage() {
|
|||||||
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
|
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
|
||||||
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
|
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
|
||||||
status: branch.status ?? 'Unknown',
|
status: branch.status ?? 'Unknown',
|
||||||
orders: stats.orders,
|
// Both channels, because this page answers "how is this merchant
|
||||||
revenue: stats.revenue,
|
// 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]);
|
}, [locations, summary.data]);
|
||||||
|
|||||||
@@ -1,489 +1,506 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||||
import { DataState } from '@/components/DataState';
|
import { DataState } from '@/components/DataState';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
||||||
import type { TenantInfo } from '@/api/types';
|
import type { TenantInfo } from '@/api/types';
|
||||||
|
|
||||||
/** A tenant, with its branches folded in. */
|
/** A tenant, with its branches folded in. */
|
||||||
interface TenantRow extends Record<string, unknown> {
|
interface TenantRow extends Record<string, unknown> {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
tenantname: string;
|
tenantname: string;
|
||||||
companyname: string;
|
companyname: string;
|
||||||
city: string;
|
city: string;
|
||||||
branches: number;
|
branches: number;
|
||||||
status: string;
|
status: string;
|
||||||
primaryemail: string;
|
primaryemail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Nearle Admin's home: every tenant on the platform, and how many branches
|
* The Nearle Admin's home: every tenant on the platform, and how many branches
|
||||||
* sit under each.
|
* sit under each.
|
||||||
*
|
*
|
||||||
* `getalltenants` returns one row per tenant-location pair, so the rows are
|
* ── The branch count comes from the server ──────────────────────────────────
|
||||||
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
|
*
|
||||||
* branches reads as six tenants.
|
* 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
|
||||||
type Tab = 'directory' | 'pending';
|
* never true: the query is `SELECT … FROM tenants`, with no join to
|
||||||
|
* tenantlocations anywhere in it, so a tenantid has never appeared twice — and
|
||||||
/** Rows per page. One more than this is fetched, to know whether there is a next. */
|
* the count was therefore 1 for every merchant on the platform, however many
|
||||||
const PAGE_SIZE = 50;
|
* 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
|
||||||
export function StoresPage() {
|
* contradicted the row that opened it.
|
||||||
const [tab, setTab] = useState<Tab>('directory');
|
*
|
||||||
const [search, setSearch] = useState('');
|
* The endpoint now returns `branchcount` and this reads it. The grouping by
|
||||||
const [page, setPage] = useState(1);
|
* 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.
|
||||||
/**
|
*/
|
||||||
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
|
type Tab = 'directory' | 'pending';
|
||||||
* 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
|
/** Rows per page. One more than this is fetched, to know whether there is a next. */
|
||||||
* at two thousand.
|
const PAGE_SIZE = 50;
|
||||||
*/
|
|
||||||
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
|
export function StoresPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('directory');
|
||||||
/**
|
const [search, setSearch] = useState('');
|
||||||
* The queue of merchants awaiting approval.
|
const [page, setPage] = useState(1);
|
||||||
*
|
|
||||||
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
|
/**
|
||||||
* `getalltenants` at all, so without this they are invisible. Nothing here
|
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
|
||||||
* can approve one — `approved` is writable only at creation — so this lists
|
* and has no total, so paging is "ask for one more than we show and see if it
|
||||||
* and says so.
|
* comes back". A platform list read whole is fine at twenty tenants and not
|
||||||
*/
|
* at two thousand.
|
||||||
const pending = useTenantsByApproval('pending');
|
*/
|
||||||
|
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
|
||||||
const rows = useMemo<TenantRow[]>(() => {
|
|
||||||
if (!data) return [];
|
/**
|
||||||
const grouped = new Map<number, TenantRow>();
|
* The queue of merchants awaiting approval.
|
||||||
|
*
|
||||||
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
|
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
|
||||||
const existing = grouped.get(tenant.tenantid);
|
* `getalltenants` at all, so without this they are invisible. Nothing here
|
||||||
if (existing) {
|
* can approve one — `approved` is writable only at creation — so this lists
|
||||||
existing.branches += 1;
|
* and says so.
|
||||||
continue;
|
*/
|
||||||
}
|
const pending = useTenantsByApproval('pending');
|
||||||
grouped.set(tenant.tenantid, {
|
|
||||||
tenantid: tenant.tenantid,
|
const rows = useMemo<TenantRow[]>(() => {
|
||||||
tenantname: tenant.tenantname,
|
if (!data) return [];
|
||||||
companyname: tenant.companyname ?? '',
|
const grouped = new Map<number, TenantRow>();
|
||||||
city: tenant.city ?? '',
|
|
||||||
branches: 1,
|
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
|
||||||
status: tenant.status ?? 'Unknown',
|
const existing = grouped.get(tenant.tenantid);
|
||||||
primaryemail: tenant.primaryemail ?? '',
|
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;
|
||||||
const all = [...grouped.values()];
|
continue;
|
||||||
const term = search.trim().toLowerCase();
|
}
|
||||||
if (!term) return all;
|
grouped.set(tenant.tenantid, {
|
||||||
return all.filter(
|
tenantid: tenant.tenantid,
|
||||||
(row) =>
|
tenantname: tenant.tenantname,
|
||||||
row.tenantname.toLowerCase().includes(term) ||
|
companyname: tenant.companyname ?? '',
|
||||||
row.companyname.toLowerCase().includes(term) ||
|
city: tenant.city ?? '',
|
||||||
row.city.toLowerCase().includes(term),
|
// `?? 1` rather than `?? 0`: against a backend that does not send the
|
||||||
);
|
// field yet, one branch is the safer guess — every merchant has at
|
||||||
}, [data, search]);
|
// least the outlet they were onboarded with, and showing 0 next to a
|
||||||
|
// shop that plainly exists reads as broken rather than as unknown.
|
||||||
const totals = useMemo(() => {
|
branches: tenant.branchcount ?? 1,
|
||||||
const tenants = rows.length;
|
status: tenant.status ?? 'Unknown',
|
||||||
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
|
primaryemail: tenant.primaryemail ?? '',
|
||||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
});
|
||||||
return { tenants, branches, active };
|
}
|
||||||
}, [rows]);
|
|
||||||
|
const all = [...grouped.values()];
|
||||||
const columns: TableColumn<TenantRow>[] = [
|
const term = search.trim().toLowerCase();
|
||||||
{
|
if (!term) return all;
|
||||||
key: 'tenantname',
|
return all.filter(
|
||||||
header: 'Tenant',
|
(row) =>
|
||||||
width: { type: 'proportional', value: 3 },
|
row.tenantname.toLowerCase().includes(term) ||
|
||||||
renderCell: (row) => (
|
row.companyname.toLowerCase().includes(term) ||
|
||||||
<VStack gap={0}>
|
row.city.toLowerCase().includes(term),
|
||||||
<Text type="label" size="sm" weight="semibold">
|
);
|
||||||
{row.tenantname}
|
}, [data, search]);
|
||||||
</Text>
|
|
||||||
<Text type="body" size="xsm" color="secondary">
|
const totals = useMemo(() => {
|
||||||
{row.companyname || '—'}
|
const tenants = rows.length;
|
||||||
</Text>
|
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
|
||||||
</VStack>
|
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||||
),
|
return { tenants, branches, active };
|
||||||
},
|
}, [rows]);
|
||||||
{
|
|
||||||
key: 'city',
|
const columns: TableColumn<TenantRow>[] = [
|
||||||
header: 'City',
|
{
|
||||||
width: { type: 'proportional', value: 1.5 },
|
key: 'tenantname',
|
||||||
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
|
header: 'Tenant',
|
||||||
},
|
width: { type: 'proportional', value: 3 },
|
||||||
{
|
renderCell: (row) => (
|
||||||
key: 'branches',
|
<VStack gap={0}>
|
||||||
header: 'Branches',
|
<Text type="label" size="sm" weight="semibold">
|
||||||
align: 'end',
|
{row.tenantname}
|
||||||
width: { type: 'pixel', value: 110 },
|
</Text>
|
||||||
renderCell: (row) => (
|
<Text type="body" size="xsm" color="secondary">
|
||||||
<Text type="label" size="sm" hasTabularNumbers>
|
{row.companyname || '—'}
|
||||||
{row.branches}
|
</Text>
|
||||||
</Text>
|
</VStack>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'primaryemail',
|
key: 'city',
|
||||||
header: 'Primary admin',
|
header: 'City',
|
||||||
width: { type: 'proportional', value: 2 },
|
width: { type: 'proportional', value: 1.5 },
|
||||||
renderCell: (row) => (
|
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
|
||||||
<Text type="body" size="xsm" color="secondary">
|
},
|
||||||
{row.primaryemail || '—'}
|
{
|
||||||
</Text>
|
key: 'branches',
|
||||||
),
|
header: 'Branches',
|
||||||
},
|
align: 'end',
|
||||||
{
|
width: { type: 'pixel', value: 110 },
|
||||||
key: 'status',
|
renderCell: (row) => (
|
||||||
header: 'Status',
|
<Text type="label" size="sm" hasTabularNumbers>
|
||||||
align: 'end',
|
{row.branches}
|
||||||
width: { type: 'pixel', value: 120 },
|
</Text>
|
||||||
renderCell: (row) => (
|
),
|
||||||
<Badge
|
},
|
||||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
{
|
||||||
label={row.status}
|
key: 'primaryemail',
|
||||||
/>
|
header: 'Primary admin',
|
||||||
),
|
width: { type: 'proportional', value: 2 },
|
||||||
},
|
renderCell: (row) => (
|
||||||
{
|
<Text type="body" size="xsm" color="secondary">
|
||||||
key: 'actions',
|
{row.primaryemail || '—'}
|
||||||
header: '',
|
</Text>
|
||||||
align: 'end',
|
),
|
||||||
width: { type: 'pixel', value: 110 },
|
},
|
||||||
renderCell: (row) => (
|
{
|
||||||
<Link
|
key: 'status',
|
||||||
to={`/nearle/stores/${row.tenantid}`}
|
header: 'Status',
|
||||||
style={{
|
align: 'end',
|
||||||
color: 'var(--color-brand)',
|
width: { type: 'pixel', value: 120 },
|
||||||
fontWeight: 600,
|
renderCell: (row) => (
|
||||||
fontSize: 13,
|
<Badge
|
||||||
textDecoration: 'none',
|
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||||
}}
|
label={row.status}
|
||||||
>
|
/>
|
||||||
Open →
|
),
|
||||||
</Link>
|
},
|
||||||
),
|
{
|
||||||
},
|
key: 'actions',
|
||||||
];
|
header: '',
|
||||||
|
align: 'end',
|
||||||
return (
|
width: { type: 'pixel', value: 110 },
|
||||||
<VStack gap={3}>
|
renderCell: (row) => (
|
||||||
{/* No `isLive` here. Both of this page's reads — `useTenants` and
|
<Link
|
||||||
`useTenantsByApproval` — use the `stable` query options: a 5-minute
|
to={`/nearle/stores/${row.tenantid}`}
|
||||||
staleTime and no refetchInterval. The pill claimed a freshness the page
|
style={{
|
||||||
does not have. The pages that keep it (Console, Sales, Counters, store
|
color: 'var(--color-brand)',
|
||||||
detail) poll on a real interval. */}
|
fontWeight: 600,
|
||||||
<PageHeader
|
fontSize: 13,
|
||||||
title="Stores"
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
actions={
|
>
|
||||||
<Button
|
Open →
|
||||||
label="Onboard tenant"
|
</Link>
|
||||||
variant="primary"
|
),
|
||||||
icon={<Plus size={15} />}
|
},
|
||||||
href="/nearle/onboard/tenant"
|
];
|
||||||
as={Link}
|
|
||||||
/>
|
return (
|
||||||
}
|
<VStack gap={3}>
|
||||||
/>
|
{/* No `isLive` here. Both of this page's reads — `useTenants` and
|
||||||
|
`useTenantsByApproval` — use the `stable` query options: a 5-minute
|
||||||
<div className="kpi-grid">
|
staleTime and no refetchInterval. The pill claimed a freshness the page
|
||||||
<KpiCard
|
does not have. The pages that keep it (Console, Sales, Counters, store
|
||||||
label="Tenants"
|
detail) poll on a real interval. */}
|
||||||
value={String(totals.tenants)}
|
<PageHeader
|
||||||
note={`${totals.active} active`}
|
title="Stores"
|
||||||
tone="accent"
|
actions={
|
||||||
icon={<Building2 size={15} />}
|
<Button
|
||||||
fill={totals.tenants ? totals.active / totals.tenants : 0}
|
label="Onboard tenant"
|
||||||
/>
|
variant="primary"
|
||||||
<KpiCard
|
icon={<Plus size={15} />}
|
||||||
label="Branches"
|
href="/nearle/onboard/tenant"
|
||||||
value={String(totals.branches)}
|
as={Link}
|
||||||
note="across the network"
|
/>
|
||||||
tone="neutral"
|
}
|
||||||
icon={<Store size={15} />}
|
/>
|
||||||
/>
|
|
||||||
<KpiCard
|
<div className="kpi-grid">
|
||||||
label="Avg branches"
|
<KpiCard
|
||||||
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
|
label="Tenants"
|
||||||
note="per tenant"
|
value={String(totals.tenants)}
|
||||||
tone="neutral"
|
note={`${totals.active} active`}
|
||||||
icon={<Users size={15} />}
|
tone="accent"
|
||||||
/>
|
icon={<Building2 size={15} />}
|
||||||
</div>
|
fill={totals.tenants ? totals.active / totals.tenants : 0}
|
||||||
|
/>
|
||||||
<HStack gap={0.5} wrap="wrap">
|
<KpiCard
|
||||||
<TabButton
|
label="Branches"
|
||||||
label="Directory"
|
value={String(totals.branches)}
|
||||||
isActive={tab === 'directory'}
|
note="across the network"
|
||||||
onClick={() => setTab('directory')}
|
tone="neutral"
|
||||||
/>
|
icon={<Store size={15} />}
|
||||||
<TabButton
|
/>
|
||||||
label="Awaiting approval"
|
<KpiCard
|
||||||
isActive={tab === 'pending'}
|
label="Avg branches"
|
||||||
badge={pending.data?.length || undefined}
|
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
|
||||||
onClick={() => setTab('pending')}
|
note="per tenant"
|
||||||
/>
|
tone="neutral"
|
||||||
</HStack>
|
icon={<Users size={15} />}
|
||||||
|
/>
|
||||||
{tab === 'pending' ? (
|
</div>
|
||||||
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
|
||||||
) : (
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<VStack gap={1.5}>
|
<TabButton
|
||||||
<SectionHeader
|
label="Directory"
|
||||||
title="Tenant directory"
|
isActive={tab === 'directory'}
|
||||||
note={`${rows.length} shown`}
|
onClick={() => setTab('directory')}
|
||||||
action={
|
/>
|
||||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
<TabButton
|
||||||
<TextInput
|
label="Awaiting approval"
|
||||||
label="Search tenants"
|
isActive={tab === 'pending'}
|
||||||
isLabelHidden
|
badge={pending.data?.length || undefined}
|
||||||
value={search}
|
onClick={() => setTab('pending')}
|
||||||
onChange={setSearch}
|
/>
|
||||||
placeholder="Search by name, company or city…"
|
</HStack>
|
||||||
hasClear
|
|
||||||
size="sm"
|
{tab === 'pending' ? (
|
||||||
/>
|
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
||||||
</div>
|
) : (
|
||||||
}
|
<VStack gap={1.5}>
|
||||||
/>
|
<SectionHeader
|
||||||
|
title="Tenant directory"
|
||||||
<Card padding={0} elevation="low">
|
note={`${rows.length} shown`}
|
||||||
<DataState
|
action={
|
||||||
isLoading={isLoading}
|
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
||||||
error={error}
|
<TextInput
|
||||||
isEmpty={rows.length === 0}
|
label="Search tenants"
|
||||||
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
|
isLabelHidden
|
||||||
emptyDescription={
|
value={search}
|
||||||
search
|
onChange={setSearch}
|
||||||
? 'Try a different name, company or city.'
|
placeholder="Search by name, company or city…"
|
||||||
: 'Onboard the first merchant group to get started.'
|
hasClear
|
||||||
}
|
size="sm"
|
||||||
emptyAction={
|
/>
|
||||||
search ? undefined : (
|
</div>
|
||||||
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
|
}
|
||||||
)
|
/>
|
||||||
}
|
|
||||||
>
|
<Card padding={0} elevation="low">
|
||||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
<DataState
|
||||||
than dropping any of them. The page itself never scrolls wide. */}
|
isLoading={isLoading}
|
||||||
<div className="table-scroll">
|
error={error}
|
||||||
<Table<TenantRow>
|
isEmpty={rows.length === 0}
|
||||||
data={rows}
|
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
|
||||||
columns={columns}
|
emptyDescription={
|
||||||
idKey="tenantid"
|
search
|
||||||
density="balanced"
|
? 'Try a different name, company or city.'
|
||||||
hasHover
|
: 'Onboard the first merchant group to get started.'
|
||||||
dividers="rows"
|
}
|
||||||
/>
|
emptyAction={
|
||||||
</div>
|
search ? undefined : (
|
||||||
</DataState>
|
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
|
||||||
</Card>
|
)
|
||||||
|
}
|
||||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
>
|
||||||
<Text type="body" size="sm" color="secondary">
|
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||||
Page {page}
|
than dropping any of them. The page itself never scrolls wide. */}
|
||||||
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
<div className="table-scroll">
|
||||||
</Text>
|
<Table<TenantRow>
|
||||||
<HStack gap={1}>
|
data={rows}
|
||||||
<Button
|
columns={columns}
|
||||||
label="Previous"
|
idKey="tenantid"
|
||||||
variant="secondary"
|
density="balanced"
|
||||||
size="sm"
|
hasHover
|
||||||
isDisabled={page === 1}
|
dividers="rows"
|
||||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
/>
|
||||||
/>
|
</div>
|
||||||
<Button
|
</DataState>
|
||||||
label="Next"
|
</Card>
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
||||||
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
<Text type="body" size="sm" color="secondary">
|
||||||
onClick={() => setPage((current) => current + 1)}
|
Page {page}
|
||||||
/>
|
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
||||||
</HStack>
|
</Text>
|
||||||
</HStack>
|
<HStack gap={1}>
|
||||||
</VStack>
|
<Button
|
||||||
)}
|
label="Previous"
|
||||||
</VStack>
|
variant="secondary"
|
||||||
);
|
size="sm"
|
||||||
}
|
isDisabled={page === 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
/**
|
/>
|
||||||
* Merchants that have been created but never approved.
|
<Button
|
||||||
*
|
label="Next"
|
||||||
* Read-only, and it says why on the panel rather than offering a button that
|
variant="secondary"
|
||||||
* cannot work: `tenants.approved` is written once, at creation, and there is no
|
size="sm"
|
||||||
* update or approve endpoint anywhere in the backend. Until there is, this is
|
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
||||||
* the only place these rows are visible at all — `getalltenants` cannot see
|
onClick={() => setPage((current) => current + 1)}
|
||||||
* them.
|
/>
|
||||||
*/
|
</HStack>
|
||||||
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
</HStack>
|
||||||
if (isLoading) {
|
</VStack>
|
||||||
return (
|
)}
|
||||||
<Card padding={0} elevation="low">
|
</VStack>
|
||||||
<VStack padding={3}>
|
);
|
||||||
<Text type="body" size="sm" color="secondary">
|
}
|
||||||
Reading the approval queue…
|
|
||||||
</Text>
|
/**
|
||||||
</VStack>
|
* Merchants that have been created but never approved.
|
||||||
</Card>
|
*
|
||||||
);
|
* Read-only, and it says why on the panel rather than offering a button that
|
||||||
}
|
* cannot work: `tenants.approved` is written once, at creation, and there is no
|
||||||
|
* update or approve endpoint anywhere in the backend. Until there is, this is
|
||||||
if (rows.length === 0) {
|
* the only place these rows are visible at all — `getalltenants` cannot see
|
||||||
return (
|
* them.
|
||||||
<Card padding={0} elevation="low">
|
*/
|
||||||
<VStack gap={0.5} padding={3}>
|
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
||||||
<Text type="label" size="sm" weight="semibold">
|
if (isLoading) {
|
||||||
Nothing waiting
|
return (
|
||||||
</Text>
|
<Card padding={0} elevation="low">
|
||||||
<Text type="body" size="sm" color="secondary">
|
<VStack padding={3}>
|
||||||
Every merchant on the platform has been approved.
|
<Text type="body" size="sm" color="secondary">
|
||||||
</Text>
|
Reading the approval queue…
|
||||||
</VStack>
|
</Text>
|
||||||
</Card>
|
</VStack>
|
||||||
);
|
</Card>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
return (
|
|
||||||
<VStack gap={1.5}>
|
if (rows.length === 0) {
|
||||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
return (
|
||||||
These merchants exist but are not approved, so they do not appear in the directory.
|
<Card padding={0} elevation="low">
|
||||||
Approving is a database change today — the backend has no endpoint for it.
|
<VStack gap={0.5} padding={3}>
|
||||||
</Text>
|
<Text type="label" size="sm" weight="semibold">
|
||||||
<Card padding={0} elevation="low">
|
Nothing waiting
|
||||||
<div className="table-scroll">
|
</Text>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<Text type="body" size="sm" color="secondary">
|
||||||
<thead>
|
Every merchant on the platform has been approved.
|
||||||
<tr>
|
</Text>
|
||||||
<Th>Merchant</Th>
|
</VStack>
|
||||||
<Th>Company</Th>
|
</Card>
|
||||||
<Th>Contact</Th>
|
);
|
||||||
<Th>City</Th>
|
}
|
||||||
<Th>Tenant id</Th>
|
|
||||||
</tr>
|
return (
|
||||||
</thead>
|
<VStack gap={1.5}>
|
||||||
<tbody>
|
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||||
{rows.map((row) => (
|
These merchants exist but are not approved, so they do not appear in the directory.
|
||||||
<tr key={`${row.tenantid}-${row.locationid}`}>
|
Approving is a database change today — the backend has no endpoint for it.
|
||||||
<Td isStrong>{row.tenantname}</Td>
|
</Text>
|
||||||
<Td isMuted>{row.companyname || '—'}</Td>
|
<Card padding={0} elevation="low">
|
||||||
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
|
<div className="table-scroll">
|
||||||
<Td isMuted>{row.city || '—'}</Td>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<Td isMuted>{row.tenantid}</Td>
|
<thead>
|
||||||
</tr>
|
<tr>
|
||||||
))}
|
<Th>Merchant</Th>
|
||||||
</tbody>
|
<Th>Company</Th>
|
||||||
</table>
|
<Th>Contact</Th>
|
||||||
</div>
|
<Th>City</Th>
|
||||||
</Card>
|
<Th>Tenant id</Th>
|
||||||
</VStack>
|
</tr>
|
||||||
);
|
</thead>
|
||||||
}
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
function TabButton({
|
<tr key={`${row.tenantid}-${row.locationid}`}>
|
||||||
label,
|
<Td isStrong>{row.tenantname}</Td>
|
||||||
isActive,
|
<Td isMuted>{row.companyname || '—'}</Td>
|
||||||
onClick,
|
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
|
||||||
badge,
|
<Td isMuted>{row.city || '—'}</Td>
|
||||||
}: {
|
<Td isMuted>{row.tenantid}</Td>
|
||||||
label: string;
|
</tr>
|
||||||
isActive: boolean;
|
))}
|
||||||
onClick: () => void;
|
</tbody>
|
||||||
badge?: number;
|
</table>
|
||||||
}) {
|
</div>
|
||||||
return (
|
</Card>
|
||||||
<button
|
</VStack>
|
||||||
type="button"
|
);
|
||||||
onClick={onClick}
|
}
|
||||||
aria-pressed={isActive}
|
|
||||||
style={{
|
function TabButton({
|
||||||
display: 'inline-flex',
|
label,
|
||||||
alignItems: 'center',
|
isActive,
|
||||||
gap: 6,
|
onClick,
|
||||||
height: 32,
|
badge,
|
||||||
padding: '0 12px',
|
}: {
|
||||||
borderRadius: 12,
|
label: string;
|
||||||
border: 0,
|
isActive: boolean;
|
||||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
onClick: () => void;
|
||||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
badge?: number;
|
||||||
fontSize: 13,
|
}) {
|
||||||
fontWeight: isActive ? 600 : 500,
|
return (
|
||||||
cursor: 'pointer',
|
<button
|
||||||
}}
|
type="button"
|
||||||
>
|
onClick={onClick}
|
||||||
{label}
|
aria-pressed={isActive}
|
||||||
{badge ? (
|
style={{
|
||||||
<span
|
display: 'inline-flex',
|
||||||
style={{
|
alignItems: 'center',
|
||||||
minWidth: 18,
|
gap: 6,
|
||||||
height: 18,
|
height: 32,
|
||||||
padding: '0 5px',
|
padding: '0 12px',
|
||||||
borderRadius: 999,
|
borderRadius: 12,
|
||||||
background: 'var(--color-warning, #b7860b)',
|
border: 0,
|
||||||
color: '#fff',
|
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||||
fontSize: 11,
|
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||||
fontWeight: 700,
|
fontSize: 13,
|
||||||
display: 'grid',
|
fontWeight: isActive ? 600 : 500,
|
||||||
placeItems: 'center',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{badge}
|
{label}
|
||||||
</span>
|
{badge ? (
|
||||||
) : null}
|
<span
|
||||||
</button>
|
style={{
|
||||||
);
|
minWidth: 18,
|
||||||
}
|
height: 18,
|
||||||
|
padding: '0 5px',
|
||||||
function Th({ children }: { children?: React.ReactNode }) {
|
borderRadius: 999,
|
||||||
return (
|
background: 'var(--color-warning, #b7860b)',
|
||||||
<th
|
color: '#fff',
|
||||||
style={{
|
fontSize: 11,
|
||||||
textAlign: 'center',
|
fontWeight: 700,
|
||||||
padding: '10px',
|
display: 'grid',
|
||||||
borderBottom: '1px solid var(--color-line)',
|
placeItems: 'center',
|
||||||
fontSize: 12,
|
}}
|
||||||
fontWeight: 600,
|
>
|
||||||
color: 'var(--color-ink-3)',
|
{badge}
|
||||||
whiteSpace: 'nowrap',
|
</span>
|
||||||
}}
|
) : null}
|
||||||
>
|
</button>
|
||||||
{children}
|
);
|
||||||
</th>
|
}
|
||||||
);
|
|
||||||
}
|
function Th({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
function Td({
|
<th
|
||||||
children,
|
style={{
|
||||||
isMuted,
|
textAlign: 'center',
|
||||||
isStrong,
|
padding: '10px',
|
||||||
}: {
|
borderBottom: '1px solid var(--color-line)',
|
||||||
children: React.ReactNode;
|
fontSize: 12,
|
||||||
isMuted?: boolean;
|
fontWeight: 600,
|
||||||
isStrong?: boolean;
|
color: 'var(--color-ink-3)',
|
||||||
}) {
|
whiteSpace: 'nowrap',
|
||||||
return (
|
}}
|
||||||
<td
|
>
|
||||||
style={{
|
{children}
|
||||||
textAlign: 'center',
|
</th>
|
||||||
padding: '10px',
|
);
|
||||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
}
|
||||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
|
||||||
fontWeight: isStrong ? 600 : 400,
|
function Td({
|
||||||
}}
|
children,
|
||||||
>
|
isMuted,
|
||||||
{children}
|
isStrong,
|
||||||
</td>
|
}: {
|
||||||
);
|
children: React.ReactNode;
|
||||||
|
isMuted?: boolean;
|
||||||
|
isStrong?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '10px',
|
||||||
|
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||||
|
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||||
|
fontWeight: isStrong ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
||||||
import { productsApi } from '@/api/products';
|
import { productsApi } from '@/api/products';
|
||||||
import type { Product } from '@/api/types';
|
import type { CatalogueProduct, Product } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
@@ -30,20 +30,29 @@ import {
|
|||||||
STATE_COLOR,
|
STATE_COLOR,
|
||||||
STATE_LABEL,
|
STATE_LABEL,
|
||||||
stateOf,
|
stateOf,
|
||||||
|
catalogueFactsOf,
|
||||||
} from './productState';
|
} from './productState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One product, in full.
|
* One product, in full.
|
||||||
*
|
*
|
||||||
* The import copies seven of the global catalogue's nineteen fields — name,
|
* ── Where the catalogue panel gets its data ─────────────────────────────────
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* That lookup often finds nothing, and that is expected rather than an error:
|
* The import used to copy eight of the global catalogue's eighteen fields and
|
||||||
* the tenant's product is a SNAPSHOT, so it outlives its source row whenever a
|
* leave the rest behind — highlights, nutrition, the FSSAI licence, providers,
|
||||||
* re-scrape retires a variant. The panel says so instead of showing a blank.
|
* 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({
|
export function ProductDrawer({
|
||||||
product,
|
product,
|
||||||
@@ -74,7 +83,23 @@ export function ProductDrawer({
|
|||||||
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
|
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
|
||||||
null;
|
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 images = imagesOf(product);
|
||||||
const state = stateOf(product);
|
const state = stateOf(product);
|
||||||
const reason = blockedReason(product);
|
const reason = blockedReason(product);
|
||||||
@@ -225,16 +250,19 @@ export function ProductDrawer({
|
|||||||
</DrawerCard>
|
</DrawerCard>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── The fields the import left behind ─────────────────────────────── */}
|
{/* ── The catalogue's reference data ────────────────────────────────── */}
|
||||||
{source.isLoading ? (
|
{stored ? (
|
||||||
|
<CatalogueExtras data={stored} />
|
||||||
|
) : source.isLoading ? (
|
||||||
<Note>Looking up the full catalogue entry…</Note>
|
<Note>Looking up the full catalogue entry…</Note>
|
||||||
) : source.data ? (
|
) : source.data ? (
|
||||||
<CatalogueExtras data={source.data} />
|
<CatalogueExtras data={source.data} />
|
||||||
) : (
|
) : (
|
||||||
<Section title="Catalogue entry">
|
<Section title="Catalogue entry">
|
||||||
<Note>
|
<Note>
|
||||||
Not available — this item has been revised in the global catalogue since you imported
|
Not available — this item did not come from the global catalogue, or it has been
|
||||||
it. Your copy is unaffected; only the extra reference details are gone.
|
revised there since you imported it. Your copy is unaffected; only the extra reference
|
||||||
|
details are missing.
|
||||||
</Note>
|
</Note>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
@@ -282,7 +310,15 @@ export function ProductDrawer({
|
|||||||
function CatalogueExtras({
|
function CatalogueExtras({
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
data: NonNullable<ReturnType<typeof useCatalogueProduct>['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<CatalogueProduct>;
|
||||||
}) {
|
}) {
|
||||||
const lines: [string, string][] = [];
|
const lines: [string, string][] = [];
|
||||||
if (data.category) lines.push(['Catalogue category', data.category]);
|
if (data.category) lines.push(['Catalogue category', data.category]);
|
||||||
|
|||||||
@@ -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', () => {
|
test('no rows is an empty map, and callers have a zero to fall back on', () => {
|
||||||
assert.equal(branchOrderStats([]).size, 0);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,9 +29,40 @@ import { matchesStatus, orderValue } from './orderStatus';
|
|||||||
* from the same rows and agree.
|
* from the same rows and agree.
|
||||||
*/
|
*/
|
||||||
export interface BranchOrderStats {
|
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;
|
revenue: number;
|
||||||
orders: 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;
|
delivered: number;
|
||||||
cancelled: number;
|
cancelled: number;
|
||||||
}
|
}
|
||||||
@@ -39,10 +70,27 @@ export interface BranchOrderStats {
|
|||||||
export const NO_ORDERS: BranchOrderStats = {
|
export const NO_ORDERS: BranchOrderStats = {
|
||||||
revenue: 0,
|
revenue: 0,
|
||||||
orders: 0,
|
orders: 0,
|
||||||
|
counterRevenue: 0,
|
||||||
|
counterOrders: 0,
|
||||||
|
counterTax: 0,
|
||||||
delivered: 0,
|
delivered: 0,
|
||||||
cancelled: 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.
|
* Group order rows by branch.
|
||||||
*
|
*
|
||||||
@@ -61,13 +109,30 @@ export function branchOrderStats(rows: readonly OrderRow[]): Map<number, BranchO
|
|||||||
if (!locationid) continue;
|
if (!locationid) continue;
|
||||||
|
|
||||||
const stats = byBranch.get(locationid) ?? { ...NO_ORDERS };
|
const stats = byBranch.get(locationid) ?? { ...NO_ORDERS };
|
||||||
stats.revenue += orderValue(row);
|
|
||||||
stats.orders += 1;
|
if (isCounterSale(row)) {
|
||||||
// `orderstatus`, not `deliverystatus`: the latter is an empty string on
|
stats.counterRevenue += orderValue(row);
|
||||||
// every one of the 181 rows tenant 1147 has, so counting on it would report
|
stats.counterOrders += 1;
|
||||||
// nothing delivered and nothing cancelled for every shop on the platform.
|
stats.counterTax += Number(row.taxamount ?? 0);
|
||||||
if (matchesStatus('delivered', row.orderstatus)) stats.delivered += 1;
|
} else {
|
||||||
if (matchesStatus('cancelled', row.orderstatus)) stats.cancelled += 1;
|
stats.revenue += orderValue(row);
|
||||||
|
stats.orders += 1;
|
||||||
|
// Counted for app orders only, and deliberately inside this branch.
|
||||||
|
//
|
||||||
|
// A counter bill is written `delivered` on arrival — it was handed over
|
||||||
|
// before it was ever imported — so counting these would add a delivery
|
||||||
|
// that never went out, and the cancellation RATE shown beside them
|
||||||
|
// ("0% of app orders") would be diluted by sales that could not be
|
||||||
|
// cancelled in the first place.
|
||||||
|
//
|
||||||
|
// `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);
|
byBranch.set(locationid, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
src/features/store-admin/catalogueFacts.test.ts
Normal file
79
src/features/store-admin/catalogueFacts.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* What the catalogue said, kept on the product.
|
||||||
|
*
|
||||||
|
* The import copied eight of the catalogue's eighteen fields and left the rest
|
||||||
|
* behind — the FSSAI licence, nutrition, highlights, providers, the price range
|
||||||
|
* and the variant key. The drawer covered for it by asking the catalogue again
|
||||||
|
* on every open, which works right up until a re-scrape retires the source row:
|
||||||
|
* a tenant's product is a snapshot and outlives it, so the licence number came
|
||||||
|
* off a product the shop was still selling and could not be got back.
|
||||||
|
*
|
||||||
|
* They are stored now. These are about reading them back safely — the value is
|
||||||
|
* a jsonb blob written by another process, so every shape it could arrive in
|
||||||
|
* has to degrade to "show the rest of the drawer" rather than throw.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type { Product } from '@/api/types';
|
||||||
|
import { catalogueFactsOf } from './productState';
|
||||||
|
|
||||||
|
const product = (over: Partial<Product> = {}): 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);
|
||||||
|
});
|
||||||
@@ -93,8 +93,14 @@ export function ConsolePage() {
|
|||||||
onlineOrders: order.orders,
|
onlineOrders: order.orders,
|
||||||
cancelled: order.cancelled,
|
cancelled: order.cancelled,
|
||||||
delivered: order.delivered,
|
delivered: order.delivered,
|
||||||
counterRevenue: pos?.grosssales ?? 0,
|
// Counter takings arrive by two routes and this is the only place
|
||||||
counterBills: pos?.billcount ?? 0,
|
// 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,
|
health,
|
||||||
pendingRequests: (requests.data ?? []).filter(
|
pendingRequests: (requests.data ?? []).filter(
|
||||||
(entry) => entry.locationid === branch.locationid,
|
(entry) => entry.locationid === branch.locationid,
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ export function ReportsPage() {
|
|||||||
appOrders: order.orders,
|
appOrders: order.orders,
|
||||||
cancelled: order.cancelled,
|
cancelled: order.cancelled,
|
||||||
delivered: order.delivered,
|
delivered: order.delivered,
|
||||||
counterRevenue: pos?.grosssales ?? 0,
|
// Synced tills and imported spreadsheets are both counter sales.
|
||||||
counterBills: pos?.billcount ?? 0,
|
// Reading only the POS tables put every imported bill on the app
|
||||||
tax: pos?.taxcollected ?? 0,
|
// 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,
|
discount: pos?.discountgiven ?? 0,
|
||||||
byDay: pos?.byday ?? [],
|
byDay: pos?.byday ?? [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,10 +39,25 @@ import { assignability, assignedFrom } from '../assignDelivery';
|
|||||||
import { useSelection } from '@/components/useSelection';
|
import { useSelection } from '@/components/useSelection';
|
||||||
import { TablePager } from '@/components/TablePager';
|
import { TablePager } from '@/components/TablePager';
|
||||||
import { usePaged } from '@/components/usePaged';
|
import { usePaged } from '@/components/usePaged';
|
||||||
|
import { isCounterSale } from '../branchStats';
|
||||||
import './deliveries.css';
|
import './deliveries.css';
|
||||||
|
|
||||||
type Tab = 'orders' | 'deliveries' | 'counter';
|
type Tab = 'orders' | 'deliveries' | 'counter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bill number a counter sale was imported under.
|
||||||
|
*
|
||||||
|
* The importer records it twice — `remarks` as `OFFLINE:<billno>`, 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.
|
* Sales — app orders, delivery jobs and counter bills.
|
||||||
*
|
*
|
||||||
@@ -94,7 +109,18 @@ export function SalesPage() {
|
|||||||
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
|
const billPages = usePosBillsByBranch(branchIds, { ...dates.range, pagesize: 200 });
|
||||||
const posSummary = usePosSalesByBranch(branchIds, dates.range);
|
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 allDeliveries = deliveries.data ?? [];
|
||||||
|
|
||||||
const orderRows = useMemo(
|
const orderRows = useMemo(
|
||||||
@@ -115,6 +141,31 @@ export function SalesPage() {
|
|||||||
out.push({ bill, branch: branch?.locationname ?? `Branch ${branchId}` });
|
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();
|
const term = keyword.trim().toLowerCase();
|
||||||
return out
|
return out
|
||||||
.filter(({ bill }) =>
|
.filter(({ bill }) =>
|
||||||
@@ -129,7 +180,7 @@ export function SalesPage() {
|
|||||||
adrift of the rest, so newest-first was newest-first only within a
|
adrift of the rest, so newest-first was newest-first only within a
|
||||||
terminal. */
|
terminal. */
|
||||||
.sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0));
|
.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.
|
* The tab strip for whichever sub-tab is open.
|
||||||
@@ -152,18 +203,28 @@ export function SalesPage() {
|
|||||||
) as Record<StatusKey, number>;
|
) as Record<StatusKey, number>;
|
||||||
}, [tab, allOrders, allDeliveries]);
|
}, [tab, allOrders, allDeliveries]);
|
||||||
|
|
||||||
const counterTotals = useMemo(
|
const counterTotals = useMemo(() => {
|
||||||
() =>
|
const fromTills = posSummary.reduce(
|
||||||
posSummary.reduce(
|
(acc, page) => ({
|
||||||
(acc, page) => ({
|
bills: acc.bills + (page.data?.billcount ?? 0),
|
||||||
bills: acc.bills + (page.data?.billcount ?? 0),
|
gross: acc.gross + (page.data?.grosssales ?? 0),
|
||||||
gross: acc.gross + (page.data?.grosssales ?? 0),
|
tax: acc.tax + (page.data?.taxcollected ?? 0),
|
||||||
tax: acc.tax + (page.data?.taxcollected ?? 0),
|
}),
|
||||||
}),
|
{ bills: 0, gross: 0, tax: 0 },
|
||||||
{ bills: 0, gross: 0, tax: 0 },
|
);
|
||||||
),
|
|
||||||
[posSummary],
|
// 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 orderTotals = useMemo(() => {
|
||||||
const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0);
|
const value = orderRows.reduce((sum, row) => sum + orderValue(row), 0);
|
||||||
|
|||||||
@@ -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.
|
* Where a product actually sits in the flow.
|
||||||
@@ -101,3 +101,43 @@ export function imagesOf(product: Product): string[] {
|
|||||||
return first;
|
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<CatalogueProduct> | 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<CatalogueProduct>;
|
||||||
|
// `{}` 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user