diff --git a/scripts/refileCategories.ts b/scripts/refileCategories.ts index 7cc3163..1dadd80 100644 --- a/scripts/refileCategories.ts +++ b/scripts/refileCategories.ts @@ -1,10 +1,15 @@ /** - * Re-files a tenant's products under the catalogue's own categories. + * Puts a tenant's existing products into the aisles the customer app displays. * - * Everything imported before the category work landed carries whatever single - * category the tenant had — "Category 2" for most shops — while the catalogue - * has known all along that an Aachi masala is Spices & Masalas. This reads that - * answer back and writes it. + * Everything imported before this carries `subcategoryid: 0`, which the app + * renders as one heading called "Uncategorized" holding the entire shop — + * measured on live tenant 1135/1166 — while the catalogue has known all along + * that an Aachi masala is Spices & Masalas. This reads that answer back, folds + * it into one of the app's ten aisles (`appAisle.ts`) and writes it. + * + * `categoryid` is deliberately NOT changed. `getproductsbysubcategory` filters + * on it with the 2 the app sends, so a per-product categoryid does not label a + * product, it removes it from the app entirely. * * npx tsx scripts/refileCategories.ts 1147 # dry run, writes nothing * npx tsx scripts/refileCategories.ts 1147 --apply # writes @@ -27,7 +32,12 @@ import { catalogueApi } from '../src/api/catalogue'; import { productsApi } from '../src/api/products'; -import { resolveCategory, UNKNOWN_CATEGORY } from '../src/features/store-admin/productCategory'; +import { + categoryForCatalogueProduct, + UNKNOWN_CATEGORY, +} from '../src/features/store-admin/productCategory'; +import { aisleForCategory, aisleIdsFrom } from '../src/features/store-admin/appAisle'; +import { APP_BROWSE_CATEGORY } from '../src/features/catalogue/tenantCategories'; import type { CatalogueProduct, Product } from '../src/api/types'; const tenantid = Number(process.argv[2]); @@ -42,8 +52,12 @@ type Source = 'catalogue' | 'ladder'; interface Plan { product: Product; + /** The subcategory the product sits in today — 0 for everything, so far. */ from: number; - toName: string; + /** One of the catalogue's 31, for the report. */ + category: string; + /** One of the app's ten aisles, or null when the category folds to none. */ + aisle: string | null; source: Source; } @@ -81,58 +95,82 @@ async function main() { const sku = (product.productsku ?? '').trim().toLowerCase(); const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined; - const fromCatalogue = (row?.category ?? '').trim(); - const toName = fromCatalogue - ? fromCatalogue - : resolveCategory({ - title: product.productname ?? '', - description: product.productdesc ?? '', - packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '), - }).category; + /* + The catalogue's answer only when it is one of the 31. + + It carries names the platform does not have — "Food - Mixes", "Pickles & + Chutneys", "Dairy - Desserts" on about a third of the rows sampled — and + taking those verbatim would file a shop's products under aisles the app + cannot browse and no other shop shares. The same gate the import uses. + */ + const verdict = categoryForCatalogueProduct({ + catalogueCategory: row?.category ?? '', + title: product.productname ?? '', + description: product.productdesc ?? '', + packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '), + }); plans.push({ product, - from: product.categoryid ?? 0, - toName, - source: fromCatalogue ? 'catalogue' : 'ladder', + from: product.subcategoryid ?? 0, + category: verdict.category, + aisle: aisleForCategory(verdict.category), + source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder', }); } - // Resolve every distinct name once, creating the aisles that do not exist. - const names = [...new Set(plans.map((p) => p.toName))]; - const ids = isApply - ? ((await productsApi.resolveCategories(tenantid, names)) ?? {}) - : Object.fromEntries(names.map((n) => [n.toLowerCase(), -1])); + // The aisle ids, by name, from the platform's own list — see `appAisle.ts` + // for why they are matched on the name and not remembered as numbers. + const aisleIds = aisleIdsFrom( + await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined), + ); - const byCategory: Record = {}; + const byAisle: Record = {}; let unchanged = 0; const writes: Plan[] = []; + const orphans: Plan[] = []; for (const plan of plans) { - const to = ids[plan.toName.toLowerCase()]; - if (to !== undefined && to === plan.from) { + if (!plan.aisle) { + orphans.push(plan); + continue; + } + const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0; + if (to === plan.from) { unchanged += 1; continue; } writes.push(plan); - const key = `${plan.toName} (${plan.source})`; - byCategory[key] = { count: (byCategory[key]?.count ?? 0) + 1, source: plan.source }; + const key = `${plan.aisle} ← ${plan.category} (${plan.source})`; + byAisle[key] = (byAisle[key] ?? 0) + 1; } - console.log(`${writes.length} would be re-filed, ${unchanged} already correct\n`); - Object.entries(byCategory) - .sort((a, b) => b[1].count - a[1].count) - .forEach(([name, { count }]) => console.log(` ${String(count).padStart(4)} ${name}`)); + console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle +`); + Object.entries(byAisle) + .sort((x, y) => y[1] - x[1]) + .forEach(([name, count]) => console.log(` ${String(count).padStart(4)} ${name}`)); - const unknown = writes.filter((p) => p.toName === UNKNOWN_CATEGORY).length; - if (unknown > 0) { - console.log(`\n${unknown} could not be identified and would go to "${UNKNOWN_CATEGORY}".`); + if (orphans.length > 0) { + console.log( + ` +${orphans.length} have no aisle and would stay under the app's "Uncategorized":`, + ); + orphans + .slice(0, 10) + .forEach((p) => + console.log( + ` ${(p.product.productname ?? '').slice(0, 44).padEnd(46)} ${p.category}`, + ), + ); + const unknown = orphans.filter((p) => p.category === UNKNOWN_CATEGORY).length; + if (unknown > 0) console.log(` (${unknown} of them could not be identified at all)`); } console.log('\nA sample of what changes:'); writes.slice(0, 12).forEach((p) => { console.log( - ` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.toName} (${p.source})`, + ` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.aisle} (${p.source})`, ); }); @@ -145,8 +183,8 @@ async function main() { /* One call for the whole tenant, not one request per product. - `recategorise` writes a single column and is scoped by tenantid on the server, - so it cannot reach another merchant's rows and cannot overwrite a price the + `recategorise` writes the category and subcategory columns only, and is scoped + by tenantid on the server, so it cannot reach another merchant's rows and cannot overwrite a price the way a whole-row update would. `PUT /products/update` was the obvious candidate and is the wrong one: it updates `productlocations.status` and never touches the products table at all. @@ -154,14 +192,16 @@ async function main() { const updates = writes .map((plan) => ({ productid: plan.product.productid, - categoryid: ids[plan.toName.toLowerCase()] ?? 0, + // Unchanged, and that is the point: it is the app's filter, not a label. + categoryid: APP_BROWSE_CATEGORY, + subcategoryid: aisleIds.get((plan.aisle ?? '').toLowerCase()) ?? 0, })) - .filter((row) => row.categoryid > 0); + .filter((row) => row.subcategoryid > 0); const skipped = writes.length - updates.length; const result = await productsApi.recategorise(tenantid, updates); console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`); - if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their category.`); + if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their aisle.`); } void main(); diff --git a/src/api/products.ts b/src/api/products.ts index e6edb1e..36442d0 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -29,6 +29,8 @@ import type { ProductStockRequest, ProductSubCategory, } from './types'; +import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; +import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle'; export interface LocationProductQuery { tenantid: number; @@ -153,13 +155,20 @@ export const productsApi = { api.post>(`${WEB}/products/resolvecategories`, { tenantid, names }), /** - * Re-files products into different categories, in bulk. + * Re-files products into different aisles, in bulk. + * + * `categoryid` should be 2 on every row — the customer app FILTERS on it and + * anything else removes the product from its browse. `subcategoryid` is the + * one that decides the heading a shopper reads; 0 leaves whatever the product + * already has, so a caller that does not know the aisle cannot erase one. * * Scoped by tenant on the server as well as here — a productid is global, so * a wrong id in the list would otherwise move another merchant's product. */ - recategorise: (tenantid: number, updates: { productid: number; categoryid: number }[]) => - api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }), + recategorise: ( + tenantid: number, + updates: { productid: number; categoryid: number; subcategoryid?: number }[], + ) => api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }), /** Unlinks from the store. The product row and its order history survive. */ removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) => @@ -237,30 +246,26 @@ export async function importSheetProducts( const createdSkus: string[] = []; /* - Categories first, in one call, before a single product is created. + The aisles first, in one call, before a single product is created. Every row carries a category NAME worked out by the ladder in - `productCategory.ts`, and the customer app browses by categoryid and rejects - 0 — so a product created before its aisle exists is a product no shopper can - find. Resolving up front also means one round trip for the whole sheet rather - than one per row. + `productCategory.ts`. What the customer app groups by is not that category + but `products.subcategoryid` — one of ten platform rows under category 2 — + so the name is folded into an aisle and the aisle looked up by name here. See + `appAisle.ts` for the endpoint that is measured against. - A failure here is not fatal. The rows still carry whatever categoryid the - operator picked for the upload, which is exactly the behaviour this had - before, so a category service that is down costs the new filing and not the - import. + A failure here is not fatal: the lookup falls back to the ids last read from + the platform, and a row that still cannot be placed is created with + subcategoryid 0, which the app lists under "Uncategorized". */ - const wanted = [...new Set(rows.map((row) => row.category ?? '').filter(Boolean))]; - let resolved: Record = {}; - if (wanted.length > 0) { - try { - resolved = (await productsApi.resolveCategories(tenantid, wanted)) ?? {}; - } catch { - resolved = {}; - } + let aisleIds: ReadonlyMap; + try { + aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY)); + } catch { + aisleIds = aisleIdsFrom(undefined); } - const categoryIdFor = (row: SheetProductRow): number => - resolved[(row.category ?? '').trim().toLowerCase()] ?? row.categoryid; + const subcategoryIdFor = (row: SheetProductRow): number => + aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0; for (const [index, row] of rows.entries()) { try { @@ -268,8 +273,10 @@ export async function importSheetProducts( tenantid, productname: row.productname, productsku: row.productsku, - categoryid: categoryIdFor(row), - subcategoryid: row.subcategoryid, + // ALWAYS 2 — the app filters on it and would drop anything else. The + // aisle a shopper reads is the subcategory. + categoryid: APP_BROWSE_CATEGORY, + subcategoryid: subcategoryIdFor(row), retailprice: row.retailprice, productcost: row.productcost, taxpercent: row.taxpercent, diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index 207f2df..cf427cb 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -13,6 +13,8 @@ import { VStack } from '@astryxdesign/core/VStack'; import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react'; import { catalogueKey, catalogueKeysOf } from '@/api/catalogue'; import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory'; +import { APP_BROWSE_CATEGORY } from './tenantCategories'; +import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle'; import { useSelection } from '@/components/useSelection'; import { productsApi } from '@/api/products'; import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types'; @@ -34,8 +36,6 @@ export interface CatalogueBrowserProps { tenantid: number | undefined; /** The outlet the import is written against. Required by the backend. */ locationid: number | undefined; - /** The tenant's own categories, for the one field the catalogue cannot supply. */ - categoryOptions: { value: string; label: string }[]; /** Wording on the card and drawer buttons. */ actionLabel: string; /** @@ -56,15 +56,6 @@ export interface CatalogueBrowserProps { * rather than one at a time into somebody else's shop. */ isReadOnly?: boolean; - /** - * Lift the search row onto the page's tab row. - * - * Only true where a tab row actually exists — the Store Admin's Inventory. - * On a page without one the offset drags the row up over the page header's - * own actions and swallows their clicks, which is exactly what it did to the - * platform catalogue's mode toggle. - */ - alignWithTabs?: boolean; /** Why importing is unavailable, if it is. */ blockedReason?: string; } @@ -89,35 +80,17 @@ export interface CatalogueBrowserProps { export function CatalogueBrowser({ tenantid, locationid, - categoryOptions, actionLabel, onImport, scope, blockedReason, isReadOnly, - alignWithTabs, }: CatalogueBrowserProps) { const client = useQueryClient(); const [brand, setBrand] = useState(''); const [keyword, setKeyword] = useState(''); const [debounced, setDebounced] = useState(''); - /** - * `null` means "not chosen yet", which is NOT the same as "no category". - * - * This was `useState('')`, and an empty string went on to `Number('') || 0` - * and imported the product under `categoryid` 0. That reads like a harmless - * default and is not: the customer app queries products by category, and a - * product filed under 0 is returned for NO category value — verified against - * tenant 1147, where `getproductsbysubcategory` answered with the other five - * products at `categoryid=2` and with nothing at 0, 1 or 1001. Two real - * products sat invisible in the app while this console listed them happily, - * because `getlocationproducts` does not filter on category. - * - * Holding "unchosen" apart from "chosen nothing" lets the default below be - * the tenant's own category rather than the value that hides the product. - */ - const [importInto, setImportInto] = useState(null); const [busy, setBusy] = useState(null); const [justImported, setJustImported] = useState>(new Set()); const [open, setOpen] = useState(null); @@ -224,9 +197,11 @@ export function CatalogueBrowser({ arrived. */ mutationFn: async (products: CatalogueProduct[]) => { - const ids = await categoryIdsFor(products); + const ids = await aisleIds(); return productsApi.importFromCatalogue( - products.map((product) => importRowFor(product, ids.get(catalogueKey(product)) ?? 0)), + products.map((product) => + importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids)), + ), ); }, onSuccess: async (_result, products) => { @@ -256,43 +231,8 @@ export function CatalogueBrowser({ }, }); - /** - * The LAST-RESORT category: the tenant's first real one. - * - * Only reached when a product carries no catalogue category of its own and - * nothing has been picked. `categoryOptions` arrives asynchronously, so this - * is derived at render rather than pushed into state by an effect — state - * seeded from an empty list would keep the old default after the categories - * loaded, and the picker would then show one thing while the request sent - * another. - */ - const defaultCategory = categoryOptions.find((option) => option.value !== '')?.value ?? ''; - const chosenCategory = importInto ?? defaultCategory; - /** - * What the picker DISPLAYS, which is not the same as the fallback above. - * - * Empty until the operator picks, so the control shows its placeholder — and - * the placeholder names the catalogue's own category, which is what an - * untouched import actually uses. - * - * Showing `chosenCategory` here was a straight contradiction on screen: the - * description read "filed under Food - Mixes" while the select beside it - * showed "Category 2" as the chosen value, because the fallback had been - * pressed into service as a display value. The two are different questions — - * "what will happen" and "what have you chosen" — and only the second belongs - * in the control. - */ - const pickerValue = importInto ?? ''; - /** - * True when an import would produce a product the customer app cannot show. - * - * The tenant has no category to file into, so the write would go in under 0. - * Better to say so and refuse than to write a row that looks imported in this - * console and does not exist as far as a shopper is concerned. - */ - const hasNoCategory = !isReadOnly && !onImport && chosenCategory === ''; /** * One import row. @@ -337,65 +277,40 @@ export function CatalogueBrowser({ }).category; } - async function categoryIdsFor(products: CatalogueProduct[]): Promise> { - const out = new Map(); - const fallback = Number(chosenCategory); - - if (importInto !== null && importInto !== '') { - for (const product of products) out.set(catalogueKey(product), Number(importInto)); - return out; - } - - const names = [...new Set(products.map(categoryNameFor).filter(Boolean))]; - let resolved: Record = {}; - if (names.length > 0) { - try { - resolved = (await productsApi.resolveCategories(tenantid as number, names)) ?? {}; - } catch { - resolved = {}; - } - } - - for (const product of products) { - const name = categoryNameFor(product).toLowerCase(); - out.set(catalogueKey(product), resolved[name] ?? fallback); - } - return out; - } - - async function categoryIdFor(product: CatalogueProduct): Promise { - if (importInto !== null && importInto !== '') return Number(importInto); - - const name = categoryNameFor(product); - if (!name) return Number(chosenCategory); - + /** + * The aisle ids the app groups by, read from the platform's own list. + * + * One call for a whole batch. It is `productsubcategories` for category 2 — + * ten rows, shared by every tenant — and matching on the NAME rather than + * trusting a remembered id is what keeps this off the app's other + * subcategory table, which carries the same ten names five ids lower. + */ + async function aisleIds(): Promise> { try { - const resolved = await productsApi.resolveCategories(tenantid as number, [name]); - return resolved?.[name.toLowerCase()] ?? Number(chosenCategory); + return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY)); } catch { - // The tenant's own list still files it somewhere findable. Better a - // wrong shelf than categoryid 0, which no query the app makes returns. - return Number(chosenCategory); + return aisleIdsFrom(undefined); } } function importRowFor( product: CatalogueProduct, - categoryid: number, + subcategoryid: number, ): ImportCatalogueProductRequest { return { tenantid: tenantid as number, locationid: locationid as number, brand: product.brand, catalogueid: product.id, - /* This used to read `Number(importInto) || 0`, defended as - "uncategorised beats wrongly categorised — an unclassified product is - visibly unfinished". It is not visible anywhere: the console lists it - like any other product and only the customer app knows it is gone. - A wrongly filed product is at least findable and fixable; one under - category 0 is returned by no query the app makes. */ - categoryid, - subcategoryid: 0, + /* ALWAYS 2, and this is not a placeholder. + `getproductsbysubcategory` filters on `categoryid = 2` — the value the + app sends — so a per-product categoryid does not label the product, it + deletes it from the app's view. What the shopper actually reads as the + aisle heading is the subcategory below, which until now was 0 on every + product on the platform: one bucket, "Uncategorized", holding the shop. + See `appAisle.ts` for the endpoint this is measured against. */ + categoryid: APP_BROWSE_CATEGORY, + subcategoryid, quantity: 0, stocktype: 'in', status: 'Draft', @@ -408,12 +323,13 @@ export function CatalogueBrowser({ async function importDirect(product: CatalogueProduct) { if (!tenantid || !locationid) return; - // Guarded rather than defaulted to 0 — see `hasNoCategory`. - if (chosenCategory === '') return; + const key = catalogueKey(product); setBusy(key); try { - await importOne.mutateAsync(importRowFor(product, await categoryIdFor(product))); + await importOne.mutateAsync( + importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())), + ); setJustImported((set) => new Set(set).add(key)); } finally { setBusy(null); @@ -423,17 +339,6 @@ export function CatalogueBrowser({ const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product)); const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason); - /** - * "Import into" lives in the drawer, at the moment of the decision. - * - * It used to sit in the filter row as well, which put a question about where - * ONE product should be filed next to three controls that change what the - * whole grid shows — and it read as a fourth filter. It is only offered when - * this component performs the import itself: when the caller takes over - * (`onImport`), its own form collects the category along with the price, and - * asking twice on one screen gets two answers that can disagree. - */ - const showCategoryPicker = !isReadOnly && !onImport && categoryOptions.length > 0; function clearFilters() { setBrand(''); @@ -453,27 +358,26 @@ export function CatalogueBrowser({ {scope} - {/* Pulled up onto the tab row's line and right-aligned, so search sits - level with the page selector rather than on a line of its own — the - tab row is mostly empty to the right and was reserving a whole row - below it for four controls. + {/* Search, filters and the rail toggle, on their own line. - The pull-up is a CSS offset rather than the controls being rendered - inside the tab row, because the tab row belongs to the page - (`InventoryPage`) and these controls belong to the browser's own - state. Rendering them there would mean lifting `keyword` and the rail - toggle into two different pages to keep one row tidy. It reverts to - its own line below 900px, where the tabs need the width. + They used to be PULLED UP onto the page's tab row by a -42px offset + (`alignWithTabs`, `.catalogue-controls`), because that row looked + mostly empty to the right. It is not empty any more — Inventory's tab + row carries "Upload sheet" at its right end — and the offset does not + move a narrow control into a gap, it lays a FULL-WIDTH row on top of + the whole tab row: measured at 24 → 1090px, covering all four tabs and + the button. Clicks landed on this element and nothing switched tabs, + which read as "the tabs stop working once you open the Catalogue". + + That was the second time. The prop's own note recorded it happening to + the platform catalogue's mode toggle, and it was fixed there by not + passing the prop — leaving the offset in place to catch the next row + that grew an action. Two controls cannot share one right-hand corner, + so search keeps its own line and the tab row keeps its button. It stays above both columns rather than inside the rail: the search narrows the whole catalogue, and the rail only lists brands. */} - + {activeFilters.map((filter) => ( ))} @@ -603,7 +507,7 @@ export function CatalogueBrowser({ variant="primary" size="sm" isLoading={importMany.isPending} - isDisabled={importMany.isPending || hasNoCategory} + isDisabled={importMany.isPending} onClick={() => importMany.mutate( rows.filter((product) => selection.has(product.id)), @@ -665,10 +569,7 @@ export function CatalogueBrowser({ isImported={importedKeys.has(catalogueKey(open))} isBusy={busy === catalogueKey(open)} actionLabel={actionLabel} - {...(showCategoryPicker - ? { categoryOptions, categoryid: pickerValue, onCategoryChange: setImportInto } - : {})} - {...(canImport && !isReadOnly && !hasNoCategory + {...(canImport && !isReadOnly ? { onImport: () => { const product = open; @@ -678,15 +579,13 @@ export function CatalogueBrowser({ } : {})} {...(blockedReason - ? // The caller's reason wins: "no outlet selected" outranks "no - // category", because you cannot pick one without the other. + ? // The only reason left is the caller's — "no outlet selected". + // "This merchant has no category yet" used to sit here too and is + // gone: the category is derived from the product and created on + // demand, so there is no longer a state where a merchant has + // nowhere to file something. { blockedReason } - : hasNoCategory - ? { - blockedReason: - 'This merchant has no product category yet, and a product imported without one is invisible in the customer app. Add a category first.', - } - : {})} + : {})} onClose={() => setOpen(null)} /> ) : null} diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx index 39d8e65..3f79ccd 100644 --- a/src/features/catalogue/CatalogueDetailDrawer.tsx +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { Banner } from '@astryxdesign/core/Banner'; import { Lightbox } from '@astryxdesign/core/Lightbox'; -import { Selector } from '@astryxdesign/core/Selector'; import { Check, DownloadCloud, ImageOff } from 'lucide-react'; import type { CatalogueProduct } from '@/api/types'; import { Drawer } from '@/features/store-admin/Drawer'; @@ -14,6 +13,7 @@ import { Section, } from '@/features/store-admin/drawerKit'; import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory'; +import { aisleForCategory } from '@/features/store-admin/appAisle'; import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel'; /** @@ -40,10 +40,6 @@ export interface CatalogueDetailDrawerProps { isImported: boolean; isBusy?: boolean; actionLabel?: string; - /** The tenant's own categories. Omit to hide the picker. */ - categoryOptions?: { value: string; label: string }[]; - categoryid?: string; - onCategoryChange?: (value: string) => void; /** Absent when the caller has nowhere to import to yet. */ onImport?: () => void; /** Shown in place of the action when importing is unavailable. */ @@ -56,14 +52,16 @@ export function CatalogueDetailDrawer({ isImported, isBusy, actionLabel = 'Add to my products', - categoryOptions, - categoryid = '', - onCategoryChange, onImport, blockedReason, onClose, }: CatalogueDetailDrawerProps) { - /* Which of the 31 this product will actually be filed under, and why. */ + /* Which of the 31 this product will be filed under, and why. + Shown, not asked. It used to be a dropdown here, defaulting to whatever the + merchant's list happened to hold — which is how a masala arrived on a shelf + as "Category 2". The classification is the catalogue team's, the same one + the customer app's filter is built on, so there is nothing for a person to + decide: what is left is telling them what it decided. */ const suggested = categoryForCatalogueProduct({ catalogueCategory: product.category, title: product.product_name, @@ -71,6 +69,9 @@ export function CatalogueDetailDrawer({ packSize: product.size ?? '', }); + /* The app groups by subcategory, not category — see `appAisle.ts`. */ + const aisle = aisleForCategory(suggested.category); + const images = product.images ?? []; const [heroAt, setHeroAt] = useState(0); const [isZoomed, setIsZoomed] = useState(false); @@ -255,7 +256,7 @@ export function CatalogueDetailDrawer({ {/* The action, last, because everything above is what the decision is made on. The button itself lives in the fixed bar; what stays here is - the choice it depends on and the warning about what it does not do. */} + where it will be filed and the warning about what importing does not do. */} {isImported ? ( -
- {categoryOptions ? ( - onCategoryChange?.(value)} - /* No "leave uncategorised" placeholder, and the advice that - used to sit here — "uncategorised beats wrongly categorised" - — is gone because it is false. A product filed under - category 0 is returned by NO query the customer app makes: - its endpoint rejects categoryid 0 outright. Seven products - across three tenants sat invisible that way, six outlets - showing shoppers an empty shop, while the console listed - their stock as normal. A wrongly filed product is at least - findable and fixable; an unfiled one was neither. */ - placeholder={`Suggested: ${suggested.category}`} - description={ - suggested.rule === 'catalogue' - ? `Left alone, this is filed under ${suggested.category} — the catalogue's own category, created for your shop if you do not have it yet. Choose one here to override.` - : product.category - ? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it goes to ${suggested.category} instead. Choose one here to override.` - : `The catalogue does not categorise this one, so it goes to ${suggested.category}. Choose one here to override.` - } - /> - ) : null} + } + /> + {/* The heading a shopper actually reads. The catalogue's 31 + categories are finer than the app's ten aisles, so the two are + shown side by side rather than one standing in for the other — + and a product with no aisle is told so here, not discovered + missing from the app later. */} + + ) : ( + Uncategorized + ) + } + /> +
- Adds this product with no price. It reaches no shop and cannot be sold until you - price and publish it. + {suggested.rule === 'catalogue' + ? `The catalogue's own category for this product.` + : product.category + ? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.` + : `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`} + + + {aisle + ? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.' + : 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
diff --git a/src/features/catalogue/tenantCategories.test.ts b/src/features/catalogue/tenantCategories.test.ts deleted file mode 100644 index 297b102..0000000 --- a/src/features/catalogue/tenantCategories.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { test } from 'node:test'; -import { APP_BROWSE_CATEGORY, categoryOptionsFor } from './tenantCategories'; - -test('a new tenant is offered a category, so it can import its first product', () => { - // The regression. `gettenantcategories` is derived from products the tenant - // ALREADY has, so a shop with none gets []. CatalogueBrowser then refuses - // every import ("no category to file into") and the only way to acquire a - // category is to import a product. Measured on 2026-09-02: 37 of the 45 most - // recent tenants had zero categories and could not add their first catalogue - // product at all. - const options = categoryOptionsFor([], false); - assert.equal(options.length, 1); - assert.equal(options[0]?.value, String(APP_BROWSE_CATEGORY)); -}); - -test('that first product is filed where the customer app will actually find it', () => { - // Not merely "some category". Anything other than 2 imports a product no - // shopper can see — which is the exact failure the refusal existed to - // prevent, so a floor that got this wrong would be worse than the deadlock. - assert.equal(categoryOptionsFor([], false)[0]?.value, '2'); -}); - -test("an established tenant's own categories are left alone", () => { - const own = [ - { categoryid: 2, categoryname: 'Grocery' }, - { categoryid: 9, categoryname: 'Chilled' }, - ]; - assert.deepEqual(categoryOptionsFor(own, false), [ - { value: '2', label: 'Grocery' }, - { value: '9', label: 'Chilled' }, - ]); -}); - -test('nothing is offered while the real answer is still in flight', () => { - // Showing "General" mid-load and swapping it for the tenant's real list a - // moment later moves where a product lands without the merchant touching it. - assert.deepEqual(categoryOptionsFor(undefined, true), []); -}); - -test('a missing list settles the same way as an empty one', () => { - assert.equal(categoryOptionsFor(undefined, false).length, 1); -}); diff --git a/src/features/catalogue/tenantCategories.ts b/src/features/catalogue/tenantCategories.ts index 9bb9e93..e5d42ad 100644 --- a/src/features/catalogue/tenantCategories.ts +++ b/src/features/catalogue/tenantCategories.ts @@ -1,5 +1,3 @@ -import type { ProductCategory } from '@/api/types'; - /** * The category the customer app browses. * @@ -10,46 +8,18 @@ import type { ProductCategory } from '@/api/types'; * Measured, not assumed: every tenant on the platform that has products reports * category 2 and nothing else, and category 2 carries the ten real retail * subcategories. + * + * It survives as a LAST RESORT only. Nothing picks a category any longer: every + * product is classified by `productCategory.ts` and the name exchanged for this + * merchant's id by `resolvecategories`. This is what a row falls back to when + * that call cannot be made at all — better a product on the shelf under the + * aisle the app browses than a product filed under 0, which no browse query + * returns. + * + * What used to live beside it, `categoryOptionsFor`, is gone with the pickers + * it fed. It offered a tenant's own categories and this constant as a floor, + * and its floor was the visible symptom: a merchant with no list yet was shown + * "General (the category the app shows)", chose it because it was the only + * entry, and every product they imported arrived as "Category 2". */ export const APP_BROWSE_CATEGORY = 2; - -export interface CategoryOption { - value: string; - label: string; -} - -/** - * A tenant's categories, with a floor for one that has none yet. - * - * `gettenantcategories` is synthesised from the categories a tenant's products - * ALREADY use. That makes it right for an established shop and empty for a new - * one — and a new shop is exactly who is importing their first product. - * - * Without a floor the two rules deadlock: the catalogue refuses to import - * without a category (correctly — `categoryid` 0 is invisible to shoppers), and - * the only way to acquire a category is to import a product. Measured on - * 2026-09-02: tenant 1148 had 0 products and 0 categories and could not add its - * first one from the catalogue at all. - * - * The floor is the category the app actually browses, so a product filed under - * it is sellable rather than merely stored. Note what is deliberately NOT - * offered: `getproductcategories`, the master table, returns `1001: vegetables` - * for every tenant — it looks like a perfectly good category and is a trap, - * because the app never asks for it. - * - * `isLoading` returns an empty list rather than the floor: offering it while the - * real answer is still in flight would show a shop a category it does not use - * and then swap it underneath them. - */ -export function categoryOptionsFor( - categories: readonly ProductCategory[] | undefined, - isLoading: boolean, -): CategoryOption[] { - const own = (categories ?? []).map((entry) => ({ - value: String(entry.categoryid), - label: entry.categoryname, - })); - if (own.length > 0) return own; - if (isLoading) return []; - return [{ value: String(APP_BROWSE_CATEGORY), label: 'General (the category the app shows)' }]; -} diff --git a/src/features/nearle-admin/import/ImportScope.tsx b/src/features/nearle-admin/import/ImportScope.tsx index 784545c..b02298a 100644 --- a/src/features/nearle-admin/import/ImportScope.tsx +++ b/src/features/nearle-admin/import/ImportScope.tsx @@ -1,9 +1,9 @@ /** * Who an uploaded sheet is for. * - * A sheet becomes stock on one shelf, so the upload has to name a tenant, a - * branch and a category before it can do anything — and where those come from - * depends entirely on who is signed in: + * A sheet becomes stock on one shelf, so the upload has to name a tenant and a + * branch before it can do anything — and where those come from depends entirely + * on who is signed in: * * - A **Nearle Admin** works across every merchant. Nothing in their session * says which one, so they are ASKED, and the upload stays blocked until @@ -16,21 +16,27 @@ * The branch matters as much as the tenant and is easier to forget: stock is * held per outlet, so a sheet uploaded against the wrong branch loads a shelf * nobody is standing at. + * + * A CATEGORY IS NOT ASKED FOR, and its absence is deliberate. It used to be the + * third question here, and one answer was applied to every row in the sheet — + * so a shop uploading its whole range had butter, bleach and biryani masala all + * filed under the same aisle, and the app's category filter showed exactly that. + * The category is now worked out per product from the catalogue team's ladder + * (`productCategory.ts`) and resolved to an id at shelving time, which is a + * classification and not a preference: there is nothing here for anyone to get + * right or wrong. */ import { useEffect, useMemo } from 'react'; import { Selector } from '@astryxdesign/core/Selector'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; -import { useTenantCategories, useTenantLocations, useTenants } from '@/queries/hooks'; -import { categoryOptionsFor } from '@/features/catalogue/tenantCategories'; +import { useTenantLocations, useTenants } from '@/queries/hooks'; /** Everything an upload needs before it can be turned into stock. */ export interface ImportTarget { tenantid: number; locationid: number; - /** Used for any sheet row that names no category of its own. */ - categoryid: number; } export interface ImportScopeProps { @@ -48,7 +54,6 @@ export interface ImportScopeProps { export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) { const tenants = useTenants({ pageno: 1, pagesize: 200 }); const locations = useTenantLocations(value.tenantid); - const categories = useTenantCategories(value.tenantid); const tenantOptions = useMemo( () => @@ -70,39 +75,11 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc [locations.data], ); - /** - * The tenant's own categories, with a floor for a merchant that has none yet. - * - * `gettenantcategories` is synthesised from the categories a tenant's products - * ALREADY use, which makes it right for an established shop and empty for a - * new one — and a new shop is exactly who is doing their first import. Without - * a floor the picker would be empty, the upload blocked, and the only way to - * get a category would be to import a product, which is the thing being - * blocked. - * - * The floor is the category the customer app actually browses. Measured, not - * assumed: every tenant with products reports category 2 and nothing else, - * category 2 carries the ten real retail subcategories (Vegetables & Fruits, - * Dairy Deli & Egg, and so on), and `scripts/appgap.mjs` shows the app asking - * for `categoryid: 2`. - * - * Note what is deliberately NOT offered: `getproductcategories`, the master - * table, returns `1001: vegetables` for every tenant. It looks like a - * perfectly good category and is a trap — the app does not browse it, so - * anything filed there is invisible to shoppers. - */ - const categoryOptions = useMemo( - () => categoryOptionsFor(categories.data, categories.isLoading), - [categories.data, categories.isLoading], - ); - /** * A single branch is not a choice, so it is made rather than offered. * * Most merchants run one outlet. Leaving it unpicked would block the upload - * behind a dropdown with one entry — and the same applies to the category, - * where `gettenantcategories` reports one usable value for every tenant seen - * so far. + * behind a dropdown with one entry. */ useEffect(() => { if (!value.tenantid) return; @@ -111,13 +88,6 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc } }, [branchOptions, value, onChange]); - useEffect(() => { - if (!value.tenantid) return; - if (!value.categoryid && categoryOptions.length === 1) { - onChange({ ...value, categoryid: Number(categoryOptions[0]!.value) }); - } - }, [categoryOptions, value, onChange]); - return ( {!isTenantFixed ? ( @@ -129,9 +99,9 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'} description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set." onChange={(next) => - // Changing the merchant clears the branch and category with it. - // Keeping them would leave another tenant's branch id attached to - // this one — an id that is valid-looking and wrong. + // Changing the merchant clears the branch with it. Keeping it + // would leave another tenant's branch id attached to this one — an + // id that is valid-looking and wrong. onChange({ tenantid: Number(next) || undefined }) } /> @@ -154,40 +124,18 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc onChange={(next) => onChange({ ...value, locationid: Number(next) || undefined })} /> - onChange({ ...value, categoryid: Number(next) || undefined })} - /> - {value.tenantid && branchOptions.length === 0 && !locations.isLoading ? ( This merchant has no branches, so there is nowhere for stock to land. Create one first. ) : null} - - {value.tenantid && (categories.data ?? []).length === 0 && !categories.isLoading ? ( - - This merchant has no categories of its own yet, so the one the customer app browses is - offered instead. Their first import is what creates the list. - - ) : null} ); } /** True when every field an import needs has been answered. */ export function isTargetComplete(value: Partial): value is ImportTarget { - return Boolean(value.tenantid && value.locationid && value.categoryid); + return Boolean(value.tenantid && value.locationid); } /** What is still missing, in words, for a blocked-reason line. */ @@ -195,7 +143,6 @@ export function describeMissingTarget(value: Partial): string | nu const missing = [ !value.tenantid ? 'a merchant' : null, !value.locationid ? 'a branch' : null, - !value.categoryid ? 'a category' : null, ].filter(Boolean); if (missing.length === 0) return null; return `Choose ${missing.join(', ')} before uploading — a sheet becomes stock on one shelf, and this is which.`; diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx index 05097e5..8062dc4 100644 --- a/src/features/nearle-admin/import/SheetImportPanel.tsx +++ b/src/features/nearle-admin/import/SheetImportPanel.tsx @@ -43,7 +43,7 @@ import { usePaged } from '@/components/usePaged'; interface PreviewRow extends Record { productname: string; productsku: string; - categoryid: number; + category: string; retailprice: number; productcost: number; quantity: number; @@ -211,7 +211,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps await uploadsApi.record({ tenantid: target.tenantid, locationid: target.locationid, - categoryid: target.categoryid, + // Nothing picks a category any more, so the receipt's column is + // written 0 and never read back: `shelveBatch` classifies each row. + categoryid: 0, batchid: submitted.batch_id, filename: file.name, sender: submitted.submitted_by ?? '', @@ -315,9 +317,20 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps ), }, - /* No Category column. The console no longer reads one from the sheet — it - is chosen once above and applied to every row — so a column here would - show 0 on every line and invite somebody to "fix" it in the file. */ + /* The category, shown because nobody chooses it any more. + It is worked out per row before the upload, so this column is the one + place the operator can see what each product will be filed under while + the file is still theirs to correct. */ + { + key: 'category', + header: 'Category', + width: { type: 'proportional', value: 2 }, + renderCell: (row) => ( + + {row.category || 'General'} + + ), + }, { key: 'retailprice', header: 'Retail', @@ -355,7 +368,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({ productname: row.productname, productsku: row.productsku, - categoryid: row.categoryid, + category: row.category ?? '', retailprice: row.retailprice, productcost: row.productcost, quantity: row.quantity, @@ -730,10 +743,10 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps and an opening stock are used when present, and a SKU makes the match back to your rows exact rather than by name.
- You do not need a categoryid column — the category chosen above applies to every row - that does not name one. Leaving it out is the safer shape: a wrong id in a sheet is - accepted silently and hides the product from shoppers, while the picker can only offer - ids the customer app actually browses. + You are not asked for a category, and a categoryid column is ignored if you include + one. Each product is filed by the platform's own classification — the sheet's category + column if it has one, then the product registry, then its name — into one of the 31 + categories the customer app browses, so a mixed sheet no longer lands in a single aisle. {parseError ? ( diff --git a/src/features/nearle-admin/import/openingStock.test.ts b/src/features/nearle-admin/import/openingStock.test.ts index 8680b68..7a48119 100644 --- a/src/features/nearle-admin/import/openingStock.test.ts +++ b/src/features/nearle-admin/import/openingStock.test.ts @@ -96,7 +96,7 @@ test('one call carries the price and the opening stock together', () => { const { requests } = buildImportRequests(plan, { tenantid: 1141, locationid: 1179, - fallbackCategoryId: 2, + aisleIds: new Map(), catalogueIds: ids, }); @@ -110,17 +110,19 @@ test('one call carries the price and the opening stock together', () => { assert.equal(req.locationid, 1179); }); -// categoryid 0 is the state the customer app rejects outright — it has cost this -// system seven invisible products already. -test('a blank sheet category falls back to the operator choice, never zero', () => { - const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]); +// categoryid 2 is not a label, it is the value the app FILTERS on — see +// `appAisle.ts`. A per-product categoryid does not categorise the product, it +// removes it from the app's browse entirely. +test('categoryid is always the one the app asks for', () => { + const plan = planOpeningStock([manifest()], [sheetRow({ category: 'Dairy', categoryid: 91 })]); const { requests } = buildImportRequests(plan, { tenantid: 1141, locationid: 1179, - fallbackCategoryId: 2, + aisleIds: new Map([['dairy, deli & egg', 15]]), catalogueIds: ids, }); - assert.equal(requests[0]?.categoryid, 2); + assert.equal(requests[0]?.categoryid, 2, 'never 91 — the app asks for 2 and would miss it'); + assert.equal(requests[0]?.subcategoryid, 15, 'the aisle is where the classification lands'); }); // A ₹0 product can be ordered for nothing. Refused and named. @@ -129,7 +131,7 @@ test('an unpriced row is refused rather than listed at zero', () => { const out = buildImportRequests(plan, { tenantid: 1141, locationid: 1179, - fallbackCategoryId: 2, + aisleIds: new Map(), catalogueIds: ids, }); assert.equal(out.requests.length, 0); @@ -141,7 +143,7 @@ test('a product whose image_id resolves to nothing is reported, not skipped', () const out = buildImportRequests(plan, { tenantid: 1141, locationid: 1179, - fallbackCategoryId: 2, + aisleIds: new Map(), catalogueIds: new Map(), }); assert.equal(out.requests.length, 0); @@ -150,20 +152,20 @@ test('a product whose image_id resolves to nothing is reported, not skipped', () test('a fractional or negative opening stock is made sane', () => { const plan = planOpeningStock([manifest()], [sheetRow({ quantity: -5 })]); - const a = buildImportRequests(plan, { tenantid: 1, locationid: 2, fallbackCategoryId: 2, catalogueIds: ids }); + const a = buildImportRequests(plan, { tenantid: 1, locationid: 2, aisleIds: new Map(), catalogueIds: ids }); assert.equal(a.requests[0]?.quantity, 0, 'negative opening stock is not a receipt'); const plan2 = planOpeningStock([manifest()], [sheetRow({ quantity: 3.7 })]); - const b = buildImportRequests(plan2, { tenantid: 1, locationid: 2, fallbackCategoryId: 2, catalogueIds: ids }); + const b = buildImportRequests(plan2, { tenantid: 1, locationid: 2, aisleIds: new Map(), catalogueIds: ids }); assert.equal(b.requests[0]?.quantity, 3); }); /* ── A sheet with no categoryid column at all ─────────────────────────────── */ // parseProductSheet defaults a missing categoryid to 0, and 0 is the value the -// customer app rejects outright. The picker's choice is what must fill it — -// otherwise dropping the column would silently hide every product uploaded. -test('a sheet with no category column gets the operator choice on every row', () => { +// customer app rejects outright. It must never reach the request whatever the +// sheet holds — otherwise dropping the column would hide every product uploaded. +test('a sheet with nothing to classify still never files a row under zero', () => { const rows = [ sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', categoryid: 0 }), sheetRow({ productname: 'Amul Ghee 1L', productsku: 'A2', categoryid: 0 }), @@ -177,7 +179,7 @@ test('a sheet with no category column gets the operator choice on every row', () const { requests } = buildImportRequests(plan, { tenantid: 1141, locationid: 1179, - fallbackCategoryId: 2, + aisleIds: new Map(), catalogueIds: new Map([ ['amul_amul_butter_100g', 42], ['amul_amul_ghee_1l', 43], @@ -187,20 +189,80 @@ test('a sheet with no category column gets the operator choice on every row', () assert.equal(requests.length, 2); for (const req of requests) { assert.equal(req.categoryid, 2, 'never 0 — that is the invisible state'); + assert.equal(req.subcategoryid, 0, "no classification means the app own Uncategorized bucket"); } }); -// The sheet no longer supplies a category at all — parseProductSheet stopped -// mapping the column, so every row arrives as 0 and the operator answer is the -// only source. This is the guard on that: if a row ever carries a stray value -// it is still used, but nothing in the parser produces one any more. -test('the operator answer applies even when a row somehow carries zero', () => { - const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]); +// A stray subcategoryid in a sheet is honoured only when nothing classified the +// row — the classification always wins, because a number typed into a column +// nobody documents is the least trustworthy thing in the file. +test('the classification beats a subcategoryid typed into the sheet', () => { + const plan = planOpeningStock( + [manifest()], + [sheetRow({ category: 'Dairy', subcategoryid: 99 })], + ); const { requests } = buildImportRequests(plan, { tenantid: 1141, locationid: 1179, - fallbackCategoryId: 2, + aisleIds: new Map([['dairy, deli & egg', 15]]), catalogueIds: ids, }); - assert.equal(requests[0]?.categoryid, 2); + assert.equal(requests[0]?.subcategoryid, 15); +}); + +/* ── The classification decides the aisle, not one answer for the sheet ───── */ + +// This is the whole point of removing the picker. One id used to be applied to +// every row, so a shop uploading its range had butter, bleach and biryani +// masala filed together — and the app's browse showed exactly that. +test('each row takes its own aisle, not one answer for the sheet', () => { + const rows = [ + sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', category: 'Dairy', categoryid: 0 }), + sheetRow({ + productname: 'Aachi Biryani Masala 50g', + productsku: 'A2', + category: 'Spices & Masalas', + categoryid: 0, + }), + ]; + const products = [ + manifest({ product_sku: 'A1' }), + manifest({ + image_id: 'aachi_biryani_masala_50g', + product_name: 'Aachi Biryani Masala 50g', + product_sku: 'A2', + }), + ]; + + const { requests } = buildImportRequests(planOpeningStock(products, rows), { + tenantid: 1141, + locationid: 1179, + // Keyed by AISLE name, lower-cased — the ten rows the app groups by. + aisleIds: new Map([ + ['dairy, deli & egg', 15], + ['foodgrains & pulses', 18], + ]), + catalogueIds: new Map([ + ['amul_amul_butter_100g', 42], + ['aachi_biryani_masala_50g', 43], + ]), + }); + + assert.equal(requests.length, 2); + assert.equal(requests[0]?.subcategoryid, 15, 'butter to Dairy, Deli & Egg'); + assert.equal(requests[1]?.subcategoryid, 18, 'masala to Foodgrains & Pulses, not the same aisle'); +}); + +// Casing and stray spaces come from the ladder's own names and from whatever +// the merchant's category list already holds; neither should cost a product its +// aisle and send it to the fallback. +test('a name is matched regardless of casing or surrounding space', () => { + const plan = planOpeningStock([manifest()], [sheetRow({ category: ' Dairy ', categoryid: 0 })]); + const { requests } = buildImportRequests(plan, { + tenantid: 1141, + locationid: 1179, + aisleIds: new Map([['dairy, deli & egg', 15]]), + catalogueIds: ids, + }); + assert.equal(requests[0]?.subcategoryid, 15); }); diff --git a/src/features/nearle-admin/import/openingStock.ts b/src/features/nearle-admin/import/openingStock.ts index 715a645..d3ea512 100644 --- a/src/features/nearle-admin/import/openingStock.ts +++ b/src/features/nearle-admin/import/openingStock.ts @@ -40,6 +40,8 @@ */ import type { IngestProduct } from '@/api/ingest'; +import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; +import { aisleIdForCategory } from '@/features/store-admin/appAisle'; import type { SheetProductRow } from '@/api/products'; /** Where a product's price and opening stock came from. */ @@ -148,8 +150,14 @@ export interface ImportRequestRow { export interface BuildOptions { tenantid: number; locationid: number; - /** Applied to any row whose sheet left `categoryid` blank. */ - fallbackCategoryId: number; + /** + * Lower-cased AISLE NAME to the id the customer app groups by. + * + * Each row carries the category the classification ladder decided; that name + * folds into one of the app's ten aisles, and this maps the aisle to the + * `productsubcategories` id. Nobody picks it — see `shelveBatch`. + */ + aisleIds: ReadonlyMap; /** `image_id` → the catalogue row id `importcatalogueproduct` addresses. */ catalogueIds: ReadonlyMap; } @@ -178,7 +186,7 @@ export interface BuildResult { * sent and both come back named. */ export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult { - const { tenantid, locationid, fallbackCategoryId, catalogueIds } = options; + const { tenantid, locationid, aisleIds, catalogueIds } = options; const requests: ImportRequestRow[] = []; const unresolved: IngestProduct[] = []; @@ -202,11 +210,16 @@ export function buildImportRequests(plan: StockPlan, options: BuildOptions): Bui locationid, brand: product.brand, catalogueid, - // The sheet's own category when it named one, the operator's choice - // otherwise. Never 0 — a product filed under 0 is invisible to shoppers - // and no API could repair it until recently. - categoryid: Number(row.categoryid) || fallbackCategoryId, - subcategoryid: Number(row.subcategoryid) || 0, + // ALWAYS 2. `getproductsbysubcategory` filters on `categoryid = 2`, the + // value the app sends, so a per-product categoryid removes the product + // from the app rather than labelling it. Never 0 either — that is the + // state the browse endpoint rejects outright. + categoryid: APP_BROWSE_CATEGORY, + // The aisle heading a shopper reads. 0 when the row could not be + // classified, which puts it in the app's own "Uncategorized" bucket — + // honest, and the state every product on the platform was already in. + subcategoryid: + aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0, // The opening stock. This is the whole point of the flow: one call writes // the product, puts it on this outlet's shelf, and records the receipt. quantity: Math.max(0, Math.trunc(Number(row.quantity) || 0)), diff --git a/src/features/nearle-admin/import/shelve.ts b/src/features/nearle-admin/import/shelve.ts index d1ce2fa..ab4b407 100644 --- a/src/features/nearle-admin/import/shelve.ts +++ b/src/features/nearle-admin/import/shelve.ts @@ -18,13 +18,13 @@ import { catalogueApi } from '@/api/catalogue'; import { productsApi, type SheetProductRow } from '@/api/products'; import { productsOf, type IngestBatch } from '@/api/ingest'; +import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; +import { aisleIdsFrom } from '@/features/store-admin/appAisle'; import { buildImportRequests, planOpeningStock, type StockPlan } from './openingStock'; export interface ShelveTarget { tenantid: number; locationid: number; - /** Applied to every row; no sheet column carries one any more. */ - categoryid: number; } export interface ShelveResult { @@ -84,10 +84,38 @@ export async function shelveBatch( } } + /** + * The aisles, worked out rather than asked for. + * + * Every row already carries the category NAME the classification ladder + * decided for it — the sheet's own value, then the registry, then the + * lexicon, then "General". What the customer app groups by is not that + * category but `products.subcategoryid`, one of ten platform rows, so the + * name is folded into an aisle and the aisle looked up by name here. + * + * There is nothing left for an operator to choose, and the choice they used + * to be given was the whole bug twice over: it applied one id to an entire + * sheet, and it applied it to the wrong column — `categoryid`, which the app + * uses as a FILTER (it asks for 2) rather than as a label. + * + * A failure here is not fatal. The lookup falls back to the ids last read + * from the platform, and a row that still cannot be placed goes out as + * subcategoryid 0, which the app shows under "Uncategorized" — the state + * every product was already in, not a new harm. + */ + let aisleIds: ReadonlyMap; + try { + aisleIds = aisleIdsFrom( + await productsApi.subCategories(target.tenantid, APP_BROWSE_CATEGORY), + ); + } catch { + aisleIds = aisleIdsFrom(undefined); + } + const { requests, unresolved, unpriced } = buildImportRequests(plan, { tenantid: target.tenantid, locationid: target.locationid, - fallbackCategoryId: target.categoryid, + aisleIds, catalogueIds, }); diff --git a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx index 3c545d0..14894ad 100644 --- a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx +++ b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx @@ -82,7 +82,6 @@ export function GlobalCataloguePage() { diff --git a/src/features/store-admin/CataloguePanel.tsx b/src/features/store-admin/CataloguePanel.tsx index 85a0109..302999a 100644 --- a/src/features/store-admin/CataloguePanel.tsx +++ b/src/features/store-admin/CataloguePanel.tsx @@ -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 ( 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. */} } + {...(aisleName ? {} : { tone: 'muted' as const })} /> { + const out = new Map(); + 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 = { 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({ ; isLoading: boolean; onOpen: (product: Product) => void; onPrice: (product: Product) => void; @@ -251,6 +274,7 @@ function ProductTable({ onOpen(product)} onPrice={() => onPrice(product)} /> @@ -276,10 +300,12 @@ function ProductTable({ */ function Row({ product, + aisleNames, onOpen, onPrice, }: { product: Product; + aisleNames: ReadonlyMap; 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'} diff --git a/src/features/store-admin/TenantSheetImportPanel.tsx b/src/features/store-admin/TenantSheetImportPanel.tsx index 26f1c67..7342762 100644 --- a/src/features/store-admin/TenantSheetImportPanel.tsx +++ b/src/features/store-admin/TenantSheetImportPanel.tsx @@ -296,9 +296,10 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo /> - 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. {parseError ? ( diff --git a/src/features/store-admin/appAisle.test.ts b/src/features/store-admin/appAisle.test.ts new file mode 100644 index 0000000..84558a0 --- /dev/null +++ b/src/features/store-admin/appAisle.test.ts @@ -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)); +}); diff --git a/src/features/store-admin/appAisle.ts b/src/features/store-admin/appAisle.ts new file mode 100644 index 0000000..c912579 --- /dev/null +++ b/src/features/store-admin/appAisle.ts @@ -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 = { + '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 = { + /* 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 { + const out = new Map(); + 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, +): number { + const aisle = aisleForCategory(category); + if (!aisle) return 0; + return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle]; +} diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx index b3f6789..521cf8a 100644 --- a/src/features/store-user/pages/StoreProductsPage.tsx +++ b/src/features/store-user/pages/StoreProductsPage.tsx @@ -13,10 +13,10 @@ import type { Product, StockRequest, StockStatementRow } from '@/api/types'; import { PageHeader } from '@/components/PageHeader'; import { queryKeys } from '@/queries/keys'; import { + useAppAisles, useLocationProducts, useStockRequests, useStockStatement, - useTenantCategories, } from '@/queries/hooks'; import { useBranchScope } from '@/features/store-admin/BranchScope'; import { ProductDrawer } from '@/features/store-admin/ProductDrawer'; @@ -66,7 +66,10 @@ export function StoreProductsPage() { const [askingMany, setAskingMany] = useState(null); const products = useLocationProducts(tenantid || undefined, locationid, 0); - const categories = useTenantCategories(tenantid || undefined); + /* The aisles the customer app groups by, not the categories — see + `appAisle.ts`. `gettenantcategories` returns one entry for every tenant, + so the filter it fed had a single option in it. */ + const aisles = useAppAisles(tenantid || undefined); const statement = useStockStatement(tenantid || undefined, locationid, { pagesize: 200 }); /** @@ -94,7 +97,7 @@ export function StoreProductsPage() { const term = keyword.trim().toLowerCase(); return (products.data ?? []) .filter((product) => isPublished(product) && effectivePrice(product) > 0) - .filter((product) => !category || String(product.categoryid ?? '') === category) + .filter((product) => !category || String(product.subcategoryid ?? 0) === category) .filter((product) => term === '' ? true @@ -130,13 +133,14 @@ export function StoreProductsPage() { const categoryOptions = useMemo( () => [ - { value: '', label: 'All categories' }, - ...(categories.data ?? []).map((entry) => ({ - value: String(entry.categoryid), - label: entry.categoryname, + { value: '', label: 'All aisles' }, + { value: '0', label: 'Uncategorized' }, + ...(aisles.data ?? []).map((entry) => ({ + value: String(entry.subcatid), + label: entry.subcatname, })), ], - [categories.data], + [aisles.data], ); return ( diff --git a/src/features/uploads/UploadsPanel.tsx b/src/features/uploads/UploadsPanel.tsx index b01efa6..fd73d69 100644 --- a/src/features/uploads/UploadsPanel.tsx +++ b/src/features/uploads/UploadsPanel.tsx @@ -531,14 +531,11 @@ function ShelveAction({ const result = await shelveBatch( batch, rows, - { - tenantid: receipt.tenantid, - locationid: receipt.locationid, - // The category chosen at upload time, kept on the receipt precisely - // so this step does not have to guess it later. A product filed - // outside the one the app browses is invisible to shoppers. - categoryid: receipt.categoryid, - }, + // No category: `shelveBatch` classifies every row itself. The receipt + // still carries the column an older upload wrote, and it is deliberately + // not read — one id applied to a whole sheet is what filed a shop's + // entire range under a single aisle. + { tenantid: receipt.tenantid, locationid: receipt.locationid }, receipt.filename || undefined, ); await uploadsApi.markShelved({ diff --git a/src/index.css b/src/index.css index 75c56b2..b979b8a 100644 --- a/src/index.css +++ b/src/index.css @@ -665,20 +665,6 @@ main { orange would have made one page look like a different product. ──────────────────────────────────────────────────────────────────────────── */ -/* The search + rail-toggle row, lifted level with the page's tab row. - 42px is the measured distance between the two rows' centres. The bottom - margin then restores the 24px the grid should keep below the tab row — - without it the pull-up drags the whole grid up with it and the first row of - cards slides under the tabs. */ -@media (min-width: 900px) { - .catalogue-controls { - margin-top: -42px; - margin-bottom: 18px; - position: relative; - z-index: 1; - } -} - .catalogue-layout { display: grid; gap: 20px; diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index 8cd2e2d..3f7a873 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -18,6 +18,7 @@ import { stockApi, type StockRequestQuery } from '@/api/stock'; import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants'; import { customersApi, type CustomerQuery } from '@/api/customers'; import { uploadsApi } from '@/api/uploads'; +import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories'; import { queryKeys } from './keys'; import { LIVE_REFETCH_MS } from './interval'; @@ -138,15 +139,17 @@ export function useImportedRefs(tenantid: number | undefined) { /* ── Products ────────────────────────────────────────────────────────────── */ /** - * The tenant's categories as they are actually used. + * The ten aisles the customer app groups products into. * - * Prefer this over `products/getproductcategories`, the master table missing - * rows for live categoryids and hardcoded to `moduleid = 2`. + * `productsubcategories` for category 2 — platform rows, the same ten for + * every tenant, so the tenantid only satisfies the endpoint's signature. This + * is the list `getproductsbysubcategory` buckets against, which makes it the + * one the console has to name products from too; see `appAisle.ts`. */ -export function useTenantCategories(tenantid: number | undefined) { +export function useAppAisles(tenantid: number | undefined) { return useQuery({ - queryKey: queryKeys.products.tenantCategories(tenantid ?? 0), - queryFn: () => productsApi.tenantCategories(tenantid as number), + queryKey: queryKeys.products.subCategories(tenantid ?? 0, APP_BROWSE_CATEGORY), + queryFn: () => productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY), enabled: typeof tenantid === 'number' && tenantid > 0, ...stable, });