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
|
* Everything imported before this carries `subcategoryid: 0`, which the app
|
||||||
* category the tenant had — "Category 2" for most shops — while the catalogue
|
* renders as one heading called "Uncategorized" holding the entire shop —
|
||||||
* has known all along that an Aachi masala is Spices & Masalas. This reads that
|
* measured on live tenant 1135/1166 — while the catalogue has known all along
|
||||||
* answer back and writes it.
|
* 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 # dry run, writes nothing
|
||||||
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
|
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
|
||||||
@@ -27,7 +32,12 @@
|
|||||||
|
|
||||||
import { catalogueApi } from '../src/api/catalogue';
|
import { catalogueApi } from '../src/api/catalogue';
|
||||||
import { productsApi } from '../src/api/products';
|
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';
|
import type { CatalogueProduct, Product } from '../src/api/types';
|
||||||
|
|
||||||
const tenantid = Number(process.argv[2]);
|
const tenantid = Number(process.argv[2]);
|
||||||
@@ -42,8 +52,12 @@ type Source = 'catalogue' | 'ladder';
|
|||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
product: Product;
|
product: Product;
|
||||||
|
/** The subcategory the product sits in today — 0 for everything, so far. */
|
||||||
from: number;
|
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;
|
source: Source;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,58 +95,82 @@ async function main() {
|
|||||||
const sku = (product.productsku ?? '').trim().toLowerCase();
|
const sku = (product.productsku ?? '').trim().toLowerCase();
|
||||||
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
|
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
|
||||||
|
|
||||||
const fromCatalogue = (row?.category ?? '').trim();
|
/*
|
||||||
const toName = fromCatalogue
|
The catalogue's answer only when it is one of the 31.
|
||||||
? fromCatalogue
|
|
||||||
: resolveCategory({
|
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 ?? '',
|
title: product.productname ?? '',
|
||||||
description: product.productdesc ?? '',
|
description: product.productdesc ?? '',
|
||||||
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
|
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
|
||||||
}).category;
|
});
|
||||||
|
|
||||||
plans.push({
|
plans.push({
|
||||||
product,
|
product,
|
||||||
from: product.categoryid ?? 0,
|
from: product.subcategoryid ?? 0,
|
||||||
toName,
|
category: verdict.category,
|
||||||
source: fromCatalogue ? 'catalogue' : 'ladder',
|
aisle: aisleForCategory(verdict.category),
|
||||||
|
source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve every distinct name once, creating the aisles that do not exist.
|
// The aisle ids, by name, from the platform's own list — see `appAisle.ts`
|
||||||
const names = [...new Set(plans.map((p) => p.toName))];
|
// for why they are matched on the name and not remembered as numbers.
|
||||||
const ids = isApply
|
const aisleIds = aisleIdsFrom(
|
||||||
? ((await productsApi.resolveCategories(tenantid, names)) ?? {})
|
await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined),
|
||||||
: Object.fromEntries(names.map((n) => [n.toLowerCase(), -1]));
|
);
|
||||||
|
|
||||||
const byCategory: Record<string, { count: number; source: Source }> = {};
|
const byAisle: Record<string, number> = {};
|
||||||
let unchanged = 0;
|
let unchanged = 0;
|
||||||
const writes: Plan[] = [];
|
const writes: Plan[] = [];
|
||||||
|
const orphans: Plan[] = [];
|
||||||
|
|
||||||
for (const plan of plans) {
|
for (const plan of plans) {
|
||||||
const to = ids[plan.toName.toLowerCase()];
|
if (!plan.aisle) {
|
||||||
if (to !== undefined && to === plan.from) {
|
orphans.push(plan);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0;
|
||||||
|
if (to === plan.from) {
|
||||||
unchanged += 1;
|
unchanged += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
writes.push(plan);
|
writes.push(plan);
|
||||||
const key = `${plan.toName} (${plan.source})`;
|
const key = `${plan.aisle} ← ${plan.category} (${plan.source})`;
|
||||||
byCategory[key] = { count: (byCategory[key]?.count ?? 0) + 1, source: plan.source };
|
byAisle[key] = (byAisle[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${writes.length} would be re-filed, ${unchanged} already correct\n`);
|
console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle
|
||||||
Object.entries(byCategory)
|
`);
|
||||||
.sort((a, b) => b[1].count - a[1].count)
|
Object.entries(byAisle)
|
||||||
.forEach(([name, { count }]) => console.log(` ${String(count).padStart(4)} ${name}`));
|
.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 (orphans.length > 0) {
|
||||||
if (unknown > 0) {
|
console.log(
|
||||||
console.log(`\n${unknown} could not be identified and would go to "${UNKNOWN_CATEGORY}".`);
|
`
|
||||||
|
${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:');
|
console.log('\nA sample of what changes:');
|
||||||
writes.slice(0, 12).forEach((p) => {
|
writes.slice(0, 12).forEach((p) => {
|
||||||
console.log(
|
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.
|
One call for the whole tenant, not one request per product.
|
||||||
|
|
||||||
`recategorise` writes a single column and is scoped by tenantid on the server,
|
`recategorise` writes the category and subcategory columns only, and is scoped
|
||||||
so it cannot reach another merchant's rows and cannot overwrite a price the
|
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
|
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
|
and is the wrong one: it updates `productlocations.status` and never touches
|
||||||
the products table at all.
|
the products table at all.
|
||||||
@@ -154,14 +192,16 @@ async function main() {
|
|||||||
const updates = writes
|
const updates = writes
|
||||||
.map((plan) => ({
|
.map((plan) => ({
|
||||||
productid: plan.product.productid,
|
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 skipped = writes.length - updates.length;
|
||||||
const result = await productsApi.recategorise(tenantid, updates);
|
const result = await productsApi.recategorise(tenantid, updates);
|
||||||
console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`);
|
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();
|
void main();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import type {
|
|||||||
ProductStockRequest,
|
ProductStockRequest,
|
||||||
ProductSubCategory,
|
ProductSubCategory,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
|
||||||
|
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
|
||||||
|
|
||||||
export interface LocationProductQuery {
|
export interface LocationProductQuery {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
@@ -153,13 +155,20 @@ export const productsApi = {
|
|||||||
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
|
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
|
* 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.
|
* a wrong id in the list would otherwise move another merchant's product.
|
||||||
*/
|
*/
|
||||||
recategorise: (tenantid: number, updates: { productid: number; categoryid: number }[]) =>
|
recategorise: (
|
||||||
api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }),
|
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. */
|
/** Unlinks from the store. The product row and its order history survive. */
|
||||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||||
@@ -237,30 +246,26 @@ export async function importSheetProducts(
|
|||||||
const createdSkus: string[] = [];
|
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
|
Every row carries a category NAME worked out by the ladder in
|
||||||
`productCategory.ts`, and the customer app browses by categoryid and rejects
|
`productCategory.ts`. What the customer app groups by is not that category
|
||||||
0 — so a product created before its aisle exists is a product no shopper can
|
but `products.subcategoryid` — one of ten platform rows under category 2 —
|
||||||
find. Resolving up front also means one round trip for the whole sheet rather
|
so the name is folded into an aisle and the aisle looked up by name here. See
|
||||||
than one per row.
|
`appAisle.ts` for the endpoint that is measured against.
|
||||||
|
|
||||||
A failure here is not fatal. The rows still carry whatever categoryid the
|
A failure here is not fatal: the lookup falls back to the ids last read from
|
||||||
operator picked for the upload, which is exactly the behaviour this had
|
the platform, and a row that still cannot be placed is created with
|
||||||
before, so a category service that is down costs the new filing and not the
|
subcategoryid 0, which the app lists under "Uncategorized".
|
||||||
import.
|
|
||||||
*/
|
*/
|
||||||
const wanted = [...new Set(rows.map((row) => row.category ?? '').filter(Boolean))];
|
let aisleIds: ReadonlyMap<string, number>;
|
||||||
let resolved: Record<string, number> = {};
|
|
||||||
if (wanted.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
resolved = (await productsApi.resolveCategories(tenantid, wanted)) ?? {};
|
aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY));
|
||||||
} catch {
|
} catch {
|
||||||
resolved = {};
|
aisleIds = aisleIdsFrom(undefined);
|
||||||
}
|
}
|
||||||
}
|
const subcategoryIdFor = (row: SheetProductRow): number =>
|
||||||
const categoryIdFor = (row: SheetProductRow): number =>
|
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
|
||||||
resolved[(row.category ?? '').trim().toLowerCase()] ?? row.categoryid;
|
|
||||||
|
|
||||||
for (const [index, row] of rows.entries()) {
|
for (const [index, row] of rows.entries()) {
|
||||||
try {
|
try {
|
||||||
@@ -268,8 +273,10 @@ export async function importSheetProducts(
|
|||||||
tenantid,
|
tenantid,
|
||||||
productname: row.productname,
|
productname: row.productname,
|
||||||
productsku: row.productsku,
|
productsku: row.productsku,
|
||||||
categoryid: categoryIdFor(row),
|
// ALWAYS 2 — the app filters on it and would drop anything else. The
|
||||||
subcategoryid: row.subcategoryid,
|
// aisle a shopper reads is the subcategory.
|
||||||
|
categoryid: APP_BROWSE_CATEGORY,
|
||||||
|
subcategoryid: subcategoryIdFor(row),
|
||||||
retailprice: row.retailprice,
|
retailprice: row.retailprice,
|
||||||
productcost: row.productcost,
|
productcost: row.productcost,
|
||||||
taxpercent: row.taxpercent,
|
taxpercent: row.taxpercent,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { VStack } from '@astryxdesign/core/VStack';
|
|||||||
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
||||||
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
||||||
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
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 { useSelection } from '@/components/useSelection';
|
||||||
import { productsApi } from '@/api/products';
|
import { productsApi } from '@/api/products';
|
||||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||||
@@ -34,8 +36,6 @@ export interface CatalogueBrowserProps {
|
|||||||
tenantid: number | undefined;
|
tenantid: number | undefined;
|
||||||
/** The outlet the import is written against. Required by the backend. */
|
/** The outlet the import is written against. Required by the backend. */
|
||||||
locationid: number | undefined;
|
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. */
|
/** Wording on the card and drawer buttons. */
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
/**
|
/**
|
||||||
@@ -56,15 +56,6 @@ export interface CatalogueBrowserProps {
|
|||||||
* rather than one at a time into somebody else's shop.
|
* rather than one at a time into somebody else's shop.
|
||||||
*/
|
*/
|
||||||
isReadOnly?: boolean;
|
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. */
|
/** Why importing is unavailable, if it is. */
|
||||||
blockedReason?: string;
|
blockedReason?: string;
|
||||||
}
|
}
|
||||||
@@ -89,35 +80,17 @@ export interface CatalogueBrowserProps {
|
|||||||
export function CatalogueBrowser({
|
export function CatalogueBrowser({
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
locationid,
|
||||||
categoryOptions,
|
|
||||||
actionLabel,
|
actionLabel,
|
||||||
onImport,
|
onImport,
|
||||||
scope,
|
scope,
|
||||||
blockedReason,
|
blockedReason,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
alignWithTabs,
|
|
||||||
}: CatalogueBrowserProps) {
|
}: CatalogueBrowserProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
|
||||||
const [brand, setBrand] = useState('');
|
const [brand, setBrand] = useState('');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [debounced, setDebounced] = 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 [busy, setBusy] = useState<string | null>(null);
|
||||||
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
||||||
const [open, setOpen] = useState<CatalogueProduct | null>(null);
|
const [open, setOpen] = useState<CatalogueProduct | null>(null);
|
||||||
@@ -224,9 +197,11 @@ export function CatalogueBrowser({
|
|||||||
arrived.
|
arrived.
|
||||||
*/
|
*/
|
||||||
mutationFn: async (products: CatalogueProduct[]) => {
|
mutationFn: async (products: CatalogueProduct[]) => {
|
||||||
const ids = await categoryIdsFor(products);
|
const ids = await aisleIds();
|
||||||
return productsApi.importFromCatalogue(
|
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) => {
|
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.
|
* One import row.
|
||||||
@@ -337,65 +277,40 @@ export function CatalogueBrowser({
|
|||||||
}).category;
|
}).category;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function categoryIdsFor(products: CatalogueProduct[]): Promise<Map<string, number>> {
|
/**
|
||||||
const out = new Map<string, number>();
|
* The aisle ids the app groups by, read from the platform's own list.
|
||||||
const fallback = Number(chosenCategory);
|
*
|
||||||
|
* One call for a whole batch. It is `productsubcategories` for category 2 —
|
||||||
if (importInto !== null && importInto !== '') {
|
* ten rows, shared by every tenant — and matching on the NAME rather than
|
||||||
for (const product of products) out.set(catalogueKey(product), Number(importInto));
|
* trusting a remembered id is what keeps this off the app's other
|
||||||
return out;
|
* subcategory table, which carries the same ten names five ids lower.
|
||||||
}
|
*/
|
||||||
|
async function aisleIds(): Promise<Map<string, number>> {
|
||||||
const names = [...new Set(products.map(categoryNameFor).filter(Boolean))];
|
|
||||||
let resolved: Record<string, number> = {};
|
|
||||||
if (names.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
resolved = (await productsApi.resolveCategories(tenantid as number, names)) ?? {};
|
return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY));
|
||||||
} catch {
|
} catch {
|
||||||
resolved = {};
|
return aisleIdsFrom(undefined);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolved = await productsApi.resolveCategories(tenantid as number, [name]);
|
|
||||||
return resolved?.[name.toLowerCase()] ?? Number(chosenCategory);
|
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function importRowFor(
|
function importRowFor(
|
||||||
product: CatalogueProduct,
|
product: CatalogueProduct,
|
||||||
categoryid: number,
|
subcategoryid: number,
|
||||||
): ImportCatalogueProductRequest {
|
): ImportCatalogueProductRequest {
|
||||||
return {
|
return {
|
||||||
tenantid: tenantid as number,
|
tenantid: tenantid as number,
|
||||||
locationid: locationid as number,
|
locationid: locationid as number,
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
catalogueid: product.id,
|
catalogueid: product.id,
|
||||||
/* This used to read `Number(importInto) || 0`, defended as
|
/* ALWAYS 2, and this is not a placeholder.
|
||||||
"uncategorised beats wrongly categorised — an unclassified product is
|
`getproductsbysubcategory` filters on `categoryid = 2` — the value the
|
||||||
visibly unfinished". It is not visible anywhere: the console lists it
|
app sends — so a per-product categoryid does not label the product, it
|
||||||
like any other product and only the customer app knows it is gone.
|
deletes it from the app's view. What the shopper actually reads as the
|
||||||
A wrongly filed product is at least findable and fixable; one under
|
aisle heading is the subcategory below, which until now was 0 on every
|
||||||
category 0 is returned by no query the app makes. */
|
product on the platform: one bucket, "Uncategorized", holding the shop.
|
||||||
categoryid,
|
See `appAisle.ts` for the endpoint this is measured against. */
|
||||||
subcategoryid: 0,
|
categoryid: APP_BROWSE_CATEGORY,
|
||||||
|
subcategoryid,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
stocktype: 'in',
|
stocktype: 'in',
|
||||||
status: 'Draft',
|
status: 'Draft',
|
||||||
@@ -408,12 +323,13 @@ export function CatalogueBrowser({
|
|||||||
|
|
||||||
async function importDirect(product: CatalogueProduct) {
|
async function importDirect(product: CatalogueProduct) {
|
||||||
if (!tenantid || !locationid) return;
|
if (!tenantid || !locationid) return;
|
||||||
// Guarded rather than defaulted to 0 — see `hasNoCategory`.
|
|
||||||
if (chosenCategory === '') return;
|
|
||||||
const key = catalogueKey(product);
|
const key = catalogueKey(product);
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
try {
|
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));
|
setJustImported((set) => new Set(set).add(key));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
@@ -423,17 +339,6 @@ export function CatalogueBrowser({
|
|||||||
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
|
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
|
||||||
const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason);
|
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() {
|
function clearFilters() {
|
||||||
setBrand('');
|
setBrand('');
|
||||||
@@ -453,27 +358,26 @@ export function CatalogueBrowser({
|
|||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
{scope}
|
{scope}
|
||||||
|
|
||||||
{/* Pulled up onto the tab row's line and right-aligned, so search sits
|
{/* Search, filters and the rail toggle, on their own line.
|
||||||
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.
|
|
||||||
|
|
||||||
The pull-up is a CSS offset rather than the controls being rendered
|
They used to be PULLED UP onto the page's tab row by a -42px offset
|
||||||
inside the tab row, because the tab row belongs to the page
|
(`alignWithTabs`, `.catalogue-controls`), because that row looked
|
||||||
(`InventoryPage`) and these controls belong to the browser's own
|
mostly empty to the right. It is not empty any more — Inventory's tab
|
||||||
state. Rendering them there would mean lifting `keyword` and the rail
|
row carries "Upload sheet" at its right end — and the offset does not
|
||||||
toggle into two different pages to keep one row tidy. It reverts to
|
move a narrow control into a gap, it lays a FULL-WIDTH row on top of
|
||||||
its own line below 900px, where the tabs need the width.
|
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
|
It stays above both columns rather than inside the rail: the search
|
||||||
narrows the whole catalogue, and the rail only lists brands. */}
|
narrows the whole catalogue, and the rail only lists brands. */}
|
||||||
<HStack
|
<HStack gap={1} align="center" wrap="wrap" justify="end">
|
||||||
gap={1}
|
|
||||||
align="center"
|
|
||||||
wrap="wrap"
|
|
||||||
justify="end"
|
|
||||||
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
|
|
||||||
>
|
|
||||||
{activeFilters.map((filter) => (
|
{activeFilters.map((filter) => (
|
||||||
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
|
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
|
||||||
))}
|
))}
|
||||||
@@ -603,7 +507,7 @@ export function CatalogueBrowser({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={importMany.isPending}
|
isLoading={importMany.isPending}
|
||||||
isDisabled={importMany.isPending || hasNoCategory}
|
isDisabled={importMany.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
importMany.mutate(
|
importMany.mutate(
|
||||||
rows.filter((product) => selection.has(product.id)),
|
rows.filter((product) => selection.has(product.id)),
|
||||||
@@ -665,10 +569,7 @@ export function CatalogueBrowser({
|
|||||||
isImported={importedKeys.has(catalogueKey(open))}
|
isImported={importedKeys.has(catalogueKey(open))}
|
||||||
isBusy={busy === catalogueKey(open)}
|
isBusy={busy === catalogueKey(open)}
|
||||||
actionLabel={actionLabel}
|
actionLabel={actionLabel}
|
||||||
{...(showCategoryPicker
|
{...(canImport && !isReadOnly
|
||||||
? { categoryOptions, categoryid: pickerValue, onCategoryChange: setImportInto }
|
|
||||||
: {})}
|
|
||||||
{...(canImport && !isReadOnly && !hasNoCategory
|
|
||||||
? {
|
? {
|
||||||
onImport: () => {
|
onImport: () => {
|
||||||
const product = open;
|
const product = open;
|
||||||
@@ -678,14 +579,12 @@ export function CatalogueBrowser({
|
|||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
{...(blockedReason
|
{...(blockedReason
|
||||||
? // The caller's reason wins: "no outlet selected" outranks "no
|
? // The only reason left is the caller's — "no outlet selected".
|
||||||
// category", because you cannot pick one without the other.
|
// "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 }
|
{ 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)}
|
onClose={() => setOpen(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
import { Lightbox } from '@astryxdesign/core/Lightbox';
|
import { Lightbox } from '@astryxdesign/core/Lightbox';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
|
||||||
import { Check, DownloadCloud, ImageOff } from 'lucide-react';
|
import { Check, DownloadCloud, ImageOff } from 'lucide-react';
|
||||||
import type { CatalogueProduct } from '@/api/types';
|
import type { CatalogueProduct } from '@/api/types';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
} from '@/features/store-admin/drawerKit';
|
} from '@/features/store-admin/drawerKit';
|
||||||
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
||||||
|
import { aisleForCategory } from '@/features/store-admin/appAisle';
|
||||||
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
|
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,10 +40,6 @@ export interface CatalogueDetailDrawerProps {
|
|||||||
isImported: boolean;
|
isImported: boolean;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
actionLabel?: string;
|
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. */
|
/** Absent when the caller has nowhere to import to yet. */
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
/** Shown in place of the action when importing is unavailable. */
|
/** Shown in place of the action when importing is unavailable. */
|
||||||
@@ -56,14 +52,16 @@ export function CatalogueDetailDrawer({
|
|||||||
isImported,
|
isImported,
|
||||||
isBusy,
|
isBusy,
|
||||||
actionLabel = 'Add to my products',
|
actionLabel = 'Add to my products',
|
||||||
categoryOptions,
|
|
||||||
categoryid = '',
|
|
||||||
onCategoryChange,
|
|
||||||
onImport,
|
onImport,
|
||||||
blockedReason,
|
blockedReason,
|
||||||
onClose,
|
onClose,
|
||||||
}: CatalogueDetailDrawerProps) {
|
}: 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({
|
const suggested = categoryForCatalogueProduct({
|
||||||
catalogueCategory: product.category,
|
catalogueCategory: product.category,
|
||||||
title: product.product_name,
|
title: product.product_name,
|
||||||
@@ -71,6 +69,9 @@ export function CatalogueDetailDrawer({
|
|||||||
packSize: product.size ?? '',
|
packSize: product.size ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* The app groups by subcategory, not category — see `appAisle.ts`. */
|
||||||
|
const aisle = aisleForCategory(suggested.category);
|
||||||
|
|
||||||
const images = product.images ?? [];
|
const images = product.images ?? [];
|
||||||
const [heroAt, setHeroAt] = useState(0);
|
const [heroAt, setHeroAt] = useState(0);
|
||||||
const [isZoomed, setIsZoomed] = useState(false);
|
const [isZoomed, setIsZoomed] = useState(false);
|
||||||
@@ -255,7 +256,7 @@ export function CatalogueDetailDrawer({
|
|||||||
|
|
||||||
{/* The action, last, because everything above is what the decision is
|
{/* 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
|
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 ? (
|
{isImported ? (
|
||||||
<Banner
|
<Banner
|
||||||
status="success"
|
status="success"
|
||||||
@@ -268,36 +269,37 @@ export function CatalogueDetailDrawer({
|
|||||||
) : onImport ? (
|
) : onImport ? (
|
||||||
<Section title="Import into my products">
|
<Section title="Import into my products">
|
||||||
<DrawerCard>
|
<DrawerCard>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 14 }}>
|
<Row
|
||||||
{categoryOptions ? (
|
label="Filed under"
|
||||||
<Selector
|
value={<Badge label={suggested.category} colour="var(--color-brand)" />}
|
||||||
label="Category"
|
/>
|
||||||
size="sm"
|
{/* The heading a shopper actually reads. The catalogue's 31
|
||||||
options={categoryOptions}
|
categories are finer than the app's ten aisles, so the two are
|
||||||
value={categoryid}
|
shown side by side rather than one standing in for the other —
|
||||||
onChange={(value) => onCategoryChange?.(value)}
|
and a product with no aisle is told so here, not discovered
|
||||||
/* No "leave uncategorised" placeholder, and the advice that
|
missing from the app later. */}
|
||||||
used to sit here — "uncategorised beats wrongly categorised"
|
<Row
|
||||||
— is gone because it is false. A product filed under
|
label="Shown in the app under"
|
||||||
category 0 is returned by NO query the customer app makes:
|
value={
|
||||||
its endpoint rejects categoryid 0 outright. Seven products
|
aisle ? (
|
||||||
across three tenants sat invisible that way, six outlets
|
<Badge label={aisle} colour="#0f8a5f" />
|
||||||
showing shoppers an empty shop, while the console listed
|
) : (
|
||||||
their stock as normal. A wrongly filed product is at least
|
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
||||||
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}
|
<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)' }}>
|
<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
|
{suggested.rule === 'catalogue'
|
||||||
price and publish it.
|
? `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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DrawerCard>
|
</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.
|
* 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
|
* 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
|
* category 2 and nothing else, and category 2 carries the ten real retail
|
||||||
* subcategories.
|
* 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 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.
|
* Who an uploaded sheet is for.
|
||||||
*
|
*
|
||||||
* A sheet becomes stock on one shelf, so the upload has to name a tenant, a
|
* A sheet becomes stock on one shelf, so the upload has to name a tenant and a
|
||||||
* branch and a category before it can do anything — and where those come from
|
* branch before it can do anything — and where those come from depends entirely
|
||||||
* depends entirely on who is signed in:
|
* on who is signed in:
|
||||||
*
|
*
|
||||||
* - A **Nearle Admin** works across every merchant. Nothing in their session
|
* - A **Nearle Admin** works across every merchant. Nothing in their session
|
||||||
* says which one, so they are ASKED, and the upload stays blocked until
|
* 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
|
* 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
|
* held per outlet, so a sheet uploaded against the wrong branch loads a shelf
|
||||||
* nobody is standing at.
|
* 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 { useEffect, useMemo } from 'react';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { useTenantCategories, useTenantLocations, useTenants } from '@/queries/hooks';
|
import { useTenantLocations, useTenants } from '@/queries/hooks';
|
||||||
import { categoryOptionsFor } from '@/features/catalogue/tenantCategories';
|
|
||||||
|
|
||||||
/** Everything an upload needs before it can be turned into stock. */
|
/** Everything an upload needs before it can be turned into stock. */
|
||||||
export interface ImportTarget {
|
export interface ImportTarget {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
/** Used for any sheet row that names no category of its own. */
|
|
||||||
categoryid: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportScopeProps {
|
export interface ImportScopeProps {
|
||||||
@@ -48,7 +54,6 @@ export interface ImportScopeProps {
|
|||||||
export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) {
|
export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) {
|
||||||
const tenants = useTenants({ pageno: 1, pagesize: 200 });
|
const tenants = useTenants({ pageno: 1, pagesize: 200 });
|
||||||
const locations = useTenantLocations(value.tenantid);
|
const locations = useTenantLocations(value.tenantid);
|
||||||
const categories = useTenantCategories(value.tenantid);
|
|
||||||
|
|
||||||
const tenantOptions = useMemo(
|
const tenantOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -70,39 +75,11 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
|
|||||||
[locations.data],
|
[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.
|
* 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
|
* 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,
|
* behind a dropdown with one entry.
|
||||||
* where `gettenantcategories` reports one usable value for every tenant seen
|
|
||||||
* so far.
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value.tenantid) return;
|
if (!value.tenantid) return;
|
||||||
@@ -111,13 +88,6 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
|
|||||||
}
|
}
|
||||||
}, [branchOptions, value, onChange]);
|
}, [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 (
|
return (
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
{!isTenantFixed ? (
|
{!isTenantFixed ? (
|
||||||
@@ -129,9 +99,9 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
|
|||||||
placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
|
placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
|
||||||
description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set."
|
description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set."
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
// Changing the merchant clears the branch and category with it.
|
// Changing the merchant clears the branch with it. Keeping it
|
||||||
// Keeping them would leave another tenant's branch id attached to
|
// would leave another tenant's branch id attached to this one — an
|
||||||
// this one — an id that is valid-looking and wrong.
|
// id that is valid-looking and wrong.
|
||||||
onChange({ tenantid: Number(next) || undefined })
|
onChange({ tenantid: Number(next) || undefined })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -154,40 +124,18 @@ export function ImportScope({ value, onChange, isTenantFixed = false }: ImportSc
|
|||||||
onChange={(next) => onChange({ ...value, locationid: Number(next) || undefined })}
|
onChange={(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 ? (
|
{value.tenantid && branchOptions.length === 0 && !locations.isLoading ? (
|
||||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
<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.
|
This merchant has no branches, so there is nowhere for stock to land. Create one first.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : 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>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when every field an import needs has been answered. */
|
/** True when every field an import needs has been answered. */
|
||||||
export function isTargetComplete(value: Partial<ImportTarget>): value is ImportTarget {
|
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. */
|
/** 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 = [
|
const missing = [
|
||||||
!value.tenantid ? 'a merchant' : null,
|
!value.tenantid ? 'a merchant' : null,
|
||||||
!value.locationid ? 'a branch' : null,
|
!value.locationid ? 'a branch' : null,
|
||||||
!value.categoryid ? 'a category' : null,
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
if (missing.length === 0) return null;
|
if (missing.length === 0) return null;
|
||||||
return `Choose ${missing.join(', ')} before uploading — a sheet becomes stock on one shelf, and this is which.`;
|
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> {
|
interface PreviewRow extends Record<string, unknown> {
|
||||||
productname: string;
|
productname: string;
|
||||||
productsku: string;
|
productsku: string;
|
||||||
categoryid: number;
|
category: string;
|
||||||
retailprice: number;
|
retailprice: number;
|
||||||
productcost: number;
|
productcost: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -211,7 +211,9 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
await uploadsApi.record({
|
await uploadsApi.record({
|
||||||
tenantid: target.tenantid,
|
tenantid: target.tenantid,
|
||||||
locationid: target.locationid,
|
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,
|
batchid: submitted.batch_id,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
sender: submitted.submitted_by ?? '',
|
sender: submitted.submitted_by ?? '',
|
||||||
@@ -315,9 +317,20 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
/* No Category column. The console no longer reads one from the sheet — it
|
/* The category, shown because nobody chooses it any more.
|
||||||
is chosen once above and applied to every row — so a column here would
|
It is worked out per row before the upload, so this column is the one
|
||||||
show 0 on every line and invite somebody to "fix" it in the file. */
|
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',
|
key: 'retailprice',
|
||||||
header: 'Retail',
|
header: 'Retail',
|
||||||
@@ -355,7 +368,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
|||||||
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({
|
const preview: PreviewRow[] = (parsed?.rows ?? []).map((row: SheetProductRow) => ({
|
||||||
productname: row.productname,
|
productname: row.productname,
|
||||||
productsku: row.productsku,
|
productsku: row.productsku,
|
||||||
categoryid: row.categoryid,
|
category: row.category ?? '',
|
||||||
retailprice: row.retailprice,
|
retailprice: row.retailprice,
|
||||||
productcost: row.productcost,
|
productcost: row.productcost,
|
||||||
quantity: row.quantity,
|
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
|
and an opening stock are used when present, and a SKU makes the match back to your rows
|
||||||
exact rather than by name.
|
exact rather than by name.
|
||||||
<br />
|
<br />
|
||||||
You do not need a categoryid column — the category chosen above applies to every row
|
You are not asked for a category, and a categoryid column is ignored if you include
|
||||||
that does not name one. Leaving it out is the safer shape: a wrong id in a sheet is
|
one. Each product is filed by the platform's own classification — the sheet's category
|
||||||
accepted silently and hides the product from shoppers, while the picker can only offer
|
column if it has one, then the product registry, then its name — into one of the 31
|
||||||
ids the customer app actually browses.
|
categories the customer app browses, so a mixed sheet no longer lands in a single aisle.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{parseError ? (
|
{parseError ? (
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ test('one call carries the price and the opening stock together', () => {
|
|||||||
const { requests } = buildImportRequests(plan, {
|
const { requests } = buildImportRequests(plan, {
|
||||||
tenantid: 1141,
|
tenantid: 1141,
|
||||||
locationid: 1179,
|
locationid: 1179,
|
||||||
fallbackCategoryId: 2,
|
aisleIds: new Map(),
|
||||||
catalogueIds: ids,
|
catalogueIds: ids,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,17 +110,19 @@ test('one call carries the price and the opening stock together', () => {
|
|||||||
assert.equal(req.locationid, 1179);
|
assert.equal(req.locationid, 1179);
|
||||||
});
|
});
|
||||||
|
|
||||||
// categoryid 0 is the state the customer app rejects outright — it has cost this
|
// categoryid 2 is not a label, it is the value the app FILTERS on — see
|
||||||
// system seven invisible products already.
|
// `appAisle.ts`. A per-product categoryid does not categorise the product, it
|
||||||
test('a blank sheet category falls back to the operator choice, never zero', () => {
|
// removes it from the app's browse entirely.
|
||||||
const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]);
|
test('categoryid is always the one the app asks for', () => {
|
||||||
|
const plan = planOpeningStock([manifest()], [sheetRow({ category: 'Dairy', categoryid: 91 })]);
|
||||||
const { requests } = buildImportRequests(plan, {
|
const { requests } = buildImportRequests(plan, {
|
||||||
tenantid: 1141,
|
tenantid: 1141,
|
||||||
locationid: 1179,
|
locationid: 1179,
|
||||||
fallbackCategoryId: 2,
|
aisleIds: new Map([['dairy, deli & egg', 15]]),
|
||||||
catalogueIds: ids,
|
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.
|
// 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, {
|
const out = buildImportRequests(plan, {
|
||||||
tenantid: 1141,
|
tenantid: 1141,
|
||||||
locationid: 1179,
|
locationid: 1179,
|
||||||
fallbackCategoryId: 2,
|
aisleIds: new Map(),
|
||||||
catalogueIds: ids,
|
catalogueIds: ids,
|
||||||
});
|
});
|
||||||
assert.equal(out.requests.length, 0);
|
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, {
|
const out = buildImportRequests(plan, {
|
||||||
tenantid: 1141,
|
tenantid: 1141,
|
||||||
locationid: 1179,
|
locationid: 1179,
|
||||||
fallbackCategoryId: 2,
|
aisleIds: new Map(),
|
||||||
catalogueIds: new Map(),
|
catalogueIds: new Map(),
|
||||||
});
|
});
|
||||||
assert.equal(out.requests.length, 0);
|
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', () => {
|
test('a fractional or negative opening stock is made sane', () => {
|
||||||
const plan = planOpeningStock([manifest()], [sheetRow({ quantity: -5 })]);
|
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');
|
assert.equal(a.requests[0]?.quantity, 0, 'negative opening stock is not a receipt');
|
||||||
|
|
||||||
const plan2 = planOpeningStock([manifest()], [sheetRow({ quantity: 3.7 })]);
|
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);
|
assert.equal(b.requests[0]?.quantity, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── A sheet with no categoryid column at all ─────────────────────────────── */
|
/* ── A sheet with no categoryid column at all ─────────────────────────────── */
|
||||||
|
|
||||||
// parseProductSheet defaults a missing categoryid to 0, and 0 is the value the
|
// 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 —
|
// customer app rejects outright. It must never reach the request whatever the
|
||||||
// otherwise dropping the column would silently hide every product uploaded.
|
// sheet holds — otherwise dropping the column would hide every product uploaded.
|
||||||
test('a sheet with no category column gets the operator choice on every row', () => {
|
test('a sheet with nothing to classify still never files a row under zero', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', categoryid: 0 }),
|
sheetRow({ productname: 'Amul Butter 100g', productsku: 'A1', categoryid: 0 }),
|
||||||
sheetRow({ productname: 'Amul Ghee 1L', productsku: 'A2', 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, {
|
const { requests } = buildImportRequests(plan, {
|
||||||
tenantid: 1141,
|
tenantid: 1141,
|
||||||
locationid: 1179,
|
locationid: 1179,
|
||||||
fallbackCategoryId: 2,
|
aisleIds: new Map(),
|
||||||
catalogueIds: new Map([
|
catalogueIds: new Map([
|
||||||
['amul_amul_butter_100g', 42],
|
['amul_amul_butter_100g', 42],
|
||||||
['amul_amul_ghee_1l', 43],
|
['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);
|
assert.equal(requests.length, 2);
|
||||||
for (const req of requests) {
|
for (const req of requests) {
|
||||||
assert.equal(req.categoryid, 2, 'never 0 — that is the invisible state');
|
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
|
// A stray subcategoryid in a sheet is honoured only when nothing classified the
|
||||||
// mapping the column, so every row arrives as 0 and the operator answer is the
|
// row — the classification always wins, because a number typed into a column
|
||||||
// only source. This is the guard on that: if a row ever carries a stray value
|
// nobody documents is the least trustworthy thing in the file.
|
||||||
// it is still used, but nothing in the parser produces one any more.
|
test('the classification beats a subcategoryid typed into the sheet', () => {
|
||||||
test('the operator answer applies even when a row somehow carries zero', () => {
|
const plan = planOpeningStock(
|
||||||
const plan = planOpeningStock([manifest()], [sheetRow({ categoryid: 0 })]);
|
[manifest()],
|
||||||
|
[sheetRow({ category: 'Dairy', subcategoryid: 99 })],
|
||||||
|
);
|
||||||
const { requests } = buildImportRequests(plan, {
|
const { requests } = buildImportRequests(plan, {
|
||||||
tenantid: 1141,
|
tenantid: 1141,
|
||||||
locationid: 1179,
|
locationid: 1179,
|
||||||
fallbackCategoryId: 2,
|
aisleIds: new Map([['dairy, deli & egg', 15]]),
|
||||||
catalogueIds: ids,
|
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 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';
|
import type { SheetProductRow } from '@/api/products';
|
||||||
|
|
||||||
/** Where a product's price and opening stock came from. */
|
/** Where a product's price and opening stock came from. */
|
||||||
@@ -148,8 +150,14 @@ export interface ImportRequestRow {
|
|||||||
export interface BuildOptions {
|
export interface BuildOptions {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: 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. */
|
/** `image_id` → the catalogue row id `importcatalogueproduct` addresses. */
|
||||||
catalogueIds: ReadonlyMap<string, number>;
|
catalogueIds: ReadonlyMap<string, number>;
|
||||||
}
|
}
|
||||||
@@ -178,7 +186,7 @@ export interface BuildResult {
|
|||||||
* sent and both come back named.
|
* sent and both come back named.
|
||||||
*/
|
*/
|
||||||
export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult {
|
export function buildImportRequests(plan: StockPlan, options: BuildOptions): BuildResult {
|
||||||
const { tenantid, locationid, fallbackCategoryId, catalogueIds } = options;
|
const { tenantid, locationid, aisleIds, catalogueIds } = options;
|
||||||
|
|
||||||
const requests: ImportRequestRow[] = [];
|
const requests: ImportRequestRow[] = [];
|
||||||
const unresolved: IngestProduct[] = [];
|
const unresolved: IngestProduct[] = [];
|
||||||
@@ -202,11 +210,16 @@ export function buildImportRequests(plan: StockPlan, options: BuildOptions): Bui
|
|||||||
locationid,
|
locationid,
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
catalogueid,
|
catalogueid,
|
||||||
// The sheet's own category when it named one, the operator's choice
|
// ALWAYS 2. `getproductsbysubcategory` filters on `categoryid = 2`, the
|
||||||
// otherwise. Never 0 — a product filed under 0 is invisible to shoppers
|
// value the app sends, so a per-product categoryid removes the product
|
||||||
// and no API could repair it until recently.
|
// from the app rather than labelling it. Never 0 either — that is the
|
||||||
categoryid: Number(row.categoryid) || fallbackCategoryId,
|
// state the browse endpoint rejects outright.
|
||||||
subcategoryid: Number(row.subcategoryid) || 0,
|
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 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.
|
// the product, puts it on this outlet's shelf, and records the receipt.
|
||||||
quantity: Math.max(0, Math.trunc(Number(row.quantity) || 0)),
|
quantity: Math.max(0, Math.trunc(Number(row.quantity) || 0)),
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
import { catalogueApi } from '@/api/catalogue';
|
import { catalogueApi } from '@/api/catalogue';
|
||||||
import { productsApi, type SheetProductRow } from '@/api/products';
|
import { productsApi, type SheetProductRow } from '@/api/products';
|
||||||
import { productsOf, type IngestBatch } from '@/api/ingest';
|
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';
|
import { buildImportRequests, planOpeningStock, type StockPlan } from './openingStock';
|
||||||
|
|
||||||
export interface ShelveTarget {
|
export interface ShelveTarget {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
/** Applied to every row; no sheet column carries one any more. */
|
|
||||||
categoryid: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShelveResult {
|
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, {
|
const { requests, unresolved, unpriced } = buildImportRequests(plan, {
|
||||||
tenantid: target.tenantid,
|
tenantid: target.tenantid,
|
||||||
locationid: target.locationid,
|
locationid: target.locationid,
|
||||||
fallbackCategoryId: target.categoryid,
|
aisleIds,
|
||||||
catalogueIds,
|
catalogueIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export function GlobalCataloguePage() {
|
|||||||
<CatalogueBrowser
|
<CatalogueBrowser
|
||||||
tenantid={undefined}
|
tenantid={undefined}
|
||||||
locationid={undefined}
|
locationid={undefined}
|
||||||
categoryOptions={[]}
|
|
||||||
actionLabel="Add to store"
|
actionLabel="Add to store"
|
||||||
isReadOnly
|
isReadOnly
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTenantCategories } from '@/queries/hooks';
|
|
||||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||||
import { useBranchScope } from './BranchScope';
|
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.
|
* 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 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 (
|
return (
|
||||||
<CatalogueBrowser
|
<CatalogueBrowser
|
||||||
tenantid={tenantid || undefined}
|
tenantid={tenantid || undefined}
|
||||||
locationid={locationid}
|
locationid={locationid}
|
||||||
categoryOptions={categoryOptions}
|
|
||||||
actionLabel="Add to my products"
|
actionLabel="Add to my products"
|
||||||
alignWithTabs
|
|
||||||
{...(!tenantid || !locationid
|
{...(!tenantid || !locationid
|
||||||
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
|
? { 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 { productsApi } from '@/api/products';
|
||||||
import type { Product } from '@/api/types';
|
import type { Product } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useCatalogueProduct } from '@/queries/hooks';
|
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +67,13 @@ export function ProductDrawer({
|
|||||||
const [confirmHide, setConfirmHide] = useState(false);
|
const [confirmHide, setConfirmHide] = useState(false);
|
||||||
const [shot, setShot] = useState(0);
|
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 source = useCatalogueProduct(product.productbrand, product.productsku);
|
||||||
const images = imagesOf(product);
|
const images = imagesOf(product);
|
||||||
const state = stateOf(product);
|
const state = stateOf(product);
|
||||||
@@ -198,10 +205,17 @@ export function ProductDrawer({
|
|||||||
value={isPublished(product) ? 'Yes' : 'Not yet'}
|
value={isPublished(product) ? 'Yes' : 'Not yet'}
|
||||||
{...(isPublished(product) ? { tone: 'success' as const } : {})}
|
{...(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
|
<Row
|
||||||
label="Category"
|
label="Shown in the app under"
|
||||||
value={product.categoryname || product.Subcategoryname || 'Uncategorised'}
|
value={aisleName ?? 'Uncategorized'}
|
||||||
icon={<Tag size={14} />}
|
icon={<Tag size={14} />}
|
||||||
|
{...(aisleName ? {} : { tone: 'muted' as const })}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Unit"
|
label="Unit"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { VStack } from '@astryxdesign/core/VStack';
|
|||||||
import { ImageOff } from 'lucide-react';
|
import { ImageOff } from 'lucide-react';
|
||||||
import type { Product } from '@/api/types';
|
import type { Product } from '@/api/types';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
|
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
import { count, money } from './format';
|
import { count, money } from './format';
|
||||||
import { ProductDrawer } from './ProductDrawer';
|
import { ProductDrawer } from './ProductDrawer';
|
||||||
@@ -71,7 +71,23 @@ export function ProductsPanel({
|
|||||||
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, {
|
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, {
|
||||||
allBranches: true,
|
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.
|
* One list, ordered by what needs doing.
|
||||||
@@ -82,7 +98,7 @@ export function ProductsPanel({
|
|||||||
*/
|
*/
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const filtered = (products.data ?? []).filter(
|
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 };
|
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
@@ -94,13 +110,16 @@ export function ProductsPanel({
|
|||||||
|
|
||||||
const categoryOptions = useMemo(
|
const categoryOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ value: '', label: 'All categories' },
|
{ value: '', label: 'All aisles' },
|
||||||
...(categories.data ?? []).map((entry) => ({
|
// Offered whether or not anything is in it — a shop wanting to know what
|
||||||
value: String(entry.categoryid),
|
// the app cannot place should not have to guess that the option exists.
|
||||||
label: entry.categoryname,
|
{ value: '0', label: 'Uncategorized' },
|
||||||
|
...(aisles.data ?? []).map((entry) => ({
|
||||||
|
value: String(entry.subcatid),
|
||||||
|
label: entry.subcatname,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
[categories.data],
|
[aisles.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,6 +143,7 @@ export function ProductsPanel({
|
|||||||
|
|
||||||
<ProductTable
|
<ProductTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
aisleNames={aisleNames}
|
||||||
queryKey={category}
|
queryKey={category}
|
||||||
isLoading={products.isLoading}
|
isLoading={products.isLoading}
|
||||||
onOpen={setOpen}
|
onOpen={setOpen}
|
||||||
@@ -159,12 +179,15 @@ export function ProductsPanel({
|
|||||||
|
|
||||||
function ProductTable({
|
function ProductTable({
|
||||||
rows,
|
rows,
|
||||||
|
aisleNames,
|
||||||
isLoading,
|
isLoading,
|
||||||
onOpen,
|
onOpen,
|
||||||
onPrice,
|
onPrice,
|
||||||
queryKey,
|
queryKey,
|
||||||
}: {
|
}: {
|
||||||
rows: Product[];
|
rows: Product[];
|
||||||
|
/** Subcategory id to the aisle name the customer app shows it under. */
|
||||||
|
aisleNames: ReadonlyMap<number, string>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onOpen: (product: Product) => void;
|
onOpen: (product: Product) => void;
|
||||||
onPrice: (product: Product) => void;
|
onPrice: (product: Product) => void;
|
||||||
@@ -251,6 +274,7 @@ function ProductTable({
|
|||||||
<Row
|
<Row
|
||||||
key={product.productid}
|
key={product.productid}
|
||||||
product={product}
|
product={product}
|
||||||
|
aisleNames={aisleNames}
|
||||||
onOpen={() => onOpen(product)}
|
onOpen={() => onOpen(product)}
|
||||||
onPrice={() => onPrice(product)}
|
onPrice={() => onPrice(product)}
|
||||||
/>
|
/>
|
||||||
@@ -276,10 +300,12 @@ function ProductTable({
|
|||||||
*/
|
*/
|
||||||
function Row({
|
function Row({
|
||||||
product,
|
product,
|
||||||
|
aisleNames,
|
||||||
onOpen,
|
onOpen,
|
||||||
onPrice,
|
onPrice,
|
||||||
}: {
|
}: {
|
||||||
product: Product;
|
product: Product;
|
||||||
|
aisleNames: ReadonlyMap<number, string>;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onPrice: () => void;
|
onPrice: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -337,7 +363,13 @@ function Row({
|
|||||||
whiteSpace: 'nowrap',
|
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>
|
</span>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
<Td isMuted>
|
||||||
|
|||||||
@@ -296,9 +296,10 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||||
Required columns: productname, productsku, categoryid, retailprice, productcost. The
|
Required columns: productname, productsku, retailprice, productcost. A categoryid
|
||||||
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
|
column is not one of them and is ignored if present — the Category column below shows
|
||||||
the file.
|
what each row was classified as, and that name is exchanged for your own category id
|
||||||
|
when the products are created.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{parseError ? (
|
{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 { PageHeader } from '@/components/PageHeader';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import {
|
import {
|
||||||
|
useAppAisles,
|
||||||
useLocationProducts,
|
useLocationProducts,
|
||||||
useStockRequests,
|
useStockRequests,
|
||||||
useStockStatement,
|
useStockStatement,
|
||||||
useTenantCategories,
|
|
||||||
} from '@/queries/hooks';
|
} from '@/queries/hooks';
|
||||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
|
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
|
||||||
@@ -66,7 +66,10 @@ export function StoreProductsPage() {
|
|||||||
const [askingMany, setAskingMany] = useState<Product[] | null>(null);
|
const [askingMany, setAskingMany] = useState<Product[] | null>(null);
|
||||||
|
|
||||||
const products = useLocationProducts(tenantid || undefined, locationid, 0);
|
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 });
|
const statement = useStockStatement(tenantid || undefined, locationid, { pagesize: 200 });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,7 +97,7 @@ export function StoreProductsPage() {
|
|||||||
const term = keyword.trim().toLowerCase();
|
const term = keyword.trim().toLowerCase();
|
||||||
return (products.data ?? [])
|
return (products.data ?? [])
|
||||||
.filter((product) => isPublished(product) && effectivePrice(product) > 0)
|
.filter((product) => isPublished(product) && effectivePrice(product) > 0)
|
||||||
.filter((product) => !category || String(product.categoryid ?? '') === category)
|
.filter((product) => !category || String(product.subcategoryid ?? 0) === category)
|
||||||
.filter((product) =>
|
.filter((product) =>
|
||||||
term === ''
|
term === ''
|
||||||
? true
|
? true
|
||||||
@@ -130,13 +133,14 @@ export function StoreProductsPage() {
|
|||||||
|
|
||||||
const categoryOptions = useMemo(
|
const categoryOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ value: '', label: 'All categories' },
|
{ value: '', label: 'All aisles' },
|
||||||
...(categories.data ?? []).map((entry) => ({
|
{ value: '0', label: 'Uncategorized' },
|
||||||
value: String(entry.categoryid),
|
...(aisles.data ?? []).map((entry) => ({
|
||||||
label: entry.categoryname,
|
value: String(entry.subcatid),
|
||||||
|
label: entry.subcatname,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
[categories.data],
|
[aisles.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -531,14 +531,11 @@ function ShelveAction({
|
|||||||
const result = await shelveBatch(
|
const result = await shelveBatch(
|
||||||
batch,
|
batch,
|
||||||
rows,
|
rows,
|
||||||
{
|
// No category: `shelveBatch` classifies every row itself. The receipt
|
||||||
tenantid: receipt.tenantid,
|
// still carries the column an older upload wrote, and it is deliberately
|
||||||
locationid: receipt.locationid,
|
// not read — one id applied to a whole sheet is what filed a shop's
|
||||||
// The category chosen at upload time, kept on the receipt precisely
|
// entire range under a single aisle.
|
||||||
// so this step does not have to guess it later. A product filed
|
{ tenantid: receipt.tenantid, locationid: receipt.locationid },
|
||||||
// outside the one the app browses is invisible to shoppers.
|
|
||||||
categoryid: receipt.categoryid,
|
|
||||||
},
|
|
||||||
receipt.filename || undefined,
|
receipt.filename || undefined,
|
||||||
);
|
);
|
||||||
await uploadsApi.markShelved({
|
await uploadsApi.markShelved({
|
||||||
|
|||||||
@@ -665,20 +665,6 @@ main {
|
|||||||
orange would have made one page look like a different product.
|
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 {
|
.catalogue-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { stockApi, type StockRequestQuery } from '@/api/stock';
|
|||||||
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
|
import { tenantsApi, utilsApi, type TenantListQuery } from '@/api/tenants';
|
||||||
import { customersApi, type CustomerQuery } from '@/api/customers';
|
import { customersApi, type CustomerQuery } from '@/api/customers';
|
||||||
import { uploadsApi } from '@/api/uploads';
|
import { uploadsApi } from '@/api/uploads';
|
||||||
|
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
|
||||||
import { queryKeys } from './keys';
|
import { queryKeys } from './keys';
|
||||||
import { LIVE_REFETCH_MS } from './interval';
|
import { LIVE_REFETCH_MS } from './interval';
|
||||||
|
|
||||||
@@ -138,15 +139,17 @@ export function useImportedRefs(tenantid: number | undefined) {
|
|||||||
/* ── Products ────────────────────────────────────────────────────────────── */
|
/* ── 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
|
* `productsubcategories` for category 2 — platform rows, the same ten for
|
||||||
* rows for live categoryids and hardcoded to `moduleid = 2`.
|
* 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({
|
return useQuery({
|
||||||
queryKey: queryKeys.products.tenantCategories(tenantid ?? 0),
|
queryKey: queryKeys.products.subCategories(tenantid ?? 0, APP_BROWSE_CATEGORY),
|
||||||
queryFn: () => productsApi.tenantCategories(tenantid as number),
|
queryFn: () => productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY),
|
||||||
enabled: typeof tenantid === 'number' && tenantid > 0,
|
enabled: typeof tenantid === 'number' && tenantid > 0,
|
||||||
...stable,
|
...stable,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user