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. // would hide the same gap at every other call site.
images: prod.image ? [prod.image] : [], images: prod.image ? [prod.image] : [],
status: 'Draft', 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, category: prod.category,
sku: prod.sku, sku: prod.sku,
price: prod.price, 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. // the tenant's own productid, not the global catalogueid.
const importedRowsQ = useFiestaProductLocations({ tenantid, locationid, pagesize: 500 }); 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) => { const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
importProductMutation.mutate([item], { importProductMutation.mutate([item], {
onSuccess: async () => { onSuccess: () => {
setImportingProduct(null); setImportingProduct(null);
if (!(item.retailprice > 0)) return; // Deliberately does not price, and deliberately does not publish.
// The import writes the price to products.retailprice, but the per-store //
// price on productlocations is what the staff catalogue, the customer // This used to call priceEverywhere(), which writes a productlocations
// app and the order all read — so publish it across the tenant here. // row per outlet — and a productlocations row IS membership of that
// Status stays 'Draft' to match what the import itself wrote; this step // shop's catalogue. So importing published to every store in the tenant
// prices the product, it doesn't change its lifecycle. // instantly, before anyone had set a price, which is exactly the
try { // behaviour this change removes.
const { data } = await importedRowsQ.refetch(); //
const row = (data ?? []).find((r) => fnum(r.catalogueid) === Number(item.catalogueid)); // The product now lands in the Admin Catalogue alone. Pricing and
const productid = fnum(row?.productid); // publishing happen there, in that order.
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.
}
}, },
onError: (err: any) => { onError: (err: any) => {
alert(err.message || 'Failed to import product.'); alert(err.message || 'Failed to import product.');
@@ -256,17 +273,35 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
</button> </button>
) : ( ) : (
<button <button
// Opens the same form the re-import path uses, // One click, straight into the admin catalogue.
// rather than importing straight away.
// //
// It used to submit immediately with retailprice 0, // No price, no category prompt: importing does not
// taxpercent 0 and whichever tenant category // put the product in a shop, so there is nothing to
// happened to be first — so every product arrived // price yet. Category is matched from the
// unpriced, untaxed and misfiled, and an unpriced // catalogue's own name where the tenant has an
// product comes down to a till as `is_active:false` // equivalent, and left unset otherwise for the
// and cannot be rung up at all. The market price was // admin to correct.
// rendered on this very card and thrown away. //
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }} // 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" 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} /> <DownloadCloud size={12} />

View File

@@ -99,11 +99,17 @@ export default function ImportProductModal({
categoryId ? Number(categoryId) : undefined, categoryId ? Number(categoryId) : undefined,
); );
// A price is mandatory. Importing at ₹0 is what left every catalogue product // A price is NOT required to import.
// on the platform priced at nothing: the staff catalogue showed "—", the //
// customer app charged nothing, and each order booked orderamount 0. // 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 priceValue = Number(retailPrice);
const canImport = Boolean(categoryId) && priceValue > 0; const canImport = Boolean(categoryId);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -114,7 +120,7 @@ export default function ImportProductModal({
locationid, locationid,
brand: product.brand, brand: product.brand,
catalogueid: product.id, catalogueid: product.id,
categoryid: Number(categoryId), categoryid: Number(categoryId) || 0,
subcategoryid: subcategoryId ? Number(subcategoryId) : 0, subcategoryid: subcategoryId ? Number(subcategoryId) : 0,
// Importing lists the product; it does not deliver stock. Quantity lands // Importing lists the product; it does not deliver stock. Quantity lands
// via the request → approve → Mark as Received flow. // via the request → approve → Mark as Received flow.
@@ -288,110 +294,40 @@ export default function ImportProductModal({
</div> </div>
</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"> <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"> <h4 className="font-extrabold text-slate-800 text-sm flex items-center gap-2">
<DownloadCloud size={16} className="text-[#662582]" /> Import Product <DownloadCloud size={16} className="text-[#662582]" /> Import Product
</h4> </h4>
<span className="text-[10px] font-semibold text-slate-500">Global FMCG</span> <span className="text-[10px] font-semibold text-slate-500">Global FMCG</span>
</div> </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> </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 <button
type="submit" type="button"
disabled={!canImport} 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 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" 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} /> <Plus size={18} strokeWidth={2.5} />
Import Product Import Product
</button> </button>
{!canImport && ( </div>
<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, useFiestaProductCategories,
useFiestaUpdateStockRequest, useFiestaUpdateStockRequest,
useFiestaGetStockRequests, useFiestaGetStockRequests,
usePublishProduct,
useUnpublishProduct,
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; 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 // business-level decision that has to reach every branch, so it goes out to
// `allLocationIds` via priceEverywhere() below. Writing only to this outlet // `allLocationIds` via priceEverywhere() below. Writing only to this outlet
// left every other branch at ₹0. // 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( const storesStock = useFiestaStoresStock(
tenantId, tenantId,
locations.map(({ locationid, locationname }) => ({ locationid, locationname })), locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
@@ -122,6 +118,36 @@ export default function InventoryView({
const storesError = const storesError =
locationsQ.isError || (storesStock.length > 0 && storesStock.every((s) => s.isError)); 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 // Global catalog = deduped union of every outlet's products, plus anything the
// admin adds/imports in-session. Computes live from storesStock and storeCat.items. // admin adds/imports in-session. Computes live from storesStock and storeCat.items.
const [selectedAdminProduct, setSelectedAdminProduct] = useState<ProductMatrixItem | null>(null); const [selectedAdminProduct, setSelectedAdminProduct] = useState<ProductMatrixItem | null>(null);
@@ -151,10 +177,15 @@ export default function InventoryView({
}, [storeCat.items]); }, [storeCat.items]);
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId); const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
const publishMut = usePublishProduct();
const unpublishMut = useUnpublishProduct();
const [importPrice, setImportPrice] = useState<string>(''); const [importPrice, setImportPrice] = useState<string>('');
const [isSettingPrice, setIsSettingPrice] = useState(false); const [isSettingPrice, setIsSettingPrice] = useState(false);
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null); const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null);
const [cardImportPrice, setCardImportPrice] = useState<string>(''); 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; const selectedProductId = selectedAdminProduct?.id;
useEffect(() => { useEffect(() => {
@@ -664,16 +695,34 @@ export default function InventoryView({
<div className="flex gap-1"> <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 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 <button
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || isPricing} disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// Publishing to the catalogue is a listing/pricing action, not a // Prices AND publishes, in one server-side step.
// stock delivery — priceEverywhere() writes quantity 0 at every //
// outlet. Real quantity only lands via the request → approve → // priceEverywhere() only wrote product-location
// Mark as Received flow. // rows — and a row was already membership of a
priceEverywhere(prod.id, Number(cardImportPrice), allLocationIds, { // 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), 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" 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 only lands via Mark as Received. priceEverywhere() sends
// quantity 0, and because the upsert doesn't update quantity, an outlet // 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. // 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" 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 { 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 { 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 { 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 AwaitingApi from './AwaitingApi';
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi'; import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
import { CardCarousel, ProductGallery } from './CatalogueGallery'; import { CardCarousel, ProductGallery } from './CatalogueGallery';
@@ -110,6 +110,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const products = useMemo( const products = useMemo(
() => () =>
storeCat.items 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) => ({ .map((it) => ({
id: it.productid, id: it.productid,
name: it.name, name: it.name,

View File

@@ -1968,3 +1968,33 @@ export function productImages(row: Row): string[] {
} }
return list; 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, getProductStocks,
getProductLocations, getProductLocations,
createProductLocations, createProductLocations,
publishProduct,
unpublishProduct,
getPosRoles, getPosRoles,
getPosStaff, getPosStaff,
createPosStaff, createPosStaff,
@@ -1151,3 +1153,28 @@ export function useUpdateStaffShift() {
qc.invalidateQueries({ queryKey: ['fiesta', 'staffshifts', v.tenantid, v.locationid] }), 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. * as a visibility gate. Use `isPublishedItem` / `isRemovedStatus` instead.
*/ */
status: string; 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. * published, whatever its stock level, until the admin deletes it.
*/ */
export function isPublishedItem(item: StoreCatalogueItem | undefined | null): boolean { 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 || ''), name: String(r.name || r.productname || ''),
image: String(r.image || r.productimage || ''), image: String(r.image || r.productimage || ''),
images: productImages(r), images: productImages(r),
publishedat: String(r.publishedat ?? ''),
category: String(r.category || r.categoryname || 'General'), category: String(r.category || r.categoryname || 'General'),
// The row's columns are `productsku` / `productunit` — the bare `sku` / // 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" // `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002"