side drawer details updated
This commit is contained in:
167
src/components/CatalogueDetailPanel.tsx
Normal file
167
src/components/CatalogueDetailPanel.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getCatalogueProductBySku } from '../services/catalogueApi';
|
||||||
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue detail blocks from the global catalogue drawer, for a product
|
||||||
|
* that has already been imported.
|
||||||
|
*
|
||||||
|
* Two problems this solves, and they are separate:
|
||||||
|
*
|
||||||
|
* 1. **Missing fields.** Highlights, nutrition, the FSSAI licence, providers,
|
||||||
|
* price range and size live only in the catalogue; the import copies none of
|
||||||
|
* them onto `products`. The admin and store drawers therefore had nothing to
|
||||||
|
* render no matter how they were written.
|
||||||
|
*
|
||||||
|
* 2. **A packaging panel that disagreed with itself.** `FMCGHoverOverlay`
|
||||||
|
* derives its content from `simpleHash(productId)` and a keyword match on
|
||||||
|
* `category`. The global drawer feeds it the *catalogue id* and the *brand*;
|
||||||
|
* the admin and store drawers fed it the *tenant product id* and the
|
||||||
|
* *category name*. Same product, different seed, different panel — which is
|
||||||
|
* exactly the "not shown as it is in the global catalogue" complaint. Making
|
||||||
|
* those two arguments match is the whole fix, and it only works because the
|
||||||
|
* catalogue row is fetched here.
|
||||||
|
*
|
||||||
|
* Keyed on the bridge the import already stores — `productbrand` + `productsku`
|
||||||
|
* — so it works retroactively for everything imported to date; nothing needs a
|
||||||
|
* migration or a backfill.
|
||||||
|
*
|
||||||
|
* Renders nothing while loading and nothing on failure. A hand-entered product
|
||||||
|
* has no catalogue row and never will, and a drawer that shows an error where a
|
||||||
|
* spec sheet should be is worse than a drawer that simply stops.
|
||||||
|
*/
|
||||||
|
export default function CatalogueDetailPanel({
|
||||||
|
brand,
|
||||||
|
sku,
|
||||||
|
catalogueId,
|
||||||
|
fallbackDescription,
|
||||||
|
}: {
|
||||||
|
brand?: string;
|
||||||
|
sku?: string;
|
||||||
|
/**
|
||||||
|
* `products.catalogueid`. The packaging panel is seeded from this rather than
|
||||||
|
* from the fetched row, so it stays identical to the global drawer's even
|
||||||
|
* when the fetch finds nothing — which happens more than expected: a
|
||||||
|
* re-scrape of the catalogue retires variants (a 100 g pack becomes a 250 g
|
||||||
|
* pack under a new SKU) and every product imported from a retired row can
|
||||||
|
* never be looked up again. Four of seven live products are in that state.
|
||||||
|
*/
|
||||||
|
catalogueId?: number;
|
||||||
|
/**
|
||||||
|
* `products.productdesc`, copied at import. Shown when the catalogue lookup
|
||||||
|
* has no row to offer, so the description survives for a product whose
|
||||||
|
* catalogue entry was deleted or which was never imported at all.
|
||||||
|
*/
|
||||||
|
fallbackDescription?: string;
|
||||||
|
}) {
|
||||||
|
const enabled = Boolean(brand && sku);
|
||||||
|
|
||||||
|
const { data: product } = useQuery({
|
||||||
|
queryKey: ['catalogue-product', brand, sku],
|
||||||
|
queryFn: () => getCatalogueProductBySku(brand!, sku!),
|
||||||
|
enabled,
|
||||||
|
// Catalogue rows are reference data — they change when someone reruns a
|
||||||
|
// scrape, not while an admin has a drawer open.
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = product?.description || fallbackDescription || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||||
|
{description && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1">Description</span>
|
||||||
|
<p className="text-[12.5px] text-slate-600 leading-relaxed">{description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product && (
|
||||||
|
<>
|
||||||
|
{(product.price_range || product.size) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
{product.price_range && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-0.5">Market Price Range</span>
|
||||||
|
<div className="text-lg font-black text-slate-900 font-mono tracking-tight">{product.price_range}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.size && (
|
||||||
|
<span className="px-3 py-1.5 rounded-lg bg-white text-slate-700 text-xs font-extrabold uppercase tracking-wider border border-slate-200 shadow-2xs">
|
||||||
|
Size: {product.size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
{Array.isArray(product.highlights) && product.highlights.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1">Highlights</span>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{product.highlights.map((h: string, i: number) => (
|
||||||
|
<li key={i} className="text-[11.5px] text-slate-600 leading-snug">• {h}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(product.nutrients) && product.nutrients.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-1">Nutrition</span>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{product.nutrients.map((n: string, i: number) => (
|
||||||
|
<li key={i} className="text-[11.5px] text-slate-600 leading-snug">• {n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-5">
|
||||||
|
{product.category && (
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-600 text-[11px] font-bold border border-slate-200">
|
||||||
|
{product.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.product_sku && (
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-600 text-[11px] font-mono border border-slate-200">
|
||||||
|
SKU {product.product_sku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.fssai_license && (
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-600 text-[11px] font-mono border border-slate-200">
|
||||||
|
FSSAI {product.fssai_license}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{Array.isArray(product.providers) && product.providers.length > 0 && (
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-white text-slate-500 text-[11px] font-medium border border-slate-200">
|
||||||
|
Sold on {product.providers.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Retail Packaging Info — same two arguments the global drawer passes,
|
||||||
|
which is what makes the panel come out identical. `showDescription` is
|
||||||
|
off because the block above already carries the full text unclamped. */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
||||||
|
<FMCGHoverOverlay
|
||||||
|
productId={String(catalogueId || product?.id || '')}
|
||||||
|
category={product?.brand ?? brand ?? ''}
|
||||||
|
productName={product?.product_name ?? ''}
|
||||||
|
product={product ?? undefined}
|
||||||
|
showDescription={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,18 @@ interface Props {
|
|||||||
category: string;
|
category: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
product?: any; // We'll pass the full product object here
|
product?: any; // We'll pass the full product object here
|
||||||
|
/**
|
||||||
|
* Let the caller own the description instead.
|
||||||
|
*
|
||||||
|
* The admin drawer renders the full text in its own block above this panel,
|
||||||
|
* the way the global catalogue drawer does. Without this the description
|
||||||
|
* appeared twice — once in full, once clamped to four lines here. Defaults
|
||||||
|
* to true so every existing caller renders exactly as before.
|
||||||
|
*/
|
||||||
|
showDescription?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FMCGHoverOverlay({ productId, category, productName, product }: Props) {
|
export default function FMCGHoverOverlay({ productId, category, productName, product, showDescription = true }: Props) {
|
||||||
const details = generateFMCGDetails(productId, category);
|
const details = generateFMCGDetails(productId, category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +42,7 @@ export default function FMCGHoverOverlay({ productId, category, productName, pro
|
|||||||
|
|
||||||
{/* 2. Ingredients & Legal (Back Panel) */}
|
{/* 2. Ingredients & Legal (Back Panel) */}
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
{product?.description && (
|
{showDescription && product?.description && (
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||||
<Info size={10} /> Description
|
<Info size={10} /> Description
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import BulkCartDrawer from './BulkCartDrawer';
|
|||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
|
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||||
import CatalogueBrowser from './CatalogueBrowser';
|
import CatalogueBrowser from './CatalogueBrowser';
|
||||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
@@ -222,6 +223,15 @@ export default function InventoryView({
|
|||||||
sku: item.sku || `SKU-${item.productid}`,
|
sku: item.sku || `SKU-${item.productid}`,
|
||||||
image: item.image,
|
image: item.image,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
|
// The drawer shows this the way the global catalogue drawer does.
|
||||||
|
// Only this branch has it — the stock statement carries no
|
||||||
|
// description at all — so the `else` below backfills the rows that
|
||||||
|
// arrived through allStoreRows instead.
|
||||||
|
description: item.description,
|
||||||
|
// Bridge key to the catalogue record the drawer's spec sheet needs.
|
||||||
|
brand: item.brand,
|
||||||
|
catalogueSku: item.sku,
|
||||||
|
catalogueId: item.catalogueId,
|
||||||
price: item.price,
|
price: item.price,
|
||||||
unitsSold: 0,
|
unitsSold: 0,
|
||||||
revenue: 0,
|
revenue: 0,
|
||||||
@@ -239,6 +249,12 @@ export default function InventoryView({
|
|||||||
const existing = byId.get(id)!;
|
const existing = byId.get(id)!;
|
||||||
existing.price = item.price || existing.price;
|
existing.price = item.price || existing.price;
|
||||||
existing.verified = existing.verified || item.price > 0;
|
existing.verified = existing.verified || item.price > 0;
|
||||||
|
// The stock-statement path supplies none of these, so a product that
|
||||||
|
// arrived that way gets them here or not at all.
|
||||||
|
if (!existing.description && item.description) existing.description = item.description;
|
||||||
|
if (!existing.brand && item.brand) existing.brand = item.brand;
|
||||||
|
if (!existing.catalogueSku && item.sku) existing.catalogueSku = item.sku;
|
||||||
|
if (!existing.catalogueId && item.catalogueId) existing.catalogueId = item.catalogueId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1102,10 +1118,14 @@ export default function InventoryView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
{/* The full catalogue spec sheet, identical to the global
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
catalogue drawer. Fetches the source row on open. */}
|
||||||
<FMCGHoverOverlay productId={selectedAdminProduct.id} category={selectedAdminProduct.category} productName={selectedAdminProduct.name} product={selectedAdminProduct} />
|
<CatalogueDetailPanel
|
||||||
</div>
|
brand={selectedAdminProduct.brand}
|
||||||
|
sku={selectedAdminProduct.catalogueSku}
|
||||||
|
catalogueId={selectedAdminProduct.catalogueId}
|
||||||
|
fallbackDescription={selectedAdminProduct.description}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import AwaitingApi from './AwaitingApi';
|
|||||||
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||||
import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
|
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||||
|
|
||||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||||
@@ -129,6 +130,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
// gallery falls back from to `image` above.
|
// gallery falls back from to `image` above.
|
||||||
images: it.images ?? [],
|
images: it.images ?? [],
|
||||||
category: it.category || 'General',
|
category: it.category || 'General',
|
||||||
|
// Carried for the same reason as `images` above — the drawer shows it.
|
||||||
|
description: it.description || '',
|
||||||
|
// Bridge key to the catalogue row the drawer's spec sheet comes from.
|
||||||
|
// `it.sku` and not the `SKU-<id>` placeholder below: the lookup needs
|
||||||
|
// the real catalogue SKU or it finds nothing.
|
||||||
|
brand: it.brand || '',
|
||||||
|
catalogueSku: it.sku || '',
|
||||||
|
catalogueId: it.catalogueId || 0,
|
||||||
price: it.price,
|
price: it.price,
|
||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
adminQty: it.qty,
|
adminQty: it.qty,
|
||||||
@@ -1038,11 +1047,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Retail Packaging Info */}
|
{/* The full catalogue spec sheet, identical to the global
|
||||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
catalogue drawer. Fetches the source row on open. */}
|
||||||
<h4 className="text-sm font-semibold text-slate-800 mb-3">Retail Packaging Info</h4>
|
<CatalogueDetailPanel
|
||||||
<FMCGHoverOverlay productId={selectedProduct.id} category={selectedProduct.category} productName={selectedProduct.name} />
|
brand={selectedProduct.brand}
|
||||||
</div>
|
sku={selectedProduct.catalogueSku}
|
||||||
|
catalogueId={selectedProduct.catalogueId}
|
||||||
|
fallbackDescription={selectedProduct.description}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ export async function getCatalogueProducts(opts: {
|
|||||||
return { products: items, total };
|
return { products: items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one catalogue row an imported product came from.
|
||||||
|
*
|
||||||
|
* The import copies seven fields onto `products` and leaves the other twelve —
|
||||||
|
* highlights, nutrition, FSSAI licence, providers, price range, size — in the
|
||||||
|
* catalogue, which is why the admin and store drawers could never show what
|
||||||
|
* the global drawer shows. Rather than widen the products table and backfill
|
||||||
|
* six thousand rows, the drawer fetches the source row on open, keyed on the
|
||||||
|
* bridge the import already stores: `productbrand` + `productsku`.
|
||||||
|
*
|
||||||
|
* Returns null for a product typed in by hand, or one whose catalogue row has
|
||||||
|
* since been removed — the caller renders nothing rather than an error.
|
||||||
|
*/
|
||||||
|
export async function getCatalogueProductBySku(brand: string, sku: string): Promise<CatalogueProduct | null> {
|
||||||
|
if (!brand || !sku) return null;
|
||||||
|
const url = new URL(`${API_BASE}/catalogue/getproduct`);
|
||||||
|
url.searchParams.set("brand", brand);
|
||||||
|
url.searchParams.set("sku", sku);
|
||||||
|
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
// This endpoint answers 200 with a `code` in the body for the not-found and
|
||||||
|
// unknown-brand cases, so the HTTP status alone cannot be trusted here.
|
||||||
|
if (!json || json.code !== 200) return null;
|
||||||
|
return (json.details || json.data || null) as CatalogueProduct | null;
|
||||||
|
}
|
||||||
|
|
||||||
// brand omitted → imported refs across every brand.
|
// brand omitted → imported refs across every brand.
|
||||||
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||||
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
|
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
|
||||||
|
|||||||
@@ -44,6 +44,28 @@ export interface StoreCatalogueItem {
|
|||||||
*/
|
*/
|
||||||
images: string[];
|
images: string[];
|
||||||
category: string;
|
category: string;
|
||||||
|
/**
|
||||||
|
* The marketing copy the import copied off the global catalogue.
|
||||||
|
*
|
||||||
|
* `products.productdesc` has been filled since the import first ran, but no
|
||||||
|
* mapper carried it, so the admin and store drawers had nothing to show
|
||||||
|
* while the global drawer — reading the catalogue row directly — showed the
|
||||||
|
* full text. Empty for a product typed in by hand rather than imported.
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* The catalogue brand this product was imported from (`products.brand`,
|
||||||
|
* returned as `productbrand`). Half of the bridge key the detail drawer uses
|
||||||
|
* to fetch the full catalogue record; empty for a hand-entered product.
|
||||||
|
*/
|
||||||
|
brand?: string;
|
||||||
|
/**
|
||||||
|
* `products.catalogueid` — the id of the global-catalogue row this was
|
||||||
|
* imported from, and the value the global drawer seeds the packaging panel
|
||||||
|
* with. Kept separate from the fetched record because it survives a
|
||||||
|
* re-scrape that removed the source row, which the SKU does not.
|
||||||
|
*/
|
||||||
|
catalogueId?: number;
|
||||||
sku?: string;
|
sku?: string;
|
||||||
price: number;
|
price: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -155,6 +177,9 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi
|
|||||||
images: productImages(r),
|
images: productImages(r),
|
||||||
publishedat: String(r.publishedat ?? ''),
|
publishedat: String(r.publishedat ?? ''),
|
||||||
category: String(r.category || r.categoryname || 'General'),
|
category: String(r.category || r.categoryname || 'General'),
|
||||||
|
description: String(r.productdesc || r.description || ''),
|
||||||
|
brand: String(r.productbrand || r.brand || ''),
|
||||||
|
catalogueId: Number(r.catalogueid || 0),
|
||||||
// The row's columns are `productsku` / `productunit` — the bare `sku` /
|
// The row's columns are `productsku` / `productunit` — the bare `sku` /
|
||||||
// `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002"
|
// `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002"
|
||||||
// rendered as "SKU-7075" and a 100g unit rendered as "Pc".
|
// rendered as "SKU-7075" and a 100g unit rendered as "Pc".
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export interface ProductMatrixItem {
|
|||||||
nutrients?: string[];
|
nutrients?: string[];
|
||||||
providers?: string[];
|
providers?: string[];
|
||||||
brand?: string;
|
brand?: string;
|
||||||
|
/**
|
||||||
|
* The real catalogue SKU, kept apart from `sku` because that field holds a
|
||||||
|
* synthesised `SKU-<productid>` for any row built from the stock statement.
|
||||||
|
* Paired with `brand` it fetches the product's catalogue record.
|
||||||
|
*/
|
||||||
|
catalogueSku?: string;
|
||||||
|
/** `products.catalogueid` — seeds the retail packaging panel. */
|
||||||
|
catalogueId?: number;
|
||||||
priceRange?: string;
|
priceRange?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user