diff --git a/src/components/BulkCartDrawer.tsx b/src/components/BulkCartDrawer.tsx index 1caf0cd..5e371ff 100644 --- a/src/components/BulkCartDrawer.tsx +++ b/src/components/BulkCartDrawer.tsx @@ -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, diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx index 1ee9a84..2cb3b91 100644 --- a/src/components/CatalogueBrowser.tsx +++ b/src/components/CatalogueBrowser.tsx @@ -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 ) : ( - {!canImport && ( -

- - A selling price and category are required. -

- )} - + )} diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index d336755..e09a3f9 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -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(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(''); const [isSettingPrice, setIsSettingPrice] = useState(false); const [addingPriceProdId, setAddingPriceProdId] = useState(null); const [cardImportPrice, setCardImportPrice] = useState(''); + /** GST rate applied on publish. 0 is a real rate for zero-rated staples. */ + const [cardImportTax, setCardImportTax] = useState('0'); + const [drawerTax, setDrawerTax] = useState('0'); const selectedProductId = selectedAdminProduct?.id; useEffect(() => { @@ -664,16 +695,34 @@ export default function InventoryView({