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;
|
||||
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) */}
|
||||
<div className="flex-1 space-y-3">
|
||||
{product?.description && (
|
||||
{showDescription && product?.description && (
|
||||
<div>
|
||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||
<Info size={10} /> Description
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
||||
<FMCGHoverOverlay productId={selectedAdminProduct.id} category={selectedAdminProduct.category} productName={selectedAdminProduct.name} product={selectedAdminProduct} />
|
||||
</div>
|
||||
{/* The full catalogue spec sheet, identical to the global
|
||||
catalogue drawer. Fetches the source row on open. */}
|
||||
<CatalogueDetailPanel
|
||||
brand={selectedAdminProduct.brand}
|
||||
sku={selectedAdminProduct.catalogueSku}
|
||||
catalogueId={selectedAdminProduct.catalogueId}
|
||||
fallbackDescription={selectedAdminProduct.description}
|
||||
/>
|
||||
</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 { 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-<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,
|
||||
unit: it.unit,
|
||||
adminQty: it.qty,
|
||||
@@ -1038,11 +1047,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Retail Packaging Info */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<h4 className="text-sm font-semibold text-slate-800 mb-3">Retail Packaging Info</h4>
|
||||
<FMCGHoverOverlay productId={selectedProduct.id} category={selectedProduct.category} productName={selectedProduct.name} />
|
||||
</div>
|
||||
{/* The full catalogue spec sheet, identical to the global
|
||||
catalogue drawer. Fetches the source row on open. */}
|
||||
<CatalogueDetailPanel
|
||||
brand={selectedProduct.brand}
|
||||
sku={selectedProduct.catalogueSku}
|
||||
catalogueId={selectedProduct.catalogueId}
|
||||
fallbackDescription={selectedProduct.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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<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.
|
||||
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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-<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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user