e2e changes

This commit is contained in:
2026-09-16 17:12:30 +05:30
parent 5cb9872037
commit f25b3fcf65
15 changed files with 1057 additions and 583 deletions

View File

@@ -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

View File

@@ -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<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>) =>
api.post<Product>(`${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<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 (stockRows.length > 0) await productsApi.createProductStock(stockRows);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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<string, unknown> {
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<Tab>('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<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
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<TenantRow>[] = [
{
key: 'tenantname',
header: 'Tenant',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.tenantname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.companyname || '—'}
</Text>
</VStack>
),
},
{
key: 'city',
header: 'City',
width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
},
{
key: 'branches',
header: 'Branches',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{row.branches}
</Text>
),
},
{
key: 'primaryemail',
header: 'Primary admin',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: '',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Link
to={`/nearle/stores/${row.tenantid}`}
style={{
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 13,
textDecoration: 'none',
}}
>
Open →
</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
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. */}
<PageHeader
title="Stores"
actions={
<Button
label="Onboard tenant"
variant="primary"
icon={<Plus size={15} />}
href="/nearle/onboard/tenant"
as={Link}
/>
}
/>
<div className="kpi-grid">
<KpiCard
label="Tenants"
value={String(totals.tenants)}
note={`${totals.active} active`}
tone="accent"
icon={<Building2 size={15} />}
fill={totals.tenants ? totals.active / totals.tenants : 0}
/>
<KpiCard
label="Branches"
value={String(totals.branches)}
note="across the network"
tone="neutral"
icon={<Store size={15} />}
/>
<KpiCard
label="Avg branches"
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
note="per tenant"
tone="neutral"
icon={<Users size={15} />}
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Tenant directory"
note={`${rows.length} shown`}
action={
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
<TextInput
label="Search tenants"
isLabelHidden
value={search}
onChange={setSearch}
placeholder="Search by name, company or city…"
hasClear
size="sm"
/>
</div>
}
/>
<Card padding={0} elevation="low">
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyDescription={
search
? 'Try a different name, company or city.'
: 'Onboard the first merchant group to get started.'
}
emptyAction={
search ? undefined : (
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
)
}
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<TenantRow>
data={rows}
columns={columns}
idKey="tenantid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* 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
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
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>
);
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<string, unknown> {
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<Tab>('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<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
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<TenantRow>[] = [
{
key: 'tenantname',
header: 'Tenant',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.tenantname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.companyname || '—'}
</Text>
</VStack>
),
},
{
key: 'city',
header: 'City',
width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
},
{
key: 'branches',
header: 'Branches',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{row.branches}
</Text>
),
},
{
key: 'primaryemail',
header: 'Primary admin',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: '',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Link
to={`/nearle/stores/${row.tenantid}`}
style={{
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 13,
textDecoration: 'none',
}}
>
Open →
</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
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. */}
<PageHeader
title="Stores"
actions={
<Button
label="Onboard tenant"
variant="primary"
icon={<Plus size={15} />}
href="/nearle/onboard/tenant"
as={Link}
/>
}
/>
<div className="kpi-grid">
<KpiCard
label="Tenants"
value={String(totals.tenants)}
note={`${totals.active} active`}
tone="accent"
icon={<Building2 size={15} />}
fill={totals.tenants ? totals.active / totals.tenants : 0}
/>
<KpiCard
label="Branches"
value={String(totals.branches)}
note="across the network"
tone="neutral"
icon={<Store size={15} />}
/>
<KpiCard
label="Avg branches"
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
note="per tenant"
tone="neutral"
icon={<Users size={15} />}
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Tenant directory"
note={`${rows.length} shown`}
action={
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
<TextInput
label="Search tenants"
isLabelHidden
value={search}
onChange={setSearch}
placeholder="Search by name, company or city…"
hasClear
size="sm"
/>
</div>
}
/>
<Card padding={0} elevation="low">
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyDescription={
search
? 'Try a different name, company or city.'
: 'Onboard the first merchant group to get started.'
}
emptyAction={
search ? undefined : (
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
)
}
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<TenantRow>
data={rows}
columns={columns}
idKey="tenantid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* 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
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
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>
);
}

View File

@@ -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({
</DrawerCard>
</Section>
{/* ── The fields the import left behind ─────────────────────────────── */}
{source.isLoading ? (
{/* ── The catalogue's reference data ────────────────────────────────── */}
{stored ? (
<CatalogueExtras data={stored} />
) : source.isLoading ? (
<Note>Looking up the full catalogue entry…</Note>
) : source.data ? (
<CatalogueExtras data={source.data} />
) : (
<Section title="Catalogue entry">
<Note>
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.
</Note>
</Section>
)}
@@ -282,7 +310,15 @@ export function ProductDrawer({
function CatalogueExtras({
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][] = [];
if (data.category) lines.push(['Catalogue category', data.category]);

View File

@@ -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);
});

View File

@@ -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<number, BranchO
if (!locationid) continue;
const stats = byBranch.get(locationid) ?? { ...NO_ORDERS };
stats.revenue += orderValue(row);
stats.orders += 1;
// `orderstatus`, not `deliverystatus`: the latter is an empty string on
// every one of the 181 rows tenant 1147 has, so counting on it would report
// nothing delivered and nothing cancelled for every shop on the platform.
if (matchesStatus('delivered', row.orderstatus)) stats.delivered += 1;
if (matchesStatus('cancelled', row.orderstatus)) stats.cancelled += 1;
if (isCounterSale(row)) {
stats.counterRevenue += orderValue(row);
stats.counterOrders += 1;
stats.counterTax += Number(row.taxamount ?? 0);
} else {
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);
}

View 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);
});

View File

@@ -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,

View File

@@ -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 ?? [],
};

View File

@@ -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:<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.
*
@@ -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<StatusKey, number>;
}, [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);

View File

@@ -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<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;
}