category changes
This commit is contained in:
@@ -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<string, { count: number; source: Source }> = {};
|
||||
const byAisle: Record<string, number> = {};
|
||||
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();
|
||||
|
||||
@@ -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<Record<string, number>>(`${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<string, number> = {};
|
||||
if (wanted.length > 0) {
|
||||
try {
|
||||
resolved = (await productsApi.resolveCategories(tenantid, wanted)) ?? {};
|
||||
} catch {
|
||||
resolved = {};
|
||||
}
|
||||
let aisleIds: ReadonlyMap<string, number>;
|
||||
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,
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
||||
const [open, setOpen] = useState<CatalogueProduct | null>(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<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
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<string, number> = {};
|
||||
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<number> {
|
||||
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<Map<string, number>> {
|
||||
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({
|
||||
<VStack gap={2}>
|
||||
{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. */}
|
||||
<HStack
|
||||
gap={1}
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
justify="end"
|
||||
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
|
||||
>
|
||||
<HStack gap={1} align="center" wrap="wrap" justify="end">
|
||||
{activeFilters.map((filter) => (
|
||||
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
|
||||
))}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
<Banner
|
||||
status="success"
|
||||
@@ -268,36 +269,37 @@ export function CatalogueDetailDrawer({
|
||||
) : onImport ? (
|
||||
<Section title="Import into my products">
|
||||
<DrawerCard>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 14 }}>
|
||||
{categoryOptions ? (
|
||||
<Selector
|
||||
label="Category"
|
||||
size="sm"
|
||||
options={categoryOptions}
|
||||
value={categoryid}
|
||||
onChange={(value) => 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}
|
||||
<Row
|
||||
label="Filed under"
|
||||
value={<Badge label={suggested.category} colour="var(--color-brand)" />}
|
||||
/>
|
||||
{/* 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. */}
|
||||
<Row
|
||||
label="Shown in the app under"
|
||||
value={
|
||||
aisle ? (
|
||||
<Badge label={aisle} colour="#0f8a5f" />
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
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}.`}
|
||||
</span>
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
{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.'}
|
||||
</span>
|
||||
</div>
|
||||
</DrawerCard>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)' }];
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<VStack gap={1.5}>
|
||||
{!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 })}
|
||||
/>
|
||||
|
||||
<Selector
|
||||
/* "for rows that do not name one" was the old label, and it stopped
|
||||
being true when the sheet stopped carrying a category. No row names
|
||||
one now, so it read as a rare fallback when it is in fact the only
|
||||
source — and a field that looks optional is a field people skip. */
|
||||
label="Category"
|
||||
size="sm"
|
||||
options={categoryOptions}
|
||||
value={value.categoryid ? String(value.categoryid) : ''}
|
||||
placeholder={!value.tenantid ? 'Choose a merchant first' : 'Choose a category'}
|
||||
description="Applied to every product in the sheet. The customer app browses by category, and one filed without a category cannot be found there at all — which is why this is asked here rather than typed on every row."
|
||||
isDisabled={!value.tenantid}
|
||||
onChange={(next) => onChange({ ...value, categoryid: Number(next) || undefined })}
|
||||
/>
|
||||
|
||||
{value.tenantid && branchOptions.length === 0 && !locations.isLoading ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
This merchant has no branches, so there is nowhere for stock to land. Create one first.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{value.tenantid && (categories.data ?? []).length === 0 && !categories.isLoading ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
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.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when every field an import needs has been answered. */
|
||||
export function isTargetComplete(value: Partial<ImportTarget>): 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<ImportTarget>): 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.`;
|
||||
|
||||
@@ -43,7 +43,7 @@ import { usePaged } from '@/components/usePaged';
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
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
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
/* 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) => (
|
||||
<Text type="body" size="sm" color={row.category ? undefined : 'secondary'}>
|
||||
{row.category || 'General'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
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.
|
||||
<br />
|
||||
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.
|
||||
</Text>
|
||||
|
||||
{parseError ? (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, number>;
|
||||
/** `image_id` → the catalogue row id `importcatalogueproduct` addresses. */
|
||||
catalogueIds: ReadonlyMap<string, number>;
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<string, number>;
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ export function GlobalCataloguePage() {
|
||||
<CatalogueBrowser
|
||||
tenantid={undefined}
|
||||
locationid={undefined}
|
||||
categoryOptions={[]}
|
||||
actionLabel="Add to store"
|
||||
isReadOnly
|
||||
/>
|
||||
|
||||
@@ -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.' }
|
||||
: {})}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
91
src/features/store-admin/appAisle.test.ts
Normal file
91
src/features/store-admin/appAisle.test.ts
Normal 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));
|
||||
});
|
||||
198
src/features/store-admin/appAisle.ts
Normal file
198
src/features/store-admin/appAisle.ts
Normal 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];
|
||||
}
|
||||
@@ -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<Product[] | null>(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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user