pricing update

This commit is contained in:
2026-08-06 10:58:34 +05:30
parent 13db63e219
commit ac6dbd7671
9 changed files with 304 additions and 107 deletions

1
.env
View File

@@ -1,3 +1,4 @@
# Local secrets — gitignored, never committed.
# Used ONLY by the Vite dev-server proxy (vite.config.ts) to inject the
# x-hasura-admin-secret header server-side. NOT prefixed with VITE_, so it

View File

@@ -8,7 +8,9 @@ import {
useTenantCategories
} from '../hooks/useCatalogueImport';
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
import { useStoreCatalogue } from '../services/storeCatalogue';
import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogue';
import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries';
import { num as fnum } from '../services/fiestaApi';
import ImportProductModal from './ImportProductModal';
interface CatalogueBrowserProps {
@@ -46,10 +48,41 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
const importProductMutation = useImportCatalogueProduct(tenantid, locationid);
// Every outlet the tenant runs — the price entered on import has to reach all
// of them, not just the one this browser happens to be scoped to.
const locationsQ = useFiestaTenantLocations(tenantid);
const allLocationIds = React.useMemo(() => {
const ids = (locationsQ.data ?? []).map((l) => fnum(l.locationid)).filter((id) => id > 0);
return ids.length ? ids : [locationid];
}, [locationsQ.data, locationid]);
const { priceEverywhere } = usePriceEverywhere(tenantid);
// Used only to look the freshly-created tenant productid back up: the import
// response returns just {status, message}, and the price has to be attached to
// the tenant's own productid, not the global catalogueid.
const importedRowsQ = useFiestaProductLocations({ tenantid, locationid, pagesize: 500 });
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
importProductMutation.mutate([item], {
onSuccess: () => {
onSuccess: async () => {
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.
}
},
onError: (err: any) => {
alert(err.message || 'Failed to import product.');

View File

@@ -49,7 +49,10 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
const groups = new Map<string, Row[]>();
for (const order of orders) {
const total = fnum(order.totalamount) || fnum(order.payableamount) || 0;
// `getorders` returns `orderamount` — it has no `totalamount` or
// `payableamount` column, so reading only those left Lifetime Value,
// Highest Bill and Avg Value at ₹0 for every customer.
const total = fnum(order.orderamount) || fnum(order.totalamount) || fnum(order.payableamount) || 0;
spend += total;
if (total > highest) highest = total;
@@ -194,7 +197,7 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
{monthOrders.map((order, orderIdx) => {
const orderDate = fstr(order.createddate) || fstr(order.orderdate) || '';
const orderId = fstr(order.orderid) || String(fnum(order.orderheaderid));
const total = fnum(order.totalamount) || fnum(order.payableamount) || 0;
const total = fnum(order.orderamount) || fnum(order.totalamount) || fnum(order.payableamount) || 0;
const statusStr = fstr(order.orderstatus) || 'CREATED';
const statusBadgeClass = getStatusClass(statusStr);
return (

View File

@@ -27,7 +27,6 @@ export default function ImportProductModal({
const [retailPrice, setRetailPrice] = useState<string>('');
const [productCost, setProductCost] = useState<string>('');
const [taxPercent, setTaxPercent] = useState<string>('0');
const [quantity, setQuantity] = useState<string>('1');
// Categories this tenant's own products actually use
const { data: categories = [], isLoading: isLoadingCategories } = useTenantCategories(tenantid);
@@ -38,12 +37,15 @@ 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.
const priceValue = Number(retailPrice);
const canImport = Boolean(categoryId) && priceValue > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!categoryId || !retailPrice || !productCost) {
alert("Please fill in all required fields.");
return;
}
if (!canImport) return;
onImport({
tenantid,
@@ -52,14 +54,16 @@ export default function ImportProductModal({
catalogueid: product.id,
categoryid: Number(categoryId),
subcategoryid: subcategoryId ? Number(subcategoryId) : 0,
quantity: Number(quantity),
// Importing lists the product; it does not deliver stock. Quantity lands
// via the request → approve → Mark as Received flow.
quantity: 0,
stocktype: "in",
// Draft: lands in the Admin Catalogue only, published to the store
// catalogue separately via the explicit "Add to Store Catalogue" step.
status: "Draft",
retailprice: Number(retailPrice),
productcost: Number(productCost),
taxpercent: Number(taxPercent),
retailprice: priceValue,
productcost: Number(productCost) || 0,
taxpercent: Number(taxPercent) || 0,
});
};
@@ -123,7 +127,7 @@ export default function ImportProductModal({
</div>
</div>
) : (
<div className="space-y-4">
<form onSubmit={handleSubmit} 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
@@ -131,32 +135,102 @@ export default function ImportProductModal({
<span className="text-[10px] font-semibold text-slate-500">Global FMCG</span>
</div>
<p className="text-[11px] text-slate-600 font-medium leading-relaxed">
Import this product to your Admin Catalogue. Selling price and outlet parameters will be managed directly in your Admin Catalogue.
The global catalogue only carries an indicative price range, so set your own
selling price here. It applies to every store under your tenant.
</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
onClick={() => {
const validCategory = categories.find(c => c.categoryid > 0)?.categoryid || (categories[0]?.categoryid ?? 2);
onImport({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
categoryid: validCategory,
subcategoryid: 0,
quantity: 0,
stocktype: "in",
status: "Draft",
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
}}
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"
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"
>
<Plus size={18} strokeWidth={2.5} />
Import Product
</button>
</div>
{!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>

View File

@@ -49,7 +49,7 @@ import {
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
import { useStoreCatalogue, usePriceEverywhere, isPublishedItem } from '../services/storeCatalogue';
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';
@@ -99,10 +99,19 @@ export default function InventoryView({
[locationsQ.data],
);
// The admin catalogue (imports, curation) is scoped to this tenant's own
// The admin catalogue (imports, curation) reads through this tenant's own
// primary/hub outlet — not the hardcoded Fiesta defaults, so onboarded
// tenants other than Fiesta see their own imports reflected here.
//
// READS are scoped here; PRICE WRITES are not. A price the admin sets is a
// 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,
@@ -125,6 +134,7 @@ export default function InventoryView({
const [hoveredAdminProduct, setHoveredAdminProduct] = useState<ProductMatrixItem | null>(null);
const [localSearch, setLocalSearch] = useState('');
const storeCat = useStoreCatalogue(tenantId, primaryLocationId);
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
const [importPrice, setImportPrice] = useState<string>('');
const [isSettingPrice, setIsSettingPrice] = useState(false);
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null);
@@ -619,7 +629,12 @@ export default function InventoryView({
</button>
) : addingPriceProdId === prod.id ? (
<div className="flex flex-col gap-1.5 p-2 bg-slate-50 border border-slate-200 rounded-none" onClick={(e) => e.stopPropagation()}>
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center">Set Selling Price ()</label>
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center">
Set Selling Price ()
</label>
<span className="text-[8px] font-bold text-slate-400 uppercase tracking-wider text-center -mt-1">
{allLocationIds.length === 1 ? 'Your store' : `All ${allLocationIds.length} stores`}
</span>
<input
type="number"
value={cardImportPrice}
@@ -631,14 +646,16 @@ 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}
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || isPricing}
onClick={(e) => {
e.stopPropagation();
// qty: 0 — publishing to the catalogue is a listing/pricing action,
// not a stock delivery. Real quantity only lands via the
// request → approve → Mark as Received flow.
storeCat.add({ productid: prod.id, name: prod.name, image: prod.image, category: prod.category, sku: prod.sku, price: Number(cardImportPrice), unit: prod.exposure, qty: 0, status: 'Active' });
setAddingPriceProdId(null);
// 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),
});
}}
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
>
@@ -968,30 +985,26 @@ export default function InventoryView({
</div>
<button
disabled={!importPrice || Number(importPrice) <= 0}
disabled={!importPrice || Number(importPrice) <= 0 || isPricing}
onClick={() => {
// First-time publish gets qty 0 — it's a listing/pricing action, not a
// stock delivery; real quantity only lands via Mark as Received. If this
// product is already published, preserve its existing qty so a mere price
// edit here doesn't zero out stock that's since been received.
const existingQty = storeCat.items.find(i => i.productid === selectedAdminProduct.id)?.qty ?? 0;
storeCat.add({
productid: selectedAdminProduct.id,
name: selectedAdminProduct.name,
image: selectedAdminProduct.image,
category: selectedAdminProduct.category,
sku: selectedAdminProduct.sku,
price: Number(importPrice),
unit: selectedAdminProduct.exposure || 'Pc',
qty: existingQty,
status: 'Active'
});
// Publishing is a listing/pricing action, never a stock delivery — real
// 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);
}}
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"
>
<CheckCircle size={16} strokeWidth={2.5} />
{storeCat.has(selectedAdminProduct.id) ? 'Save Price to Database' : 'Save Price & Publish to Store'}
{isPricing
? 'Saving…'
: storeCat.has(selectedAdminProduct.id)
? 'Save Price to All Stores'
: 'Save Price & Publish to All Stores'}
</button>
<p className="text-[10px] font-semibold text-slate-500 text-center -mt-1">
Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`}
</p>
{storeCat.has(selectedAdminProduct.id) && (
<button

View File

@@ -76,53 +76,32 @@ export async function getImportedCatalogueRefs(tenantid: number, brand?: string)
return new Set(items.map((r) => `${r.brand}:${r.catalogueid}`));
}
/**
* Import one or more global-catalogue products into the tenant's own catalogue.
*
* There is deliberately no fallback path. The previous one called
* `createproductlocation` directly with `productid: item.catalogueid` — but a
* catalogue id is only unique within its brand table and is NOT the tenant's own
* productid, so the row it wrote linked stock and price to an unrelated product.
* It also sent `retailprice`, which models.Productlocations doesn't bind (the
* column there is `price`), so it persisted no price either. Only the import
* service can mint a tenant productid, so when it's unavailable the right answer
* is to surface the failure rather than write a wrong row.
*/
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
try {
const res = await fetch(`${API_BASE}/products/importcatalogueproduct`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(items),
});
if (res.ok) {
const json = await res.json();
if (json && typeof json === 'object' && 'status' in json && json.status === false) {
console.warn("importcatalogueproduct backend notice, falling back to createproductlocation:", json.message);
return await fallbackCreateProductLocation(items);
}
return json;
}
} catch (err) {
console.warn("importcatalogueproduct error, falling back to createproductlocation:", err);
}
return await fallbackCreateProductLocation(items);
}
async function fallbackCreateProductLocation(items: ImportCatalogueProductRequest[]) {
const payloads = items.map(item => ({
tenantid: item.tenantid,
locationid: item.locationid,
productid: item.catalogueid,
quantity: item.quantity ?? 0,
stocktype: item.stocktype || "in",
status: item.status || "Active",
retailprice: item.retailprice || 0,
productcost: item.productcost || 0,
taxpercent: item.taxpercent || 0
}));
const res = await fetch(`${API_BASE}/products/createproductlocation`, {
const res = await fetch(`${API_BASE}/products/importcatalogueproduct`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(payloads),
body: JSON.stringify(items),
});
if (!res.ok) {
let errText = "";
try { errText = await res.text(); } catch (_) {}
throw new Error(errText || `Failed to add product to catalogue (${res.status})`);
let json: any = null;
try { json = await res.json(); } catch { /* non-JSON error body */ }
if (!res.ok || (json && typeof json === 'object' && json.status === false)) {
throw new Error(json?.message || `Failed to import product (${res.status})`);
}
return await res.json();
return json;
}
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {

View File

@@ -942,6 +942,36 @@ export async function createProductLocation(input: CreateProductLocationInput):
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
}
/**
* Same endpoint, one request for many outlets. `createproductlocation` takes an
* array and upserts on (tenantid, locationid, productid), so this publishes or
* reprices a product across every branch in a single round trip.
*
* `quantity` is deliberately 0. It is not a productlocations column at all —
* models.Productlocations marks Quantity/Stocktype `gorm:"<-:false"`, so they
* are never written; they only decide whether the backend appends a
* `productstocks` ledger row, which it skips entirely when quantity is 0.
* Sending 0 therefore prices every outlet while touching no stock anywhere.
* Pricing must never move stock.
*/
export async function createProductLocations(
inputs: Array<{ tenantid: number; locationid: number; productid: number; price?: number; status?: string }>,
): Promise<Row> {
const payload = inputs.map((i) => {
const row: Record<string, unknown> = {
tenantid: i.tenantid,
locationid: i.locationid,
productid: i.productid,
quantity: 0,
stocktype: 'in',
status: i.status || 'Active',
};
if (num(i.price) > 0) row.price = i.price;
return row;
});
return fiestaSend<Row>('products/createproductlocation', 'POST', payload);
}
export interface DeleteProductLocationInput {
tenantid: number;
locationid: number;

View File

@@ -47,6 +47,7 @@ import {
getProductsCount,
getProductStocks,
getProductLocations,
createProductLocations,
getMasterCatalog,
getProductCategories,
getProductSubcategories,
@@ -1024,6 +1025,25 @@ export function useFiestaCreateProductLocation() {
});
}
/**
* Publish/reprice one product across many outlets in a single request. Used by
* the admin catalogue, where a price the admin sets is meant to apply to every
* store under the tenant — not just whichever outlet happened to load first.
*/
export function useFiestaCreateProductLocations() {
const qc = useQueryClient();
return useMutation({
mutationFn: (inputs: Array<{ tenantid: number; locationid: number; productid: number; price?: number; status?: string }>) =>
createProductLocations(inputs),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['catalogue', 'imported'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'productStocks'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
},
});
}
export function useFiestaDeleteProductLocation() {
const qc = useQueryClient();
return useMutation({

View File

@@ -21,7 +21,12 @@
* store user's catalogue on its next fetch.
*/
import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
import {
useFiestaProductLocations,
useFiestaCreateProductLocation,
useFiestaCreateProductLocations,
useFiestaDeleteProductLocation,
} from './fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
export interface StoreCatalogueItem {
@@ -69,6 +74,45 @@ export function isPublishedItem(item: StoreCatalogueItem | undefined | null): bo
return Boolean(item) && !isRemovedStatus(item!.status);
}
/**
* Set one product's selling price at EVERY outlet of a tenant.
*
* The price lives on `productlocations`, which is keyed per outlet, so a single
* write only ever priced one branch — and the admin catalogue was hardwired to
* `locations[0]`, meaning branches 2..N stayed at ₹0 and their staff (and the
* customer app) saw no price at all. Pricing is a business-level decision here:
* the admin sets it once and every store carries it.
*
* One request for all outlets, not N — `createproductlocation` accepts an array
* and upserts each row. Outlets that don't stock the product yet get a row at
* quantity 0, which lists it without inventing stock.
*/
export function usePriceEverywhere(tenantid: number = FIESTA_TENANT_ID) {
const mutation = useFiestaCreateProductLocations();
const priceEverywhere = (
productid: number | string,
price: number,
locationIds: number[],
opts?: { status?: string; onSuccess?: () => void; onError?: (e: Error) => void },
) => {
const outlets = [...new Set(locationIds.filter((id) => id > 0))];
if (!outlets.length || !Number(productid)) return;
mutation.mutate(
outlets.map((locationid) => ({
tenantid,
locationid,
productid: Number(productid),
price,
status: opts?.status || 'Active',
})),
{ onSuccess: () => opts?.onSuccess?.(), onError: (e: any) => opts?.onError?.(e) },
);
};
return { priceEverywhere, isPending: mutation.isPending };
}
/**
* Live view of the store catalogue + curation helpers. Re-renders whenever the
* catalogue changes via React Query invalidation.