console category
This commit is contained in:
@@ -152,6 +152,15 @@ export const productsApi = {
|
||||
resolveCategories: (tenantid: number, names: string[]) =>
|
||||
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
|
||||
|
||||
/**
|
||||
* Re-files products into different categories, in bulk.
|
||||
*
|
||||
* Scoped by tenant on the server as well as here — a productid is global, so
|
||||
* a wrong id in the list would otherwise move another merchant's product.
|
||||
*/
|
||||
recategorise: (tenantid: number, updates: { productid: number; categoryid: number }[]) =>
|
||||
api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }),
|
||||
|
||||
/** Unlinks from the store. The product row and its order history survive. */
|
||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
||||
|
||||
@@ -211,8 +211,23 @@ export function CatalogueBrowser({
|
||||
const selection = useSelection(selectableIds);
|
||||
|
||||
const importMany = useMutation({
|
||||
mutationFn: (products: CatalogueProduct[]) =>
|
||||
productsApi.importFromCatalogue(products.map(importRowFor)),
|
||||
/*
|
||||
Every distinct category in the batch resolved in ONE call, then each row
|
||||
built with its own id.
|
||||
|
||||
Not `products.map(importRowFor)`: `map` hands the callback the array
|
||||
INDEX as its second argument, which is a number, so passing the row
|
||||
builder directly type-checks perfectly and files the first product under
|
||||
category 0, the second under 1, and so on. It was written that way for a
|
||||
one-argument builder and stayed valid the moment the second argument
|
||||
arrived.
|
||||
*/
|
||||
mutationFn: async (products: CatalogueProduct[]) => {
|
||||
const ids = await categoryIdsFor(products);
|
||||
return productsApi.importFromCatalogue(
|
||||
products.map((product) => importRowFor(product, ids.get(catalogueKey(product)) ?? 0)),
|
||||
);
|
||||
},
|
||||
onSuccess: async (_result, products) => {
|
||||
// Ticked locally as well as refetched: the imported list is a separate
|
||||
// query and the grid would otherwise show them as un-imported until it
|
||||
@@ -268,7 +283,71 @@ export function CatalogueBrowser({
|
||||
* row. Two copies of this object is how a bulk import quietly writes a
|
||||
* different category, or a price where the single one writes none.
|
||||
*/
|
||||
function importRowFor(product: CatalogueProduct): ImportCatalogueProductRequest {
|
||||
/**
|
||||
* The category id an import should use for one catalogue product.
|
||||
*
|
||||
* The catalogue already knows what this is — "Spices & Masalas" sits on the
|
||||
* row — and that answer comes from the same deterministic ladder the
|
||||
* pipeline runs. Discarding it in favour of whatever single category the
|
||||
* tenant happened to have is how an Aachi masala arrived filed under
|
||||
* "Category 2".
|
||||
*
|
||||
* An EXPLICIT choice still wins, which is the ladder's own first rule: if the
|
||||
* operator has touched the picker, that is their answer and it is kept. Until
|
||||
* they do, `importInto` is null and the catalogue's own category is used.
|
||||
*/
|
||||
/**
|
||||
* The same decision for a whole batch, in one round trip.
|
||||
*
|
||||
* A request per product would open one connection per row from a shop's
|
||||
* browser — the mistake the catalogue reconciliation already documents.
|
||||
*/
|
||||
async function categoryIdsFor(products: CatalogueProduct[]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
const fallback = Number(chosenCategory);
|
||||
|
||||
if (importInto !== null && importInto !== '') {
|
||||
for (const product of products) out.set(catalogueKey(product), Number(importInto));
|
||||
return out;
|
||||
}
|
||||
|
||||
const names = [...new Set(products.map((p) => (p.category ?? '').trim()).filter(Boolean))];
|
||||
let resolved: Record<string, number> = {};
|
||||
if (names.length > 0) {
|
||||
try {
|
||||
resolved = (await productsApi.resolveCategories(tenantid as number, names)) ?? {};
|
||||
} catch {
|
||||
resolved = {};
|
||||
}
|
||||
}
|
||||
|
||||
for (const product of products) {
|
||||
const name = (product.category ?? '').trim().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 = (product.category ?? '').trim();
|
||||
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(
|
||||
product: CatalogueProduct,
|
||||
categoryid: number,
|
||||
): ImportCatalogueProductRequest {
|
||||
return {
|
||||
tenantid: tenantid as number,
|
||||
locationid: locationid as number,
|
||||
@@ -280,7 +359,7 @@ export function CatalogueBrowser({
|
||||
like any other product and only the customer app knows it is gone.
|
||||
A wrongly filed product is at least findable and fixable; one under
|
||||
category 0 is returned by no query the app makes. */
|
||||
categoryid: Number(chosenCategory),
|
||||
categoryid,
|
||||
subcategoryid: 0,
|
||||
quantity: 0,
|
||||
stocktype: 'in',
|
||||
@@ -299,7 +378,7 @@ export function CatalogueBrowser({
|
||||
const key = catalogueKey(product);
|
||||
setBusy(key);
|
||||
try {
|
||||
await importOne.mutateAsync(importRowFor(product));
|
||||
await importOne.mutateAsync(importRowFor(product, await categoryIdFor(product)));
|
||||
setJustImported((set) => new Set(set).add(key));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
|
||||
@@ -276,8 +276,12 @@ export function CatalogueDetailDrawer({
|
||||
showing shoppers an empty shop, while the console listed
|
||||
their stock as normal. A wrongly filed product is at least
|
||||
findable and fixable; an unfiled one was neither. */
|
||||
placeholder="Choose a category"
|
||||
description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all."
|
||||
placeholder={product.category ? `Catalogue: ${product.category}` : 'Choose a category'}
|
||||
description={
|
||||
product.category
|
||||
? `Left alone, this is filed under ${product.category} — the catalogue's own category, created for your shop if you do not have it yet. Choose one here to override.`
|
||||
: 'The catalogue does not categorise this one, so pick where it belongs. A product with no category cannot appear in the customer app at all.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
|
||||
Reference in New Issue
Block a user