changes fixed in the catalogue
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user