# Store Catalogue Import — Frontend Integration Spec Backend work is done and verified live. This doc is the handoff: build the Admin Catalogue UI flow (browse global catalogue → choose products → import into a specific store) against the endpoints below. ## 1. Architecture (why the API looks like this) There are two separate Postgres databases that never talk to each other directly: - **CatalogueDB** (pgvector) — the global catalogue, one table per brand (`brand_dabur`, `brand_nestle`, `brand_pepsico`, `brand_sakthi`, `brand_manna`, `brand_naga`). ~237 products total today. - **nearledb** — your tenant/store data (`products`, `productlocations`, `productstocks`). The backend bridges them using a **composite key: `(brand, catalogueid)`**. A catalogue row's bare `id` is only unique *within its own brand table* — `brand_dabur.id=1` and `brand_nestle.id=1` are different products. Every call that references a catalogue product must send both `brand` and `catalogueid`, never just an id. When a product is imported, the backend snapshots it into the tenant's own `products` table (tagged with that brand+catalogueid) and links it to the location via the existing stock/location system. After that, it behaves exactly like a product the tenant created by hand — reading a store's catalogue never touches CatalogueDB again. ## 2. Endpoints Base path: `/live/api/v1` (replace host with your environment's API host). | Method | Path | Purpose | |---|---|---| | `GET` | `/web/catalogue/getproducts` | Browse the global catalogue. Query: `brand` (**optional** — omit to search all brands merged), `category`, `keyword`, `pageno`, `pagesize`. This is the "show everything" entry point. | | `GET` | `/web/catalogue/getbrands` | List brands with product counts, for a brand filter chip row. | | `GET` | `/web/products/getimportedcatalogueproducts` | Query: `tenantid` (required), `brand` (**optional** — omit to check across every brand). Returns `[{brand, catalogueid}, …]` already imported by this tenant, for badging "Imported" in the browser. | | `GET` | `/web/products/getproductsubcategories` | Query: `tenantid`, `categoryid`. Use to populate the category/subcategory picker shown before import (see §4). | | `POST` | `/web/products/importcatalogueproduct` | Body is an **array** — import one or many in a batch. Idempotent: re-importing the same `(tenantid, brand, catalogueid)` tops up stock and updates price instead of duplicating. | | `GET` | `/web/products/getlocationproducts` | Query: `tenantid`, `locationid`, `pageno`, `pagesize`. The store's own catalogue view — what's actually imported. | | `DELETE` | `/web/products/deleteproductlocation` | Body: `tenantid`, `locationid`, `productid`. Unlinks from the store; keeps the product row and order history intact (safely re-importable after). | Mobile mirrors exist at `/mob/products/importcatalogueproduct` and `/mob/products/getimportedcatalogueproducts` if the mobile app needs this flow too. ## 3. Integration flow Order matters — each step depends on data fetched in the one before it. 1. **Show everything first.** Call `catalogue/getproducts` with no `brand`. That's the full catalogue, merged and paginated. Don't gate the list behind a brand selector — brand/category/keyword are filters applied on top of an already-visible list, not a prerequisite to seeing it. 2. **Mark what's already imported.** Call `products/getimportedcatalogueproducts?tenantid=` (no `brand`, since the list mixes brands) in parallel with step 1. Build a lookup keyed on `` `${brand}:${catalogueid}` `` and badge matching items as "Imported". 3. **Collect what the catalogue can't supply.** The catalogue has no exact price (only a `price_range` display string) and no mapping to this tenant's own categories. Before enabling the Import action on a product, require the store owner to pick `categoryid`/`subcategoryid` (from `getproductsubcategories`) and enter `retailprice`/`productcost`/`taxpercent`. 4. **Import.** `POST products/importcatalogueproduct` with the batch. On success, invalidate both the imported-refs query and the store-catalogue query. 5. **Show it in the store.** Refetch `products/getlocationproducts` — the imported item now appears like any other product, with live stock computed from the stock ledger. 6. **Remove, if needed.** `DELETE products/deleteproductlocation`, then invalidate the same two queries as import. ## 4. Code TypeScript + TanStack Query (React Query), matching the existing admin app pattern of invalidating queries after mutations. ### `api/catalogue.ts` ```ts const API_BASE = "https:///live/api/v1"; export interface CatalogueProduct { id: number; brand: string; product_name: string; category?: string; images?: string[]; size?: string; product_sku?: string; price_range?: string; // display only — never an exact price } // brand omitted → the entire catalogue, all brands merged. export async function getCatalogueProducts(opts: { brand?: string; keyword?: string; pageno?: number; pagesize?: number; } = {}) { const { brand, keyword, pageno = 1, pagesize = 50 } = opts; const url = new URL(`${API_BASE}/web/catalogue/getproducts`); if (brand) url.searchParams.set("brand", brand); if (keyword) url.searchParams.set("keyword", keyword); url.searchParams.set("pageno", String(pageno)); url.searchParams.set("pagesize", String(pagesize)); const res = await fetch(url); const json = await res.json(); return { products: json.details as CatalogueProduct[], total: json.total as number }; } export interface ImportedRef { brand: string; catalogueid: number; } // brand omitted → imported refs across every brand. export async function getImportedCatalogueRefs(tenantid: number, brand?: string) { const url = new URL(`${API_BASE}/web/products/getimportedcatalogueproducts`); url.searchParams.set("tenantid", String(tenantid)); if (brand) url.searchParams.set("brand", brand); const res = await fetch(url); const json = await res.json(); const refs = json.details as ImportedRef[]; return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`)); } export interface ImportCatalogueProductRequest { tenantid: number; locationid: number; brand: string; // bridge key part 1 catalogueid: number; // bridge key part 2 — the catalogue row's `id` categoryid: number; // this tenant's own category subcategoryid: number; // this tenant's own subcategory quantity: number; stocktype: "in" | "out"; status: string; retailprice: number; productcost: number; taxpercent: number; } export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) { const res = await fetch(`${API_BASE}/web/products/importcatalogueproduct`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(items), }); const json = await res.json(); if (!json.status) throw new Error(json.message); return json; } export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) { const res = await fetch(`${API_BASE}/web/products/deleteproductlocation`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenantid, locationid, productid }), }); return res.json(); } ``` ### `hooks/useCatalogueImport.ts` ```ts export function useCatalogueProducts(brand?: string, keyword?: string) { return useQuery({ queryKey: ["catalogue", "products", brand ?? "all", keyword ?? ""], queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 100 }), }); } export function useImportedCatalogueRefs(tenantid: number, brand?: string) { return useQuery({ queryKey: ["catalogue", "imported", tenantid, brand ?? "all"], queryFn: () => getImportedCatalogueRefs(tenantid, brand), }); } export function useImportCatalogueProduct(tenantid: number, locationid: number) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (items: ImportCatalogueProductRequest[]) => importCatalogueProducts(items), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["catalogue", "imported", tenantid] }); queryClient.invalidateQueries({ queryKey: ["store-catalogue", tenantid, locationid] }); }, }); } ``` ### Component usage ```tsx // brand starts undefined: the screen opens showing the whole catalogue. // Selecting a brand chip narrows it — it's a filter, never a gate. function CatalogueBrowser({ tenantid, locationid }: Props) { const [brand, setBrand] = useState(undefined); const { data } = useCatalogueProducts(brand); const products = data?.products ?? []; const { data: imported = new Set() } = useImportedCatalogueRefs(tenantid); const importProduct = useImportCatalogueProduct(tenantid, locationid); function handleImport(product: CatalogueProduct, form: ImportForm) { importProduct.mutate([{ tenantid, locationid, brand: product.brand, catalogueid: product.id, categoryid: form.categoryid, subcategoryid: form.subcategoryid, quantity: form.quantity, stocktype: "in", status: "Active", retailprice: form.retailprice, productcost: form.productcost, taxpercent: form.taxpercent, }]); } return (
    {products.map((p) => (
  • {p.product_name} ({p.brand}) {imported.has(`${p.brand}:${p.id}`) ? Imported : handleImport(p, form)} />}
  • ))}
); } ``` ## 5. Gotchas - **Always send `brand` with `catalogueid`.** Ids repeat across brands; either one alone is ambiguous. - **Category/subcategory must already exist for the tenant.** There's no automatic mapping from the catalogue's free-text `category` string to this tenant's `categoryid`/`subcategoryid` yet — the UI must require a pick from `getproductsubcategories` before enabling Import. - **Price is store-set, not catalogue-set.** The catalogue only has a `price_range` display string. `retailprice`/`productcost`/`taxpercent` always come from the store owner's input. - **Re-importing tops up, it doesn't duplicate.** Same `(tenantid, brand, catalogueid)` twice reuses the same product row: quantity adds via the stock ledger, price fields overwrite with whatever was sent that call. - **Delete unlinks, it doesn't erase.** The product row (and any order history referencing it) survives; the item becomes instantly re-importable. - **Known brands today:** `dabur`, `nestle`, `pepsico`, `sakthi`, `manna`, `naga` — pull the live list from `getbrands` rather than hardcoding it. ## 6. Implementation checklist - [ ] API client functions (§4) added to the frontend's API layer, with `API_BASE` pointed at the real environment host. - [ ] Catalogue browse screen: loads with no brand filter (all products), brand/category/keyword as UI filters on top. - [ ] Already-imported badge wired to `getImportedCatalogueRefs`, keyed on `brand:catalogueid`. - [ ] Import action collects `categoryid`, `subcategoryid`, `retailprice`, `productcost`, `taxpercent` from the user before enabling submit. - [ ] Import mutation invalidates both the imported-refs query and the store-catalogue query on success. - [ ] Store catalogue screen (`getlocationproducts`) reflects imports immediately after the above invalidation. - [ ] Remove action wired to `deleteproductlocation`, same invalidation.