diff --git a/src/components/CatalogueDetailPanel.tsx b/src/components/CatalogueDetailPanel.tsx
new file mode 100644
index 0000000..553112d
--- /dev/null
+++ b/src/components/CatalogueDetailPanel.tsx
@@ -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 (
+
+ {description && (
+
+
Description
+
{description}
+
+ )}
+
+ {product && (
+ <>
+ {(product.price_range || product.size) && (
+
+ {product.price_range && (
+
+
Market Price Range
+
{product.price_range}
+
+ )}
+ {product.size && (
+
+ Size: {product.size}
+
+ )}
+
+ )}
+
+
+ {Array.isArray(product.highlights) && product.highlights.length > 0 && (
+
+
Highlights
+
+ {product.highlights.map((h: string, i: number) => (
+ - • {h}
+ ))}
+
+
+ )}
+ {Array.isArray(product.nutrients) && product.nutrients.length > 0 && (
+
+
Nutrition
+
+ {product.nutrients.map((n: string, i: number) => (
+ - • {n}
+ ))}
+
+
+ )}
+
+
+
+ {product.category && (
+
+ {product.category}
+
+ )}
+ {product.product_sku && (
+
+ SKU {product.product_sku}
+
+ )}
+ {product.fssai_license && (
+
+ FSSAI {product.fssai_license}
+
+ )}
+ {Array.isArray(product.providers) && product.providers.length > 0 && (
+
+ Sold on {product.providers.join(', ')}
+
+ )}
+
+ >
+ )}
+
+ {/* 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. */}
+
+
Retail Packaging Info
+
+
+
+ );
+}
diff --git a/src/components/FMCGHoverOverlay.tsx b/src/components/FMCGHoverOverlay.tsx
index 1a0735a..a684de1 100644
--- a/src/components/FMCGHoverOverlay.tsx
+++ b/src/components/FMCGHoverOverlay.tsx
@@ -7,9 +7,18 @@ interface Props {
category: string;
productName: string;
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);
return (
@@ -33,7 +42,7 @@ export default function FMCGHoverOverlay({ productId, category, productName, pro
{/* 2. Ingredients & Legal (Back Panel) */}
- {product?.description && (
+ {showDescription && product?.description && (
Description
diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx
index e09a3f9..39d0c84 100644
--- a/src/components/InventoryView.tsx
+++ b/src/components/InventoryView.tsx
@@ -57,6 +57,7 @@ import BulkCartDrawer from './BulkCartDrawer';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
+import CatalogueDetailPanel from './CatalogueDetailPanel';
import CatalogueBrowser from './CatalogueBrowser';
import OfflineSalesUpload from './OfflineSalesUpload';
import { useCompare } from '../contexts/CompareContext';
@@ -222,6 +223,15 @@ export default function InventoryView({
sku: item.sku || `SKU-${item.productid}`,
image: item.image,
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,
unitsSold: 0,
revenue: 0,
@@ -239,6 +249,12 @@ export default function InventoryView({
const existing = byId.get(id)!;
existing.price = item.price || existing.price;
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({
-
-
Retail Packaging Info
-
-
+ {/* The full catalogue spec sheet, identical to the global
+ catalogue drawer. Fetches the source row on open. */}
+
);
})()}
diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx
index d3ba06a..183123e 100644
--- a/src/components/StoreCatalogView.tsx
+++ b/src/components/StoreCatalogView.tsx
@@ -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 { CardCarousel, ProductGallery } from './CatalogueGallery';
import FMCGHoverOverlay from './FMCGHoverOverlay';
+import CatalogueDetailPanel from './CatalogueDetailPanel';
import OfflineSalesUpload from './OfflineSalesUpload';
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.
images: it.images ?? [],
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-` 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,
unit: it.unit,
adminQty: it.qty,
@@ -1038,11 +1047,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
)}
- {/* Retail Packaging Info */}
-
-
Retail Packaging Info
-
-
+ {/* The full catalogue spec sheet, identical to the global
+ catalogue drawer. Fetches the source row on open. */}
+
);
})()}
diff --git a/src/services/catalogueApi.ts b/src/services/catalogueApi.ts
index 241d174..c6e4fb7 100644
--- a/src/services/catalogueApi.ts
+++ b/src/services/catalogueApi.ts
@@ -86,6 +86,34 @@ export async function getCatalogueProducts(opts: {
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 {
+ 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.
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
diff --git a/src/services/storeCatalogue.ts b/src/services/storeCatalogue.ts
index 5f72b74..0f0e3bb 100644
--- a/src/services/storeCatalogue.ts
+++ b/src/services/storeCatalogue.ts
@@ -44,6 +44,28 @@ export interface StoreCatalogueItem {
*/
images: 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;
price: number;
unit: string;
@@ -155,6 +177,9 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi
images: productImages(r),
publishedat: String(r.publishedat ?? ''),
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` /
// `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".
diff --git a/src/types.ts b/src/types.ts
index d4e1a23..be11b07 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -56,6 +56,14 @@ export interface ProductMatrixItem {
nutrients?: string[];
providers?: string[];
brand?: string;
+ /**
+ * The real catalogue SKU, kept apart from `sku` because that field holds a
+ * synthesised `SKU-` 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;
}