category changes

This commit is contained in:
2026-09-08 12:03:23 +05:30
parent 1a37949bbe
commit d54fadef20
22 changed files with 776 additions and 540 deletions

View File

@@ -1,8 +1,5 @@
import { useMemo } from 'react';
import { useTenantCategories } from '@/queries/hooks';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useBranchScope } from './BranchScope';
import { categoryOptionsFor } from '@/features/catalogue/tenantCategories';
/**
* The Store Admin's half of the catalogue: browse it, add to your own products.
@@ -26,35 +23,11 @@ export function CataloguePanel() {
*/
const locationid = current?.locationid ?? branches[0]?.locationid;
const categories = useTenantCategories(tenantid);
/**
* No "Leave uncategorised" entry, and its absence is the fix.
*
* It sat first in this list, so it was what the picker showed until somebody
* changed it — and it wrote `categoryid` 0, which the customer app cannot
* return under any category it asks for. It was not an opt-out from filing a
* product; it was an opt-out from selling it, offered as the default.
*
* A tenant with no categories of its own is not refused either, which was
* the overcorrection: the refusal was right about `categoryid` 0 and wrong
* about who it caught. `gettenantcategories` is derived from products a
* tenant already has, so the shops it blocked were new ones importing their
* first product — 37 of the 45 most recent tenants. See
* `categoryOptionsFor` for the floor that resolves it.
*/
const categoryOptions = useMemo(
() => categoryOptionsFor(categories.data, categories.isLoading),
[categories.data, categories.isLoading],
);
return (
<CatalogueBrowser
tenantid={tenantid || undefined}
locationid={locationid}
categoryOptions={categoryOptions}
actionLabel="Add to my products"
alignWithTabs
{...(!tenantid || !locationid
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
: {})}

View File

@@ -4,7 +4,7 @@ import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { productsApi } from '@/api/products';
import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { useCatalogueProduct } from '@/queries/hooks';
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer';
import {
@@ -67,6 +67,13 @@ export function ProductDrawer({
const [confirmHide, setConfirmHide] = useState(false);
const [shot, setShot] = useState(0);
/* The aisle name for this product's subcategoryid, from the same list the
customer app groups by. */
const aisles = useAppAisles(tenantid || undefined);
const aisleName =
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
null;
const source = useCatalogueProduct(product.productbrand, product.productsku);
const images = imagesOf(product);
const state = stateOf(product);
@@ -198,10 +205,17 @@ export function ProductDrawer({
value={isPublished(product) ? 'Yes' : 'Not yet'}
{...(isPublished(product) ? { tone: 'success' as const } : {})}
/>
{/* The AISLE, which is what a shopper sees.
`categoryname` is empty on every row — the app's category 2 has no
row in `productcategories` — and `getlocationproducts` joins no
subcategory table, so the name is looked up from the platform list
the customer app itself groups by. "Uncategorized" is not a
cosmetic default here: it is the heading the app will print. */}
<Row
label="Category"
value={product.categoryname || product.Subcategoryname || 'Uncategorised'}
label="Shown in the app under"
value={aisleName ?? 'Uncategorized'}
icon={<Tag size={14} />}
{...(aisleName ? {} : { tone: 'muted' as const })}
/>
<Row
label="Unit"

View File

@@ -8,7 +8,7 @@ import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react';
import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
@@ -71,7 +71,23 @@ export function ProductsPanel({
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, {
allBranches: true,
});
const categories = useTenantCategories(tenantid || undefined);
/**
* The aisles, not the categories, because the aisle is what a shopper sees.
*
* `gettenantcategories` reports one entry for every tenant — category 2 —
* so the filter it fed was a dropdown with a single option, and the column
* beside it read "—" on every row. What actually separates products in the
* customer app is `subcategoryid`, one of ten platform rows; see
* `appAisle.ts`.
*/
const aisles = useAppAisles(tenantid || undefined);
const aisleNames = useMemo(() => {
const out = new Map<number, string>();
for (const row of aisles.data ?? []) {
if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname);
}
return out;
}, [aisles.data]);
/**
* One list, ordered by what needs doing.
@@ -82,7 +98,7 @@ export function ProductsPanel({
*/
const rows = useMemo(() => {
const filtered = (products.data ?? []).filter(
(product) => !category || String(product.categoryid ?? '') === category,
(product) => !category || String(product.subcategoryid ?? 0) === category,
);
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
return [...filtered].sort((a, b) => {
@@ -94,13 +110,16 @@ export function ProductsPanel({
const categoryOptions = useMemo(
() => [
{ value: '', label: 'All categories' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
{ value: '', label: 'All aisles' },
// Offered whether or not anything is in it — a shop wanting to know what
// the app cannot place should not have to guess that the option exists.
{ value: '0', label: 'Uncategorized' },
...(aisles.data ?? []).map((entry) => ({
value: String(entry.subcatid),
label: entry.subcatname,
})),
],
[categories.data],
[aisles.data],
);
return (
@@ -124,6 +143,7 @@ export function ProductsPanel({
<ProductTable
rows={rows}
aisleNames={aisleNames}
queryKey={category}
isLoading={products.isLoading}
onOpen={setOpen}
@@ -159,12 +179,15 @@ export function ProductsPanel({
function ProductTable({
rows,
aisleNames,
isLoading,
onOpen,
onPrice,
queryKey,
}: {
rows: Product[];
/** Subcategory id to the aisle name the customer app shows it under. */
aisleNames: ReadonlyMap<number, string>;
isLoading: boolean;
onOpen: (product: Product) => void;
onPrice: (product: Product) => void;
@@ -251,6 +274,7 @@ function ProductTable({
<Row
key={product.productid}
product={product}
aisleNames={aisleNames}
onOpen={() => onOpen(product)}
onPrice={() => onPrice(product)}
/>
@@ -276,10 +300,12 @@ function ProductTable({
*/
function Row({
product,
aisleNames,
onOpen,
onPrice,
}: {
product: Product;
aisleNames: ReadonlyMap<number, string>;
onOpen: () => void;
onPrice: () => void;
}) {
@@ -337,7 +363,13 @@ function Row({
whiteSpace: 'nowrap',
}}
>
{product.categoryname || product.Subcategoryname || '—'}
{/* The aisle id is on the row; the NAME is not — `getlocationproducts`
selects `a.*` and joins no subcategory table — so it is looked up
from the platform list the app itself groups by. */}
{aisleNames.get(product.subcategoryid ?? 0) ??
product.categoryname ??
product.Subcategoryname ??
'Uncategorized'}
</span>
</Td>
<Td isMuted>

View File

@@ -296,9 +296,10 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
/>
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
Required columns: productname, productsku, categoryid, retailprice, productcost. The
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
the file.
Required columns: productname, productsku, retailprice, productcost. A categoryid
column is not one of them and is ignored if present — the Category column below shows
what each row was classified as, and that name is exchanged for your own category id
when the products are created.
</Text>
{parseError ? (

View File

@@ -0,0 +1,91 @@
/**
* The fold from the catalogue's 31 categories into the app's ten aisles.
*
* The stakes here are not cosmetic. `subcategoryid` is the only thing the
* customer app groups by, and a category that folds to nothing lands the
* product in "Uncategorized" — which is the state the whole platform was in.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
APP_AISLES,
FALLBACK_AISLE_IDS,
aisleForCategory,
aisleIdForCategory,
aisleIdsFrom,
} from './appAisle';
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
const ids = aisleIdsFrom(undefined);
test('every one of the 31 categories folds into an aisle', () => {
const orphans = CATEGORY_REGISTRY.map((entry) => entry.category).filter(
(category) => aisleForCategory(category) === null,
);
assert.deepEqual(orphans, [], 'a category with no aisle is a product the app cannot show');
});
// "General" is the ladder saying it does not know. Inventing an aisle for it
// would put an unidentified product on a real shelf, which is worse than the
// app's own "Uncategorized" heading — that one at least tells the truth.
test('the unknown bucket deliberately has no aisle', () => {
assert.equal(aisleForCategory(UNKNOWN_CATEGORY), null);
assert.equal(aisleIdForCategory(UNKNOWN_CATEGORY, ids), 0);
});
test('a name that is not a category at all folds to nothing', () => {
assert.equal(aisleForCategory(''), null);
assert.equal(aisleForCategory(undefined), null);
assert.equal(aisleForCategory('Category 2'), null, 'the old default is not an aisle');
});
test('the things a shopper would look for land where they would look', () => {
assert.equal(aisleForCategory('Spices & Masalas'), 'Foodgrains & Pulses');
assert.equal(aisleForCategory('Dairy'), 'Dairy, Deli & Egg');
assert.equal(aisleForCategory('Hair Care'), 'Beauty & Personal Care');
assert.equal(aisleForCategory('Household Cleaning'), 'Hygiene Essentials');
assert.equal(aisleForCategory('Cooking Oils'), 'Oil & Ghee');
assert.equal(aisleForCategory('Biscuits & Cookies'), 'Bakes & Nuts');
});
// The registry writes an en-dash; a sheet typed by hand will not. Two strings
// that read the same must not reach different aisles.
test('dash and spacing variants reach the same aisle', () => {
assert.equal(aisleForCategory('Household – Agarbatti'), 'Hygiene Essentials');
assert.equal(aisleForCategory('Household - Agarbatti'), 'Hygiene Essentials');
assert.equal(aisleForCategory('household—agarbatti'), 'Hygiene Essentials');
assert.equal(aisleForCategory(' HEALTH CARE – ANTISEPTIC '), 'Hygiene Essentials');
});
/* ── Ids come from the platform's list, never from the wrong table ────────── */
// `productsubcategories` holds these ten names at 14-23; the app's utils list
// holds the same ten at 9-18. Matching on the name is what keeps a product out
// of the second set, which the grouping does not read.
test('ids are taken from the rows the API returned, by name', () => {
const live = aisleIdsFrom([
{ subcatid: 15, subcatname: 'Dairy, Deli & Egg', categoryid: 2 },
{ subcatid: 21, subcatname: 'Snacks & Drinks', categoryid: 2 },
]);
assert.equal(aisleIdForCategory('Dairy', live), 15);
assert.equal(aisleIdForCategory('Chocolates', live), 21);
});
// A name the API did not return has no id from it — but the aisle is still one
// of the ten, so the last-known id is better than 0, which means "no aisle".
test('a missing row falls back to the last known id rather than to zero', () => {
const partial = aisleIdsFrom([{ subcatid: 15, subcatname: 'Dairy, Deli & Egg', categoryid: 2 }]);
assert.equal(aisleIdForCategory('Cooking Oils', partial), FALLBACK_AISLE_IDS['Oil & Ghee']);
});
test('an empty response falls back to the whole known list', () => {
const empty = aisleIdsFrom([]);
assert.equal(empty.size, APP_AISLES.length);
assert.equal(aisleIdForCategory('Eggs', empty), FALLBACK_AISLE_IDS['Dairy, Deli & Egg']);
});
test('every aisle has an id, and no two share one', () => {
const values = APP_AISLES.map((aisle) => FALLBACK_AISLE_IDS[aisle]);
assert.equal(new Set(values).size, values.length);
assert.ok(values.every((id) => id > 0));
});

View File

@@ -0,0 +1,198 @@
/**
* The aisle the customer app actually displays, and how a category becomes one.
*
* ── What the app does ───────────────────────────────────────────────────────
*
* Its browse screen calls
*
* GET /mob/products/getproductsbysubcategory?categoryid=2&tenantid=…&locationid=…
*
* and that endpoint does two things (`services/productService.go:237`):
*
* 1. `GetProducts` filters on `a.categoryid = ?` — UNCONDITIONALLY, with the
* 2 from the query string.
* 2. It then buckets the result by `products.subcategoryid`, against the rows
* of `productsubcategories` for category 2. Anything with subcategoryid 0
* falls into a bucket literally named "Uncategorized".
*
* So the heading a shopper reads is the SUBCATEGORY, not the category — and
* `categoryid` is not a label at all, it is the filter that decides whether the
* product comes back. Measured on live tenant 1135/1166: every product returns
* `categoryid: 2, subcategoryid: 0`, so the app renders a single heading,
* "Uncategorized", holding the whole shop. That is exactly the report from the
* app side, and it is why nothing displays category-wise.
*
* TWO CONSEQUENCES, both the opposite of what looks obvious:
*
* - `categoryid` must stay 2. Writing a per-product categoryid — 91 for
* Dairy, 92 for Spices — does not label the product, it removes it: the app
* asks for 2 and the row no longer matches.
* - `subcategoryid` is what has to be written, and it can only be one of the
* ten rows `productsubcategories` holds for category 2. Those are platform
* rows shared by every tenant, not something a shop creates.
*
* ── Why the ids are looked up rather than hardcoded ─────────────────────────
*
* `productsubcategories` (subcatid 14-23) and the app's own utils list
* (`/mob/utils/getsubcategories`, ids 9-18) carry the SAME TEN NAMES under
* different ids. The grouping reads the first, so those are the ids to write —
* but two tables one offset apart is precisely the shape that gets confused, so
* the ids are read back by name from the same API the grouping uses, and
* `FALLBACK_AISLE_IDS` is used only when that call cannot be made.
*/
import type { ProductSubCategory } from '@/api/types';
/** The ten aisles, in the order the app lists them. */
export const APP_AISLES = [
'Vegetables & Fruits',
'Dairy, Deli & Egg',
'Meat, Chicken & Fish',
'Bakes & Nuts',
'Foodgrains & Pulses',
'Oil & Ghee',
'Rice & Cereals',
'Snacks & Drinks',
'Beauty & Personal Care',
'Hygiene Essentials',
] as const;
export type AppAisle = (typeof APP_AISLES)[number];
/**
* The ids as `productsubcategories` holds them today, read from live on
* 2026-09-08. Used only when the lookup cannot be made — see the header.
*/
export const FALLBACK_AISLE_IDS: Record<AppAisle, number> = {
'Vegetables & Fruits': 14,
'Dairy, Deli & Egg': 15,
'Meat, Chicken & Fish': 16,
'Bakes & Nuts': 17,
'Foodgrains & Pulses': 18,
'Oil & Ghee': 19,
'Rice & Cereals': 20,
'Snacks & Drinks': 21,
'Beauty & Personal Care': 22,
'Hygiene Essentials': 23,
};
/**
* The catalogue team's 31 categories, folded into the app's ten aisles.
*
* The 31 are a finer classification than the shop floor has room for, so this
* is a real narrowing and some of it is judgement rather than fact: "Spices &
* Masalas" has no aisle of its own and goes to Foodgrains & Pulses, the nearest
* the app offers. Where a category could sit in two it goes to the one a
* shopper would look in first — instant noodles to Snacks & Drinks rather than
* Rice & Cereals, because that is where a shop stocks them.
*
* Anything not listed — including "General", the ladder's explicit "we do not
* know" — has no aisle, and `aisleForCategory` returns null rather than
* guessing. Those products land in the app's own "Uncategorized" bucket, which
* is honest: it says the classification failed, instead of shelving bleach with
* the butter.
*/
const AISLE_FOR_CATEGORY: Record<string, AppAisle> = {
/* Bakes & Nuts */
'biscuits & cookies': 'Bakes & Nuts',
rusk: 'Bakes & Nuts',
crackers: 'Bakes & Nuts',
'cakes & muffins': 'Bakes & Nuts',
'bakery & breads': 'Bakes & Nuts',
/* Snacks & Drinks */
snacks: 'Snacks & Drinks',
chocolates: 'Snacks & Drinks',
'candy & confectionery': 'Snacks & Drinks',
beverages: 'Snacks & Drinks',
'noodles & instant food': 'Snacks & Drinks',
/* Foodgrains & Pulses */
'pulses, grains & spices': 'Foodgrains & Pulses',
'spices & masalas': 'Foodgrains & Pulses',
'salt & staples': 'Foodgrains & Pulses',
'sugar & jaggery': 'Foodgrains & Pulses',
/* Rice & Cereals */
'atta & staples': 'Rice & Cereals',
/* Oil & Ghee */
'cooking oils': 'Oil & Ghee',
/* Dairy, Deli & Egg */
dairy: 'Dairy, Deli & Egg',
eggs: 'Dairy, Deli & Egg',
/* Meat, Chicken & Fish */
'fish & seafood': 'Meat, Chicken & Fish',
/* Vegetables & Fruits */
'fruits & vegetables': 'Vegetables & Fruits',
'fresh herbs & greens': 'Vegetables & Fruits',
flowers: 'Vegetables & Fruits',
/* Beauty & Personal Care */
'oral care': 'Beauty & Personal Care',
'hair care': 'Beauty & Personal Care',
'bath soap': 'Beauty & Personal Care',
'skin & bath care': 'Beauty & Personal Care',
'fragrance & deodorants': 'Beauty & Personal Care',
/* Hygiene Essentials */
'household cleaning': 'Hygiene Essentials',
'health care - antiseptic': 'Hygiene Essentials',
'household - agarbatti': 'Hygiene Essentials',
'household - lamp oil': 'Hygiene Essentials',
};
/**
* Every dash normalised to a plain hyphen before lookup.
*
* The registry writes "Household – Agarbatti" with an en-dash and a sheet typed
* by hand will not. Two strings that read identically must not reach different
* aisles because of which key somebody pressed.
*/
function keyOf(category: string | null | undefined): string {
return (category ?? '')
.trim()
.toLowerCase()
.replace(/[‐-―]/g, '-')
.replace(/\s*-\s*/g, ' - ')
.replace(/\s+/g, ' ');
}
/** The aisle a classified category belongs in, or null when there is none. */
export function aisleForCategory(category: string | null | undefined): AppAisle | null {
return AISLE_FOR_CATEGORY[keyOf(category)] ?? null;
}
/**
* Aisle name to the id the app groups on, from the platform's own list.
*
* Matched on the NAME, because the id is exactly what differs between the two
* tables carrying these ten rows. A name the API does not return has no id, and
* the caller writes 0 rather than a number out of the wrong table.
*/
export function aisleIdsFrom(
subcategories: readonly ProductSubCategory[] | undefined,
): Map<string, number> {
const out = new Map<string, number>();
for (const row of subcategories ?? []) {
const name = (row.subcatname ?? '').trim();
if (name && row.subcatid > 0) out.set(name.toLowerCase(), row.subcatid);
}
if (out.size === 0) {
for (const [name, id] of Object.entries(FALLBACK_AISLE_IDS)) out.set(name.toLowerCase(), id);
}
return out;
}
/** The subcategory id for a classified category — 0 when it has no aisle. */
export function aisleIdForCategory(
category: string | null | undefined,
aisleIds: ReadonlyMap<string, number>,
): number {
const aisle = aisleForCategory(category);
if (!aisle) return 0;
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
}