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.
|
// 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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user