changes fixed in the catalogue

This commit is contained in:
2026-08-11 17:09:15 +05:30
parent e54e3c9290
commit ec0ea88d06
8 changed files with 254 additions and 141 deletions

View File

@@ -30,6 +30,10 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
// would hide the same gap at every other call site.
images: prod.image ? [prod.image] : [],
status: 'Draft',
// Not published. This drawer stages products for the admin
// catalogue; releasing them to the shops is the explicit publish
// step, which requires a price.
publishedat: '',
category: prod.category,
sku: prod.sku,
price: prod.price,

View File

@@ -63,27 +63,44 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
// the tenant's own productid, not the global catalogueid.
const importedRowsQ = useFiestaProductLocations({ tenantid, locationid, pagesize: 500 });
/**
* The tenant category that best matches the catalogue's own name.
*
* Exact match first, then a loose one so a tenant's "Noodles" catches the
* catalogue's "Noodles & Instant Food". Returns 0 when nothing lines up —
* uncategorised is honest and correctable, where picking the tenant's first
* category (which this used to do) silently misfiles every import.
*/
const matchTenantCategory = React.useCallback(
(catalogueCategory?: string): number => {
const want = String(catalogueCategory ?? '').toLowerCase().trim();
if (!want || !tenantCategories.length) return 0;
const norm = (v: unknown) => String(v).toLowerCase().trim();
const hit =
tenantCategories.find((c) => norm(c.categoryname) === want) ??
tenantCategories.find((c) => {
const n = norm(c.categoryname);
return n.length > 2 && (want.includes(n) || n.includes(want));
});
return hit ? Number(hit.categoryid) : 0;
},
[tenantCategories],
);
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
importProductMutation.mutate([item], {
onSuccess: async () => {
onSuccess: () => {
setImportingProduct(null);
if (!(item.retailprice > 0)) return;
// The import writes the price to products.retailprice, but the per-store
// price on productlocations is what the staff catalogue, the customer
// app and the order all read — so publish it across the tenant here.
// Status stays 'Draft' to match what the import itself wrote; this step
// prices the product, it doesn't change its lifecycle.
try {
const { data } = await importedRowsQ.refetch();
const row = (data ?? []).find((r) => fnum(r.catalogueid) === Number(item.catalogueid));
const productid = fnum(row?.productid);
if (productid) {
priceEverywhere(productid, item.retailprice, allLocationIds, { status: 'Draft' });
}
} catch {
// Import succeeded; only the price broadcast failed. The admin can
// still set it from the Admin Catalogue, so don't fail the import.
}
// Deliberately does not price, and deliberately does not publish.
//
// This used to call priceEverywhere(), which writes a productlocations
// row per outlet — and a productlocations row IS membership of that
// shop's catalogue. So importing published to every store in the tenant
// instantly, before anyone had set a price, which is exactly the
// behaviour this change removes.
//
// The product now lands in the Admin Catalogue alone. Pricing and
// publishing happen there, in that order.
},
onError: (err: any) => {
alert(err.message || 'Failed to import product.');
@@ -256,17 +273,35 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</button>
) : (
<button
// Opens the same form the re-import path uses,
// rather than importing straight away.
// One click, straight into the admin catalogue.
//
// It used to submit immediately with retailprice 0,
// taxpercent 0 and whichever tenant category
// happened to be first — so every product arrived
// unpriced, untaxed and misfiled, and an unpriced
// product comes down to a till as `is_active:false`
// and cannot be rung up at all. The market price was
// rendered on this very card and thrown away.
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }}
// No price, no category prompt: importing does not
// put the product in a shop, so there is nothing to
// price yet. Category is matched from the
// catalogue's own name where the tenant has an
// equivalent, and left unset otherwise for the
// admin to correct.
//
// Clicking the card body still opens the detail
// drawer — photos, nutrition, FSSAI — for anyone
// who wants to look before importing.
onClick={(e) => {
e.stopPropagation();
handleImportSubmit({
tenantid,
locationid,
brand: p.brand,
catalogueid: p.id,
categoryid: matchTenantCategory(p.category),
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
}}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<DownloadCloud size={12} />

View File

@@ -99,11 +99,17 @@ export default function ImportProductModal({
categoryId ? Number(categoryId) : undefined,
);
// A price is mandatory. Importing at ₹0 is what left every catalogue product
// on the platform priced at nothing: the staff catalogue showed "—", the
// customer app charged nothing, and each order booked orderamount 0.
// A price is NOT required to import.
//
// Importing puts a product in the ADMIN catalogue; it does not put it in a
// shop. Pricing happens there, and publishing — the step that does reach the
// shops — refuses without a price, enforced server-side in PublishProduct.
//
// So the check that stopped unpriced products being sold has moved rather
// than gone: it now sits at the point where it actually matters, instead of
// forcing a number out of somebody who is only browsing a global catalogue.
const priceValue = Number(retailPrice);
const canImport = Boolean(categoryId) && priceValue > 0;
const canImport = Boolean(categoryId);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -114,7 +120,7 @@ export default function ImportProductModal({
locationid,
brand: product.brand,
catalogueid: product.id,
categoryid: Number(categoryId),
categoryid: Number(categoryId) || 0,
subcategoryid: subcategoryId ? Number(subcategoryId) : 0,
// Importing lists the product; it does not deliver stock. Quantity lands
// via the request → approve → Mark as Received flow.
@@ -288,110 +294,40 @@ export default function ImportProductModal({
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
<h4 className="font-extrabold text-slate-800 text-sm flex items-center gap-2">
<DownloadCloud size={16} className="text-[#662582]" /> Import Product
</h4>
<span className="text-[10px] font-semibold text-slate-500">Global FMCG</span>
</div>
<p className="text-[11px] text-slate-600 font-medium leading-relaxed">
The global catalogue only carries an indicative price range, so set your own
selling price here. It applies to every store under your tenant.
{/*
No price, cost, tax or category here any more.
Importing adds the product to the ADMIN catalogue and nothing
else — it does not reach a shop and cannot be sold, so there is
nothing yet to price. Asking for a selling price while somebody
is browsing a global catalogue put the decision at the wrong
moment and made a one-click action a five-field form.
Pricing and publishing happen in the Admin Catalogue, and
publishing is where a price becomes mandatory.
*/}
<p className="text-[11px] text-slate-500 leading-relaxed">
Adds this product to your <strong className="text-slate-700">Admin Catalogue</strong>.
It is not visible to any store until you set a price and publish it there.
</p>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1.5 col-span-2">
<span className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">
Selling Price (₹) <span className="text-rose-500">*</span>
</span>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold pointer-events-none">₹</span>
<input
type="number"
min="0"
step="0.01"
value={retailPrice}
onChange={(e) => setRetailPrice(e.target.value)}
placeholder="e.g. 50"
className="w-full pl-7 pr-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] transition-all"
/>
</div>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Cost (₹)</span>
<input
type="number"
min="0"
step="0.01"
value={productCost}
onChange={(e) => setProductCost(e.target.value)}
placeholder="0"
className="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] transition-all"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Tax (%)</span>
<input
type="number"
min="0"
step="0.01"
value={taxPercent}
onChange={(e) => setTaxPercent(e.target.value)}
className="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] transition-all"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">
Category <span className="text-rose-500">*</span>
</span>
<select
value={categoryId}
onChange={(e) => { setCategoryId(e.target.value); setSubcategoryId(''); }}
disabled={isLoadingCategories}
className="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] transition-all disabled:opacity-60"
>
<option value="">{isLoadingCategories ? 'Loading…' : 'Select…'}</option>
{categories.map((c) => (
<option key={c.categoryid} value={c.categoryid}>{c.categoryname}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Subcategory</span>
<select
value={subcategoryId}
onChange={(e) => setSubcategoryId(e.target.value)}
disabled={!categoryId || isLoadingSubcats}
className="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] transition-all disabled:opacity-60"
>
<option value="">{!categoryId ? 'Pick a category first' : isLoadingSubcats ? 'Loading…' : 'None'}</option>
{subcategories.map((s: any) => (
<option key={s.subcategoryid} value={s.subcategoryid}>{s.subcategoryname}</option>
))}
</select>
</label>
</div>
<button
type="submit"
disabled={!canImport}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
type="button"
onClick={handleSubmit}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md cursor-pointer"
>
<Plus size={18} strokeWidth={2.5} />
Import Product
</button>
{!canImport && (
<p className="text-[10px] font-semibold text-slate-500 text-center flex items-center justify-center gap-1">
<AlertCircle size={11} className="text-amber-500" />
A selling price and category are required.
</p>
)}
</form>
</div>
)}
</div>

View File

@@ -46,6 +46,8 @@ import {
useFiestaProductCategories,
useFiestaUpdateStockRequest,
useFiestaGetStockRequests,
usePublishProduct,
useUnpublishProduct,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
@@ -108,12 +110,6 @@ export default function InventoryView({
// business-level decision that has to reach every branch, so it goes out to
// `allLocationIds` via priceEverywhere() below. Writing only to this outlet
// left every other branch at ₹0.
const primaryLocationId = locations[0]?.locationid ?? FIESTA_PRIMARY_LOCATION_ID;
const allLocationIds = useMemo(
() => (locations.length ? locations.map((l) => l.locationid) : [primaryLocationId]),
[locations, primaryLocationId],
);
const storesStock = useFiestaStoresStock(
tenantId,
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
@@ -122,6 +118,36 @@ export default function InventoryView({
const storesError =
locationsQ.isError || (storesStock.length > 0 && storesStock.every((s) => s.isError));
/**
* The outlet the admin catalogue reads and writes through.
*
* Was `locations[0]` — whatever order the tenant-locations API happened to
* return. For tenant 1087 that is outlet 885536644, a test row named "power"
* with **zero products**, while the real shops are 1097/1135/1137. So the
* admin catalogue read an empty outlet, and every import wrote into it: the
* product landed somewhere the screen never looked, which is exactly the
* "imported product is not visible" report.
*
* Picked from data instead: the outlet actually carrying the most stock rows,
* since `storesStock` is already fetched for every outlet on this page. Falls
* back to the lowest active locationid, which is deterministic where array
* order is not — a junk row with a nine-digit id cannot win by sorting first.
*/
const primaryLocationId = useMemo(() => {
const byRows = [...storesStock]
.filter((s) => (s.rows?.length ?? 0) > 0)
.sort((a, b) => (b.rows?.length ?? 0) - (a.rows?.length ?? 0));
if (byRows.length && byRows[0].locationid) return byRows[0].locationid;
const sorted = [...locations].sort((a, b) => a.locationid - b.locationid);
return sorted[0]?.locationid ?? FIESTA_PRIMARY_LOCATION_ID;
}, [storesStock, locations]);
const allLocationIds = useMemo(
() => (locations.length ? locations.map((l) => l.locationid) : [primaryLocationId]),
[locations, primaryLocationId],
);
// Global catalog = deduped union of every outlet's products, plus anything the
// admin adds/imports in-session. Computes live from storesStock and storeCat.items.
const [selectedAdminProduct, setSelectedAdminProduct] = useState<ProductMatrixItem | null>(null);
@@ -151,10 +177,15 @@ export default function InventoryView({
}, [storeCat.items]);
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
const publishMut = usePublishProduct();
const unpublishMut = useUnpublishProduct();
const [importPrice, setImportPrice] = useState<string>('');
const [isSettingPrice, setIsSettingPrice] = useState(false);
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null);
const [cardImportPrice, setCardImportPrice] = useState<string>('');
/** GST rate applied on publish. 0 is a real rate for zero-rated staples. */
const [cardImportTax, setCardImportTax] = useState<string>('0');
const [drawerTax, setDrawerTax] = useState<string>('0');
const selectedProductId = selectedAdminProduct?.id;
useEffect(() => {
@@ -664,16 +695,34 @@ export default function InventoryView({
<div className="flex gap-1">
<button onClick={(e) => { e.stopPropagation(); setAddingPriceProdId(null); }} className="flex-1 py-1 bg-white text-slate-500 border border-slate-200 text-[9px] font-bold">Cancel</button>
<button
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || isPricing}
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending}
onClick={(e) => {
e.stopPropagation();
// Publishing to the catalogue is a listing/pricing action, not a
// stock delivery — priceEverywhere() writes quantity 0 at every
// outlet. Real quantity only lands via the request → approve →
// Mark as Received flow.
priceEverywhere(prod.id, Number(cardImportPrice), allLocationIds, {
onSuccess: () => setAddingPriceProdId(null),
});
// Prices AND publishes, in one server-side step.
//
// priceEverywhere() only wrote product-location
// rows — and a row was already membership of a
// shop's catalogue, so it could not express
// "priced but not yet released". Publishing is
// now explicit, covers every outlet the tenant
// runs, and is refused without a price.
//
// Still a listing action, not a stock delivery:
// quantity stays 0 until the request → approve →
// Mark as Received flow moves it.
publishMut.mutate(
{
tenantid: tenantId,
productid: Number(prod.id),
price: Number(cardImportPrice),
taxpercent: Number(cardImportTax) || 0,
},
{
onSuccess: () => setAddingPriceProdId(null),
onError: (err: any) =>
alert(err?.message || 'Could not publish this product.'),
},
);
}}
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
>
@@ -1019,7 +1068,15 @@ export default function InventoryView({
// quantity only lands via Mark as Received. priceEverywhere() sends
// quantity 0, and because the upsert doesn't update quantity, an outlet
// that already holds stock keeps it while a new outlet starts at zero.
priceEverywhere(selectedAdminProduct.id, Number(importPrice), allLocationIds);
publishMut.mutate(
{
tenantid: tenantId,
productid: Number(selectedAdminProduct.id),
price: Number(importPrice),
taxpercent: Number(drawerTax) || 0,
},
{ onError: (err: any) => alert(err?.message || 'Could not publish this product.') },
);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>

View File

@@ -26,7 +26,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox, UploadCloud } from 'lucide-react';
import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, useFiestaCreateProductLocation, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, productImages, str as fstr, type Row } from '../services/fiestaApi';
import { useStoreCatalogue } from '../services/storeCatalogue';
import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
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';
@@ -110,6 +110,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const products = useMemo(
() =>
storeCat.items
// Published only. A store user sees what the admin has released and
// priced — never a draft the admin is still working on.
//
// There was no such filter before, and no flag to build one from: the
// import wrote 'Draft' into `status`, and syncProductLocationStatus
// overwrote it with an availability value on the next stock movement,
// so 'Draft' survived on one row out of 6,755. `publishedat` is not
// touched by that.
.filter(isPublishedItem)
.map((it) => ({
id: it.productid,
name: it.name,

View File

@@ -1968,3 +1968,33 @@ export function productImages(row: Row): string[] {
}
return list;
}
/**
* POST /products/publishproduct — release a product to every outlet.
*
* Publishing is tenant-wide by design: a price is a business decision, and a
* product live at one branch and absent from another is a support call nobody
* can explain. The outlet list is read server-side rather than sent, so a
* caller cannot publish to a subset by omission.
*
* Refused without a price. That check lives in the backend, not in the form —
* an unpriced product reaching a till comes down as `is_active: false` and
* cannot be rung up at all.
*/
export async function publishProduct(input: {
tenantid: number;
productid: number;
price: number;
/** Optional. 0 is a real rate for zero-rated staples, not a missing value. */
taxpercent?: number;
}): Promise<Row> {
return fiestaSend<Row>('products/publishproduct', 'POST', input);
}
/** POST /products/unpublishproduct — withdraw from every shop, keeping the rows. */
export async function unpublishProduct(input: {
tenantid: number;
productid: number;
}): Promise<Row> {
return fiestaSend<Row>('products/unpublishproduct', 'POST', input);
}

View File

@@ -48,6 +48,8 @@ import {
getProductStocks,
getProductLocations,
createProductLocations,
publishProduct,
unpublishProduct,
getPosRoles,
getPosStaff,
createPosStaff,
@@ -1151,3 +1153,28 @@ export function useUpdateStaffShift() {
qc.invalidateQueries({ queryKey: ['fiesta', 'staffshifts', v.tenantid, v.locationid] }),
});
}
// ── Publishing a product from the admin catalogue to the shops ───────────────
/**
* Release a product to every outlet the tenant runs.
*
* Invalidates the location-products cache on success, because that read is what
* both catalogues render from — without it the admin sees "Draft" until a
* refetch, and the store user sees nothing new at all.
*/
export function usePublishProduct() {
const qc = useQueryClient();
return useMutation({
mutationFn: publishProduct,
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] }),
});
}
export function useUnpublishProduct() {
const qc = useQueryClient();
return useMutation({
mutationFn: unpublishProduct,
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] }),
});
}

View File

@@ -59,6 +59,15 @@ export interface StoreCatalogueItem {
* as a visibility gate. Use `isPublishedItem` / `isRemovedStatus` instead.
*/
status: string;
/**
* When the admin released this product to the shops; empty while it sits in
* the admin catalogue awaiting a price.
*
* THIS is the publish flag — not , which the backend rewrites with
* availability values on every stock movement. See isPublishedItem.
*/
publishedat: string;
}
/**
@@ -81,7 +90,12 @@ export function isRemovedStatus(status: unknown): boolean {
* published, whatever its stock level, until the admin deletes it.
*/
export function isPublishedItem(item: StoreCatalogueItem | undefined | null): boolean {
return Boolean(item) && !isRemovedStatus(item!.status);
// Published means the admin explicitly released it — a real timestamp — and
// the row has not since been removed. It used to mean only the latter, i.e.
// the row existing at all, which made importing a product the same act as
// putting it on sale in every shop.
const at = String(item?.publishedat ?? '').trim();
return Boolean(item) && at !== '' && at !== 'null' && !isRemovedStatus(item!.status);
}
/**
@@ -139,6 +153,7 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi
name: String(r.name || r.productname || ''),
image: String(r.image || r.productimage || ''),
images: productImages(r),
publishedat: String(r.publishedat ?? ''),
category: String(r.category || r.categoryname || 'General'),
// 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"