From 7c26470c52cdf7305b87c5ced970ea766ef7bfe6 Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 11 Aug 2026 19:18:14 +0530 Subject: [PATCH] side drawer details updated --- src/components/CatalogueDetailPanel.tsx | 167 ++++++++++++++++++++++++ src/components/FMCGHoverOverlay.tsx | 13 +- src/components/InventoryView.tsx | 28 +++- src/components/StoreCatalogView.tsx | 22 +++- src/services/catalogueApi.ts | 28 ++++ src/services/storeCatalogue.ts | 25 ++++ src/types.ts | 8 ++ 7 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 src/components/CatalogueDetailPanel.tsx 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; }