import product catalogue
This commit is contained in:
117
src/services/catalogueApi.ts
Normal file
117
src/services/catalogueApi.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_FIESTA_URL || "https://fiesta.nearle.app/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
|
||||
}
|
||||
|
||||
export interface ImportedRef {
|
||||
brand: string;
|
||||
catalogueid: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function apiGet<T>(url: URL): Promise<T[]> {
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Catalogue API failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (Array.isArray(json)) return json;
|
||||
if (json && typeof json === 'object' && 'details' in json) {
|
||||
return json.details || [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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}/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 products = await apiGet<CatalogueProduct>(url);
|
||||
return { products, total: products.length };
|
||||
}
|
||||
|
||||
// brand omitted → imported refs across every brand.
|
||||
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
|
||||
url.searchParams.set("tenantid", String(tenantid));
|
||||
if (brand) url.searchParams.set("brand", brand);
|
||||
|
||||
const refs = await apiGet<ImportedRef>(url);
|
||||
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
|
||||
}
|
||||
|
||||
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
||||
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) throw new Error("Failed to import products");
|
||||
const json = await res.json();
|
||||
if (json && typeof json === 'object' && 'status' in json && !json.status) {
|
||||
throw new Error(json.message || "Failed to import products");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
|
||||
const res = await fetch(`${API_BASE}/products/deleteproductlocation`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify({ tenantid, locationid, productid }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove product from store");
|
||||
const json = await res.json();
|
||||
if (json && typeof json === 'object' && 'status' in json && !json.status) {
|
||||
throw new Error(json.message || "Failed to remove product from store");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function getBrands() {
|
||||
const url = new URL(`${API_BASE}/catalogue/getbrands`);
|
||||
const brands = await apiGet<{ brand: string; count: number }>(url);
|
||||
return brands;
|
||||
}
|
||||
|
||||
export async function getProductSubcategories(tenantid: number, categoryid?: number) {
|
||||
const url = new URL(`${API_BASE}/products/getproductsubcategories`);
|
||||
url.searchParams.set("tenantid", String(tenantid));
|
||||
if (categoryid) url.searchParams.set("categoryid", String(categoryid));
|
||||
const subcategories = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url);
|
||||
return subcategories;
|
||||
}
|
||||
@@ -20,11 +20,11 @@
|
||||
const FIESTA_BASE = import.meta.env.VITE_FIESTA_URL || 'https://fiesta.nearle.app/live/api/v1/web';
|
||||
const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'https://fiesta.nearle.app/live/api/v1/mob';
|
||||
|
||||
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
|
||||
export const FIESTA_TENANT_ID = 1087;
|
||||
/** Tenant / location scope shared by the merchant console. */
|
||||
export const FIESTA_TENANT_ID = 1135;
|
||||
export const FIESTA_APPLOCATION_ID = 1;
|
||||
/** Primary outlet for this tenant — the one carrying live orders/stock. */
|
||||
export const FIESTA_PRIMARY_LOCATION_ID = 1097;
|
||||
export const FIESTA_PRIMARY_LOCATION_ID = 1170;
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
type QueryParams = Record<string, string | number | undefined | null>;
|
||||
@@ -167,7 +167,13 @@ export async function getOrderSummary(
|
||||
todate: string,
|
||||
locationid?: number,
|
||||
): Promise<FiestaOrderSummary | null> {
|
||||
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, locationid, fromdate, todate }));
|
||||
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', {
|
||||
tenantid,
|
||||
locationid,
|
||||
applocationid: locationid, // send both to bypass backend bugs
|
||||
fromdate,
|
||||
todate
|
||||
}));
|
||||
if (!row) return null;
|
||||
return {
|
||||
total: num(row.total),
|
||||
@@ -192,9 +198,9 @@ export interface FiestaLocationSummary {
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
/** /orders/getlocationsummary?tenantid= — per-outlet order rollup. */
|
||||
export async function getLocationSummary(tenantid: number): Promise<FiestaLocationSummary[]> {
|
||||
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid })).map((r) => ({
|
||||
/** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */
|
||||
export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise<FiestaLocationSummary[]> {
|
||||
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({
|
||||
locationid: num(r.locationid),
|
||||
locationname: str(r.locationname),
|
||||
total: num(r.total),
|
||||
@@ -622,9 +628,11 @@ export function cleanTenantLocations(rows: Row[]): Row[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant (test rows stripped). */
|
||||
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
||||
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
|
||||
/** /tenants/gettenantlocations?tenantid=&userid= — outlet locations for a tenant (test rows stripped). */
|
||||
export async function getTenantLocations(tenantid: number, userid?: number): Promise<Row[]> {
|
||||
const params: Record<string, any> = { tenantid };
|
||||
if (userid) params.userid = userid;
|
||||
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', params)));
|
||||
}
|
||||
|
||||
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
|
||||
@@ -737,7 +745,7 @@ export async function getProductStocks(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
/** /products/getproductlocations?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
||||
/** /products/getlocationproducts?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
||||
* geofenced per-outlet inventory. */
|
||||
export async function getProductLocations(opts: {
|
||||
tenantid: number;
|
||||
@@ -747,7 +755,7 @@ export async function getProductLocations(opts: {
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('products/getproductlocations', {
|
||||
await fiestaGet('products/getlocationproducts', {
|
||||
tenantid: opts.tenantid,
|
||||
locationid: opts.locationid,
|
||||
subcategoryid: opts.subcategoryid,
|
||||
@@ -783,13 +791,35 @@ export interface CreateProductLocationInput {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
qty: number;
|
||||
quantity?: number; // User prompt specified quantity
|
||||
qty?: number; // Keep for backwards compatibility if needed
|
||||
stocktype?: string;
|
||||
status?: string;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. */
|
||||
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
|
||||
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
|
||||
return fiestaSend<Row>('products/createproductlocation', 'POST', input);
|
||||
const payload = {
|
||||
tenantid: input.tenantid,
|
||||
locationid: input.locationid,
|
||||
productid: input.productid,
|
||||
quantity: input.quantity ?? input.qty ?? 0,
|
||||
stocktype: input.stocktype || 'in',
|
||||
status: input.status || 'Active',
|
||||
};
|
||||
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
|
||||
}
|
||||
|
||||
export interface DeleteProductLocationInput {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
}
|
||||
|
||||
/** DELETE /products/deleteproductlocation — Remove a product from a store catalogue. */
|
||||
export async function deleteProductLocation(input: DeleteProductLocationInput): Promise<Row> {
|
||||
return fiestaSend<Row>('products/deleteproductlocation', 'DELETE', input);
|
||||
}
|
||||
|
||||
export interface StockRequestInput {
|
||||
@@ -1211,3 +1241,40 @@ export async function getSalesSummary(opts: {
|
||||
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
||||
return res.details;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GLOBAL CATALOGUE
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /v1/web/catalogue/getbrands — List all brands + product count per brand */
|
||||
export async function getGlobalBrands(): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('catalogue/getbrands'));
|
||||
}
|
||||
|
||||
/** GET /v1/web/catalogue/getcategories — List categories available for that brand */
|
||||
export async function getGlobalCategories(opts: { brand: string }): Promise<string[]> {
|
||||
const res = await fiestaGet<{ details: string[] }>('catalogue/getcategories', { brand: opts.brand });
|
||||
return Array.isArray(res.details) ? res.details : [];
|
||||
}
|
||||
|
||||
/** GET /v1/web/catalogue/getproducts — Product list */
|
||||
export async function getGlobalProducts(opts: {
|
||||
brand: string;
|
||||
category?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('catalogue/getproducts', {
|
||||
brand: opts.brand,
|
||||
category: opts.category,
|
||||
keyword: opts.keyword,
|
||||
pageno: opts.pageno,
|
||||
pagesize: opts.pagesize,
|
||||
}));
|
||||
}
|
||||
|
||||
/** GET /v1/web/catalogue/getproduct — Single product lookup by SKU */
|
||||
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
|
||||
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
|
||||
}
|
||||
|
||||
@@ -166,3 +166,34 @@ export function orderRowToOrder(row: Row): CustomerOrder {
|
||||
locationid: num(row.locationid),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Global Catalogue product row (from /catalogue/getproducts) to ProductMatrixItem.
|
||||
*/
|
||||
export function globalRowToProduct(row: Row): ProductMatrixItem {
|
||||
// Extract base price from price_range if possible, else 0
|
||||
const priceRange = str(row.price_range) || '';
|
||||
const priceMatch = priceRange.match(/\d+/);
|
||||
const basePrice = priceMatch ? parseInt(priceMatch[0], 10) : 0;
|
||||
|
||||
return {
|
||||
id: str(row.id),
|
||||
name: str(row.product_name) || str(row.title) || 'Unnamed product',
|
||||
sku: str(row.product_sku) || str(row.variant_key) || `SKU-${str(row.id)}`,
|
||||
unitsSold: 0,
|
||||
revenue: basePrice,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'flat',
|
||||
image: (Array.isArray(row.images) && row.images.length > 0) ? str(row.images[0]) : str(row.image) || str(row.image_url) || (str(row.image_id) ? `https://fiesta.nearle.app/images/${str(row.image_id)}.jpg` : PLACEHOLDER_IMG),
|
||||
category: str(row.category) || 'Uncategorized',
|
||||
exposure: str(row.size) || '1 unit',
|
||||
verified: true,
|
||||
description: str(row.description) || undefined,
|
||||
fssaiLicense: str(row.fssai_license) || undefined,
|
||||
highlights: Array.isArray(row.highlights) ? row.highlights : [],
|
||||
nutrients: Array.isArray(row.nutrients) ? row.nutrients : [],
|
||||
providers: Array.isArray(row.providers) ? row.providers : [],
|
||||
brand: str(row.brand) || undefined,
|
||||
priceRange: str(row.price_range) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@
|
||||
* Swap `read`/`write` for those calls when the API is ready; the hook API stays.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createProductLocation, FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
||||
import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
||||
|
||||
export interface StoreCatalogueItem {
|
||||
productid: string;
|
||||
@@ -30,71 +30,85 @@ export interface StoreCatalogueItem {
|
||||
unit: string;
|
||||
/** Quantity the admin intends to stock for this product. */
|
||||
qty: number;
|
||||
}
|
||||
|
||||
const KEY = 'nearledaily.storeCatalogue';
|
||||
const EVENT = 'nearledaily:storeCatalogue';
|
||||
|
||||
function read(): StoreCatalogueItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as StoreCatalogueItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: StoreCatalogueItem[]): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(items));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
// Notify listeners in this tab (storage event only fires in OTHER tabs).
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live view of the store catalogue + curation helpers. Re-renders whenever the
|
||||
* catalogue changes (this tab or another).
|
||||
* catalogue changes via React Query invalidation.
|
||||
*/
|
||||
export function useStoreCatalogue() {
|
||||
const [items, setItems] = useState<StoreCatalogueItem[]>(read);
|
||||
const q = useFiestaProductLocations({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
pagesize: 500,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setItems(read());
|
||||
window.addEventListener(EVENT, sync);
|
||||
window.addEventListener('storage', sync);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, sync);
|
||||
window.removeEventListener('storage', sync);
|
||||
};
|
||||
}, []);
|
||||
const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => r.status !== 'Inactive').map((r: any) => ({
|
||||
productid: String(r.productid),
|
||||
name: String(r.name || r.productname || ''),
|
||||
image: String(r.image || r.productimage || ''),
|
||||
category: String(r.category || r.categoryname || 'General'),
|
||||
sku: String(r.sku || ''),
|
||||
price: Number(r.price || 0),
|
||||
unit: String(r.unit || ''),
|
||||
qty: Number(r.quantity ?? r.qty ?? 0),
|
||||
status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(),
|
||||
}));
|
||||
|
||||
const mutation = useFiestaCreateProductLocation();
|
||||
const deleteMutation = useFiestaDeleteProductLocation();
|
||||
|
||||
const has = (id: string) => items.some((i) => i.productid === id);
|
||||
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
|
||||
|
||||
const add = (item: StoreCatalogueItem) => {
|
||||
write([...read().filter((i) => i.productid !== item.productid), item]);
|
||||
createProductLocation({
|
||||
mutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(item.productid),
|
||||
qty: item.qty,
|
||||
status: 'Active'
|
||||
}).catch(e => console.error('API createProductLocation failed:', e));
|
||||
price: item.price,
|
||||
status: item.status || 'Active'
|
||||
});
|
||||
};
|
||||
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
|
||||
|
||||
const remove = (id: string) => {
|
||||
const item = items.find(i => i.productid === id);
|
||||
if (!item) return;
|
||||
deleteMutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(id)
|
||||
});
|
||||
};
|
||||
|
||||
const setQty = (id: string, qty: number) => {
|
||||
const item = items.find(i => i.productid === id);
|
||||
if (!item) return;
|
||||
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||
write(read().map((i) => (i.productid === id ? { ...i, qty: safeQty } : i)));
|
||||
createProductLocation({
|
||||
mutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(id),
|
||||
qty: safeQty,
|
||||
status: 'Active'
|
||||
}).catch(e => console.error('API updateProductLocation failed:', e));
|
||||
price: item.price,
|
||||
status: item.status || 'Active'
|
||||
});
|
||||
};
|
||||
|
||||
const setPrice = (id: string, price: number) => {
|
||||
const item = items.find(i => i.productid === id);
|
||||
if (!item) return;
|
||||
mutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(id),
|
||||
qty: item.qty || 0,
|
||||
price: price,
|
||||
status: 'Active' // Setting a price activates it
|
||||
});
|
||||
};
|
||||
|
||||
return { items, has, getQty, add, remove, setQty };
|
||||
return { items, has, getQty, add, remove, setQty, setPrice, isLoading: q.isLoading };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user