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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user