pricing update
This commit is contained in:
1
.env
1
.env
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
<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"
|
||||
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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,15 +646,17 @@ 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}
|
||||
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);
|
||||
}}
|
||||
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || isPricing}
|
||||
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),
|
||||
});
|
||||
}}
|
||||
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
|
||||
>
|
||||
Confirm
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user