side drawer details updated

This commit is contained in:
2026-08-11 19:18:14 +05:30
parent ec0ea88d06
commit 7c26470c52
7 changed files with 280 additions and 11 deletions

View 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>
);
}

View File

@@ -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

View File

@@ -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>
);
})()}

View File

@@ -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>
);
})()}

View File

@@ -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`);

View File

@@ -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".

View File

@@ -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;
}