From 11baf732b70202d93ce04c3f2b0befb7b6a2840e Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 16 Jul 2026 17:05:06 +0530 Subject: [PATCH] import product catalogue --- package-lock.json | 7 + package.json | 1 + setup-stock-trigger.js | 147 +++ src/App.tsx | 24 +- src/components/CatalogueBrowser.tsx | 201 +++ src/components/CustomerDetailPanel.tsx | 13 +- src/components/DashboardView.tsx | 6 +- src/components/DeliveriesView.tsx | 4 +- src/components/DeliveryReportsView.tsx | 4 +- src/components/DispatchView.tsx | 61 +- src/components/FMCGHoverOverlay.tsx | 36 +- src/components/Header.tsx | 4 +- src/components/ImportProductModal.tsx | 190 +++ src/components/InventoryView.tsx | 994 ++++++++------- src/components/OrdersView.tsx | 63 +- src/components/ReportsView.tsx | 8 +- src/components/SettingsView.tsx | 27 +- src/components/Sidebar.tsx | 12 +- src/components/StoreCatalogView.tsx | 257 ++-- src/components/StoreDetailView.tsx | 2 +- src/components/StoreQRView.tsx | 4 +- src/components/UserStorePage.tsx | 50 +- src/components/UserStoreSidebar.tsx | 6 +- src/components/UsersPanel.tsx | 4 +- src/components/consoleUi.tsx | 6 +- src/hooks/useCatalogueImport.ts | 65 + src/index.css | 22 + src/services/catalogueApi.ts | 117 ++ src/services/fiestaApi.ts | 97 +- src/services/fiestaMappers.ts | 31 + src/services/fiestaQueries.ts | 1604 +++++++++++++----------- src/services/storeCatalogue.ts | 104 +- src/types.ts | 10 +- test-api.js | 37 + test-insight.js | 17 +- 35 files changed, 2722 insertions(+), 1513 deletions(-) create mode 100644 setup-stock-trigger.js create mode 100644 src/components/CatalogueBrowser.tsx create mode 100644 src/components/ImportProductModal.tsx create mode 100644 src/hooks/useCatalogueImport.ts create mode 100644 src/services/catalogueApi.ts create mode 100644 test-api.js diff --git a/package-lock.json b/package-lock.json index 934a1a9..8d83182 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", "@vitejs/plugin-react": "^5.0.4", + "claude": "^0.1.1", "dotenv": "^17.2.3", "express": "^4.21.2", "leaflet": "^1.9.4", @@ -2039,6 +2040,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/claude": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz", + "integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==", + "license": "ISC" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", diff --git a/package.json b/package.json index d730ec6..0b459d3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", "@vitejs/plugin-react": "^5.0.4", + "claude": "^0.1.1", "dotenv": "^17.2.3", "express": "^4.21.2", "leaflet": "^1.9.4", diff --git a/setup-stock-trigger.js b/setup-stock-trigger.js new file mode 100644 index 0000000..80c690d --- /dev/null +++ b/setup-stock-trigger.js @@ -0,0 +1,147 @@ +import dotenv from 'dotenv'; +import fs from 'fs'; +import path from 'path'; + +// Load .env variables +const envPath = path.resolve(process.cwd(), '.env'); +if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); +} + +const HASURA_ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET || 'nearle-admin-secret'; +const HASURA_QUERY_URL = 'https://api.workolik.com/v2/query'; +const HASURA_METADATA_URL = 'https://api.workolik.com/v1/metadata'; + +let sourceName = 'default'; + +async function getSourceName() { + const response = await fetch(HASURA_METADATA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-hasura-admin-secret': HASURA_ADMIN_SECRET, + }, + body: JSON.stringify({ + type: 'export_metadata', + args: {} + }), + }); + + const data = await response.json(); + if (data.error) { + throw new Error(`Hasura Metadata Error: ${data.error}`); + } + + if (data.sources && data.sources.length > 0) { + return data.sources[0].name; + } + return 'default'; +} + +async function runSql(sqlQuery) { + const response = await fetch(HASURA_QUERY_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-hasura-admin-secret': HASURA_ADMIN_SECRET, + }, + body: JSON.stringify({ + type: 'run_sql', + args: { + source: sourceName, + sql: sqlQuery, + cascade: false, + check_metadata_consistency: false, + }, + }), + }); + + const data = await response.json(); + if (data.error) { + throw new Error(`Hasura SQL Error: ${data.error}`); + } + return data; +} + +async function main() { + try { + console.log('πŸ”„ Connecting to Hasura Database...'); + sourceName = await getSourceName(); + console.log(`βœ… Using database source: "${sourceName}"`); + + // 1. Let's introspect the tables to ensure we have the correct table names before running the trigger. + const targetTables = ['orders', 'orderdetails', 'productstocks']; + const columnsCheckSql = ` + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name IN ('orders', 'orderdetails', 'productstocks'); + `; + + console.log('πŸ” Introspecting columns for orders, orderdetails, productstocks...'); + const colData = await runSql(columnsCheckSql); + const cols = colData.result.slice(1); + + const getCols = (table) => cols.filter(r => r[0] === table).map(r => r[1]); + const ordersCols = getCols('orders'); + const orderDetailsCols = getCols('orderdetails'); + const productStockCols = getCols('productstocks'); + + console.log(`βœ… orders columns: ${ordersCols.join(', ')}`); + console.log(`βœ… orderdetails columns: ${orderDetailsCols.join(', ')}`); + console.log(`βœ… productstocks columns: ${productStockCols.join(', ')}`); + + // Determine exact column names + const orderPk = ordersCols.includes('orderheaderid') ? 'orderheaderid' : 'orderid'; + const detailOrderId = orderDetailsCols.includes('orderheaderid') ? 'orderheaderid' : 'orderid'; + const qtyCol = orderDetailsCols.includes('qty') ? 'qty' : 'orderqty'; + const stockCol = productStockCols.includes('physicalstock') ? 'physicalstock' : (productStockCols.includes('closing') ? 'closing' : 'stock'); + + // 2. Define the SQL for the Trigger Functions + console.log('πŸ› οΈ Creating trigger functions...'); + + const createTriggerSql = ` + -- Function to reduce stock when a new order detail is inserted + CREATE OR REPLACE FUNCTION update_stock_on_order_insert() + RETURNS trigger AS $$ + DECLARE + v_locationid INT; + BEGIN + -- Try to fetch locationid from orders + BEGIN + SELECT locationid INTO v_locationid FROM orders WHERE ${orderPk} = NEW.${detailOrderId}; + EXCEPTION WHEN OTHERS THEN + v_locationid := NULL; + END; + + IF v_locationid IS NOT NULL THEN + UPDATE productstocks + SET ${stockCol} = GREATEST(0, COALESCE(${stockCol}, 0) - COALESCE(NEW.${qtyCol}, 1)) + WHERE productid = NEW.productid AND locationid = v_locationid; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + -- Create the trigger on orderdetails table + DROP TRIGGER IF EXISTS trigger_reduce_stock_on_order ON orderdetails; + CREATE TRIGGER trigger_reduce_stock_on_order + AFTER INSERT ON orderdetails + FOR EACH ROW + EXECUTE FUNCTION update_stock_on_order_insert(); + `; + + console.log('Deploying trigger...'); + await runSql(createTriggerSql); + console.log('βœ… Stock Reduction Trigger successfully deployed!'); + + console.log('πŸŽ‰ Setup complete. The database will now automatically reduce physicalstock when an order is created.'); + + } catch (error) { + console.error('❌ Error executing deployment script:'); + console.error(error.message); + } +} + +main(); diff --git a/src/App.tsx b/src/App.tsx index 3a2aeca..23d4bdb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,8 @@ import { Store, Settings, LayoutDashboard, - Users + Users, + Box } from 'lucide-react'; import { MainSection } from './types'; @@ -42,7 +43,7 @@ import { useFiestaCreateLocation, useFiestaOrderSummary, } from './services/fiestaQueries'; -import { FIESTA_TENANT_ID, str as fstr, num as fnum } from './services/fiestaApi'; +import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr, num as fnum } from './services/fiestaApi'; import Sidebar from './components/Sidebar'; import Header from './components/Header'; import DashboardView from './components/DashboardView'; @@ -55,6 +56,7 @@ import LoginView from './components/LoginView'; import UserStorePage from './components/UserStorePage'; import AwaitingApi from './components/AwaitingApi'; import ComparisonModal from './components/ComparisonModal'; +import CatalogueBrowser from './components/CatalogueBrowser'; import type { AuthUser } from './services/auth'; import ragulStoreCover from './assets/images/store_front_view_1780299351800.png'; @@ -581,14 +583,18 @@ export default function App() { ? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console') : currentSection === 'inventory' ? 'Products' - : currentSection === 'dispatch' + : currentSection === 'catalogue' + ? 'Global Catalogue' + : currentSection === 'dispatch' ? 'Console' : currentSection.charAt(0).toUpperCase() + currentSection.slice(1), icon: currentSection === 'dashboard' ? LayoutDashboard : currentSection === 'inventory' ? Layers - : currentSection === 'stores' + : currentSection === 'catalogue' + ? Box + : currentSection === 'stores' ? Store : currentSection === 'reports' ? TrendingUp @@ -614,7 +620,7 @@ export default function App() { {/* Main core pages payload area */}
-
+
} /> + } /> + + } /> diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx new file mode 100644 index 0000000..1ce4ad0 --- /dev/null +++ b/src/components/CatalogueBrowser.tsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import { Search, CheckCircle2, DownloadCloud, Box, PackageOpen } from 'lucide-react'; +import { + useCatalogueProducts, + useCatalogueBrands, + useImportedCatalogueRefs, + useImportCatalogueProduct +} from '../hooks/useCatalogueImport'; +import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi'; +import ImportProductModal from './ImportProductModal'; + +interface CatalogueBrowserProps { + tenantid: number; + locationid: number; +} + +export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrowserProps) { + const [brand, setBrand] = useState(undefined); + const [keyword, setKeyword] = useState(''); + const [debouncedKeyword, setDebouncedKeyword] = useState(''); + + const [importingProduct, setImportingProduct] = useState(null); + + // Debounce search + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedKeyword(keyword); + }, 400); + return () => clearTimeout(handler); + }, [keyword]); + + const { data: catalogueData, isLoading: isLoadingProducts } = useCatalogueProducts(brand, debouncedKeyword); + const products = catalogueData?.products ?? []; + const total = catalogueData?.total ?? 0; + + const { data: brandsData = [], isLoading: isLoadingBrands } = useCatalogueBrands(); + + const { data: importedRefs = new Set() } = useImportedCatalogueRefs(tenantid, brand); + + const importProductMutation = useImportCatalogueProduct(tenantid, locationid); + + const handleImportSubmit = (item: ImportCatalogueProductRequest) => { + importProductMutation.mutate([item], { + onSuccess: () => { + setImportingProduct(null); + }, + onError: (err: any) => { + alert(err.message || 'Failed to import product.'); + } + }); + }; + + return ( +
+ + {/* Top Filters */} +
+
+
+

+ + Global Catalogue +

+

+ Browse {total > 0 ? total : ''} global FMCG products and import them to your store. +

+
+ +
+ + setKeyword(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 focus:bg-white transition-all" + /> +
+
+ + {/* Brands Chip Row */} +
+

Filter by Brand

+
+ + {isLoadingBrands ? ( + Loading brands... + ) : ( + brandsData.map(b => ( + + )) + )} +
+
+
+ + {/* Product Grid */} +
+ {isLoadingProducts ? ( +
+
+ Loading catalogue... +
+ ) : products.length === 0 ? ( +
+ +

No products found in the catalogue.

+
+ ) : ( +
+ {products.map(p => { + const isImported = importedRefs.has(`${p.brand}:${p.id}`); + + return ( +
+ {isImported && ( +
+ + Imported +
+ )} + +
+ {p.images && p.images.length > 0 ? ( + {p.product_name} + ) : ( + + )} +
+ +
+
{p.brand.replace('brand_', '')}
+

+ {p.product_name} +

+ +
+
+ {p.size || 'N/A'} + {p.price_range || 'Price N/A'} +
+ + {!isImported && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ + {importingProduct && ( + setImportingProduct(null)} + onImport={handleImportSubmit} + /> + )} + + {importProductMutation.isPending && ( +
+
+
Importing product...
+
+ )} +
+ ); +} diff --git a/src/components/CustomerDetailPanel.tsx b/src/components/CustomerDetailPanel.tsx index fb19121..27e666c 100644 --- a/src/components/CustomerDetailPanel.tsx +++ b/src/components/CustomerDetailPanel.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from 'react'; import { useFiestaCustomerOrders } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; -import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee } from 'lucide-react'; +import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react'; import OrderDetailsModal from './OrderDetailsModal'; import './CustomerDetailPanel.css'; @@ -108,6 +108,17 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
+ {fstr(customer.locationname) || fstr(customer.storename) ? ( +
+ + {fstr(customer.locationname) || fstr(customer.storename)} +
+ ) : fnum(customer.locationid) ? ( +
+ + Store {fnum(customer.locationid)} +
+ ) : null} {phone && ( diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 215cce5..b3bc658 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -57,7 +57,7 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID const monthlyRevenue = insight ? Number(insight.grossrevenue || insight.overallrevenue || insight.revenue || 0) : null; const monthlyProfit = insight ? Number(insight.profit || insight.netrevenue || insight.margin || 0) : null; - const locSummaryQ = useFiestaLocationSummary(tenantId); + const locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate); const summaries = locSummaryQ.data ?? []; // Region fulfillment β€” live month-to-date delivered Γ· total orders for the tenant. @@ -133,9 +133,9 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID return (
{/* ── Immersive Executive Banner (cover image + slateβ†’purple gradient overlay) ── */} -
+
{/* Cover image background & decorative glow */} -
+
Executive operations dashboard {/* Table */} -
+
@@ -268,7 +268,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void } // in the view tree is transformed/blurred (otherwise the panel collapses). return createPortal(
{ if (e.target === e.currentTarget) onClose(); }}> -
+

{fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}

diff --git a/src/components/DeliveryReportsView.tsx b/src/components/DeliveryReportsView.tsx index 05e9db1..3b96ca8 100644 --- a/src/components/DeliveryReportsView.tsx +++ b/src/components/DeliveryReportsView.tsx @@ -121,7 +121,7 @@ const Cnt = ({ n, color }: { n: number; color: string }) => ( function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenantId: number; locationid?: number; fromdate: string; todate: string; }) { const [metric, setMetric] = useState<'revenue' | 'orders'>('revenue'); - const q = useFiestaLocationSummary(tenantId); + const q = useFiestaLocationSummary(tenantId, fromdate, todate); const ordersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid }); const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate, todate }); @@ -151,7 +151,7 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan return `${d.getDate()} ${months[d.getMonth()]}`; }; - const totalRevenue = revenueQ.data?.grossrevenue ?? 0; + const totalRevenue = revenueQ.data?.overallrevenue ?? 0; const locationRevenueMap = useMemo(() => { const map = new Map(); for (const r of (ordersQ.data ?? [])) { diff --git a/src/components/DispatchView.tsx b/src/components/DispatchView.tsx index bdb87a0..84b7f33 100644 --- a/src/components/DispatchView.tsx +++ b/src/components/DispatchView.tsx @@ -131,6 +131,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, setFocusedId(null); }, [viewMode]); + const [customerStoreFilter, setCustomerStoreFilter] = useState('all'); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned'); const [animateNonce, setAnimateNonce] = useState(0); @@ -167,6 +168,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, if (viewMode === 'stores' && locationsQ.data) { for (const loc of locationsQ.data) { + if (locationid && fnum(loc.locationid) !== locationid) continue; const id = String(fnum(loc.locationid)).toLowerCase(); const name = fstr(loc.locationname) || `Store ${id}`; map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} }); @@ -175,6 +177,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, if (viewMode === 'customers' && customersQ.data) { for (const cust of customersQ.data) { + if (customerStoreFilter !== 'all') { + const locId = String(fnum(cust.locationid)); + if (locId !== customerStoreFilter) continue; + } const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase(); const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`; map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, raw: cust }); @@ -221,6 +227,12 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name)); }, [rows, viewMode, locationsQ.data, customersQ.data]); + useEffect(() => { + if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) { + setFocusedId(groups[0].id); + } + }, [viewMode, locationid, groups, focusedId]); + const focused = groups.find((g) => g.id === focusedId) ?? null; const groupedByRider = viewMode !== 'riders'; @@ -361,7 +373,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, groupedByRider={groupedByRider} tripSort={tripSort} setTripSort={setTripSort} - onBack={() => setFocusedId(null)} + onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)} fmtTime={fmtTime} riderLogs={riderLogsQ.data} riderLogsLoading={riderLogsQ.isLoading} @@ -370,8 +382,31 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
{viewMode === 'customers' ? 'No customers found' : 'No deliveries for this day'}
) : ( <> -
- {viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length}) +
+ {viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length}) + {viewMode === 'customers' && !locationid && locationsQ.data && ( +
+ +
+ +
+
+ )}
{groups.map((g) => { const isSelected = focusedId === g.id; @@ -580,16 +615,18 @@ function FocusedDetail({ groupedByRider: boolean; tripSort: 'planned' | 'time'; setTripSort: (v: 'planned' | 'time') => void; - onBack: () => void; + onBack?: () => void; fmtTime: (raw: unknown) => string; riderLogs?: Row[]; riderLogsLoading?: boolean; }) { return ( <> - + {onBack && ( + + )} {riderLogs && riderLogs.length > 0 && ( )} + {tripBlocks.length === 0 && ( +
+
+ +
+

No orders to display

+

There are no deliveries matching this selection for the current date.

+
+ )} + {tripBlocks.map((blk, bi) => (
diff --git a/src/components/FMCGHoverOverlay.tsx b/src/components/FMCGHoverOverlay.tsx index f6b966a..1a0735a 100644 --- a/src/components/FMCGHoverOverlay.tsx +++ b/src/components/FMCGHoverOverlay.tsx @@ -6,9 +6,10 @@ interface Props { productId: string; category: string; productName: string; + product?: any; // We'll pass the full product object here } -export default function FMCGHoverOverlay({ productId, category, productName }: Props) { +export default function FMCGHoverOverlay({ productId, category, productName, product }: Props) { const details = generateFMCGDetails(productId, category); return ( @@ -32,21 +33,36 @@ export default function FMCGHoverOverlay({ productId, category, productName }: P {/* 2. Ingredients & Legal (Back Panel) */}
-
-
- Ingredients -
-

{details.ingredients}

-
- {details.allergens} + {product?.description && ( +
+
+ Description +
+

{product.description}

-
+ )} + + {(product?.nutrients?.length > 0 || !product) && ( +
+
+ {product ? 'Nutritional Highlights' : 'Ingredients'} +
+

+ {product?.nutrients?.length > 0 ? product.nutrients.join(' β€’ ') : details.ingredients} +

+ {!product && ( +
+ {details.allergens} +
+ )} +
+ )}
FSSAI / Storage
-

Lic No. {details.fssai}

+

Lic No. {product?.fssaiLicense || details.fssai}

{details.storage}

diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8d6876c..02a074f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -70,7 +70,7 @@ export default function Header({ .toUpperCase() || 'NA'; return ( -
+
{/* Brand & Desktop Navigation Tabs */}
{/* Brand cell β€” width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */} @@ -80,7 +80,7 @@ export default function Header({ }`} > {/* Brand Logo β€” full wordmark when sidebar open, icon only when collapsed */} - + nearledaily logo void; + onImport: (item: ImportCatalogueProductRequest) => void; +} + +export default function ImportProductModal({ + product, + tenantid, + locationid, + onClose, + onImport +}: ImportProductModalProps) { + const [categoryId, setCategoryId] = useState(''); + const [subcategoryId, setSubcategoryId] = useState(''); + const [retailPrice, setRetailPrice] = useState(''); + const [productCost, setProductCost] = useState(''); + const [taxPercent, setTaxPercent] = useState('0'); + const [quantity, setQuantity] = useState('1'); + + // Load subcategories for this tenant (optional: filter by categoryId if category picker is also dynamic) + // The spec says "getproductsubcategories" with optional categoryid. We will fetch all for now and pick. + const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories(tenantid); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!categoryId || !subcategoryId || !retailPrice || !productCost) { + alert("Please fill in all required fields."); + return; + } + + onImport({ + tenantid, + locationid, + brand: product.brand, + catalogueid: product.id, + categoryid: Number(categoryId), + subcategoryid: Number(subcategoryId), + quantity: Number(quantity), + stocktype: "in", + status: "Active", + retailprice: Number(retailPrice), + productcost: Number(productCost), + taxpercent: Number(taxPercent), + }); + }; + + return ( +
+
+ + {/* Header */} +
+
+

Import Product

+

{product.product_name} ({product.brand})

+
+ +
+ + {/* Body */} +
+
+ +
+ +

You need to map this catalogue product to your store's categories and set your own pricing.

+
+ +
+
+ + setCategoryId(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors" + placeholder="e.g. 1" + required + /> +
+
+ + {isLoadingSubcats ? ( +
Loading...
+ ) : ( + + )} +
+
+ +
+
+ +
+ β‚Ή + setRetailPrice(e.target.value)} + className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-colors" + placeholder="0.00" + required + /> +
+
+
+ +
+ β‚Ή + setProductCost(e.target.value)} + className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-colors" + placeholder="0.00" + required + /> +
+
+
+ +
+
+ +
+ setTaxPercent(e.target.value)} + className="w-full pr-8 pl-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors" + placeholder="0" + /> + % +
+
+
+ + setQuantity(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors" + required + /> +
+
+ + +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 68b33f3..1410f53 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -33,19 +33,22 @@ import { Inbox, Store, Activity, - Award + Award, + Filter } from 'lucide-react'; import { ProductMatrixItem } from '../types'; import { useFiestaTenantLocations, useFiestaStoresStock, useFiestaProductCategories, - useFiestaMasterCatalog, useFiestaUpdateStockRequest, useFiestaGetStockRequests, + useFiestaGlobalBrands, + useFiestaGlobalCategories, + useFiestaGlobalProducts, } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi'; -import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; +import { stockRowToProduct, stockRowToInventory, globalRowToProduct } from '../services/fiestaMappers'; import { useStoreCatalogue } from '../services/storeCatalogue'; import BulkCartDrawer from './BulkCartDrawer'; import AwaitingApi from './AwaitingApi'; @@ -53,58 +56,7 @@ import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BOR import FMCGHoverOverlay from './FMCGHoverOverlay'; import { useCompare } from '../contexts/CompareContext'; -const MOCK_GLOBAL_CATALOG: ProductMatrixItem[] = [ - { - id: 'gc-1', name: 'Aavin Pure Cow Milk 500ml', sku: 'GC-AAV-500', category: 'Dairy / Milk', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: false, - image: 'https://images.unsplash.com/photo-1550583724-b2692b85b150?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-2', name: 'Britannia Whole Wheat Bread', sku: 'GC-BRI-BREAD', category: 'Bakery / Bread', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: false, - image: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-3', name: 'Heritage Farm Fresh Curd 400g', sku: 'GC-HER-CURD', category: 'Dairy / Curd', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: false, - image: 'https://images.unsplash.com/photo-1588669527025-a131238ba4fb?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-4', name: 'Tata Salt Vacuum Evaporated 1kg', sku: 'GC-TAT-SALT', category: 'Staples / Spices', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: false, - image: 'https://images.unsplash.com/photo-1621245039234-f81d113daef4?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-5', name: 'Sunfeast Dark Fantasy Choco Fills', sku: 'GC-SUN-CHOC', category: 'Snacks / Biscuits', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: false, - image: 'https://images.unsplash.com/photo-1558961363-fa8fdf82db35?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-6', name: 'Nandini GoodLife UHT Milk 500ml', sku: 'GC-NAN-UHT', category: 'Dairy / Milk', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: true, - image: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-7', name: 'Maggi 2-Minute Noodles Masala 70g', sku: 'GC-MAG-NOO', category: 'Snacks / Instant', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: true, - image: 'https://images.unsplash.com/photo-1612817288484-6f916006741a?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-8', name: 'Dettol Original Bathing Soap 75g', sku: 'GC-DET-SOAP', category: 'Personal Care / Soap', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: true, - image: 'https://images.unsplash.com/photo-1584824486509-112e4181ff6b?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-9', name: 'Surf Excel Easy Wash Detergent 1kg', sku: 'GC-SUR-WASH', category: 'Home Care / Laundry', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: true, - image: 'https://images.unsplash.com/photo-1583947215259-38e31be8751f?auto=format&fit=crop&q=80&w=200' - }, - { - id: 'gc-10', name: 'MTR Rava Idli Mix 500g', sku: 'GC-MTR-IDL', category: 'Staples / Ready to Cook', - unitsSold: 0, revenue: 0, stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', verified: true, isNew: true, - image: 'https://images.unsplash.com/photo-1589301760014-d929f39ce9b0?auto=format&fit=crop&q=80&w=200' - }, -]; + type StockRow = Record; const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? ''); @@ -114,12 +66,14 @@ interface InventoryViewProps { searchQuery: string; isCoimbatoreView: boolean; tenantId?: number; + isSidebarOpen?: boolean; } export default function InventoryView({ searchQuery, isCoimbatoreView, - tenantId = FIESTA_TENANT_ID + tenantId = FIESTA_TENANT_ID, + isSidebarOpen = false }: InventoryViewProps) { const { selectedProducts, toggleProduct, setIsComparing, clearSelection, setHideCompareBar } = useCompare(); const [searchTerm, setSearchTerm] = useState(''); @@ -149,43 +103,109 @@ export default function InventoryView({ locationsQ.isError || (storesStock.length > 0 && storesStock.every((s) => s.isError)); // Global catalog = deduped union of every outlet's products, plus anything the - // admin adds/imports in-session. Seeded once from the live data. - const [products, setProducts] = useState([]); - const [seeded, setSeeded] = useState(false); + // admin adds/imports in-session. Computes live from storesStock and storeCat.items. const [selectedAdminProduct, setSelectedAdminProduct] = useState(null); const [selectedRequest, setSelectedRequest] = useState(null); const allStoreRows = storesStock.flatMap((s) => s.rows); - useEffect(() => { - if (seeded || allStoreRows.length === 0) return; - const byId = new Map(); - allStoreRows.forEach((r) => { - const id = rowId(r); - if (id && !byId.has(id)) byId.set(id, r); - }); - - const initialProducts = Array.from(byId.values()).map(stockRowToProduct); - - setProducts(initialProducts); - setSeeded(true); - }, [allStoreRows, seeded]); - const masterCatalogQ = useFiestaMasterCatalog({ tenantid: tenantId }); - const liveMasterCatalog = useMemo(() => masterCatalogQ.data ?? [], [masterCatalogQ.data]); + const globalBrandsQ = useFiestaGlobalBrands(); + const [globalCatalogSearch, setGlobalCatalogSearch] = useState(''); + const [debouncedGlobalSearch, setDebouncedGlobalSearch] = useState(''); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedGlobalSearch(globalCatalogSearch); + }, 500); + return () => clearTimeout(handler); + }, [globalCatalogSearch]); + + const [globalSelectedBrand, setGlobalSelectedBrand] = useState(''); + + const globalCategoriesQ = useFiestaGlobalCategories(globalSelectedBrand); + const [globalSelectedCategories, setGlobalSelectedCategories] = useState([]); + + const globalProductsQ = useFiestaGlobalProducts({ + brand: globalSelectedBrand, + keyword: debouncedGlobalSearch, + pagesize: 500 + }); + const liveMasterCatalog = useMemo(() => globalProductsQ.data ?? [], [globalProductsQ.data]); + const masterCatalogProducts = useMemo(() => liveMasterCatalog.map(globalRowToProduct), [liveMasterCatalog]); const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding' | 'requests'>('catalog'); + const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true); + const [isGlobalSidebarOpen, setIsGlobalSidebarOpen] = useState(true); const [selectedCategories, setSelectedCategories] = useState([]); const [hoveredAdminProduct, setHoveredAdminProduct] = useState(null); const [localSearch, setLocalSearch] = useState(''); const storeCat = useStoreCatalogue(); - const [globalCatalogSearch, setGlobalCatalogSearch] = useState(''); const [globalCatalogPicks, setGlobalCatalogPicks] = useState>(new Set()); const [csvText, setCsvText] = useState(''); + const [importPrice, setImportPrice] = useState(''); + const [isSettingPrice, setIsSettingPrice] = useState(false); + const [addingPriceProdId, setAddingPriceProdId] = useState(null); + const [cardImportPrice, setCardImportPrice] = useState(''); - const [requestStoreFilter, setRequestStoreFilter] = useState('All Stores'); + useEffect(() => { + if (selectedAdminProduct) { + setImportPrice( + selectedAdminProduct.price + ? String(selectedAdminProduct.price) + : String(selectedAdminProduct.unitsSold > 0 ? Math.round(selectedAdminProduct.revenue / selectedAdminProduct.unitsSold) : 0) + ); + setIsSettingPrice(false); + } else { + setImportPrice(''); + setIsSettingPrice(false); + } + }, [selectedAdminProduct]); - const [storeRequests, setStoreRequests] = useState<{ locationid: number, locationname: string, picks: Record }[]>([]); - const stockRequestsQ = useFiestaGetStockRequests({ tenantid: tenantId, pagesize: 1000 }); + const products = useMemo(() => { + const byId = new Map(); + + allStoreRows.forEach((r) => { + const id = rowId(r); + if (id && !byId.has(id)) byId.set(id, stockRowToProduct(r)); + }); + + // Ensure all items published to the store catalogue or imported as drafts are preserved + storeCat.items.forEach((item) => { + const id = String(item.productid); + if (!byId.has(id)) { + byId.set(id, { + id: id, + name: item.name, + sku: item.sku || `SKU-${item.productid}`, + image: item.image, + category: item.category, + price: item.price, + unitsSold: 0, + revenue: 0, + stockStatus: 'Healthy', + trend: 'flat', + exposure: 'All Outlets', + verified: item.status === 'Active', // Active means it's fully published with price + }); + } else { + // If it exists but we have curated price in storeCat, we could apply it here if needed + const existing = byId.get(id)!; + existing.price = item.price || existing.price; + existing.verified = existing.verified || item.status === 'Active'; + } + }); + + return Array.from(byId.values()); + }, [allStoreRows, storeCat.items]); + + const [requestStoreFilter, setRequestStoreFilter] = useState('All Stores'); + const [requestStatusFilter, setRequestStatusFilter] = useState('All Statuses'); + const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]); + + // Fetch store stock requests + const stockRequestsQ = useFiestaGetStockRequests({ tenantid: tenantId, pagesize: 1000, date: requestDate }); + + const [storeRequests, setStoreRequests] = useState<{ locationid: number, locationname: string, picks: Record }[]>([]); useEffect(() => { if (activeTab === 'requests' && stockRequestsQ.data) { @@ -199,12 +219,14 @@ export default function InventoryView({ picks: {} }; } - if (!reqsMap[req.locationid].picks[req.productid]) { - reqsMap[req.locationid].picks[req.productid] = { + // Key by requestid so multiple requests for the same product are all shown + if (!reqsMap[req.locationid].picks[req.requestid]) { + reqsMap[req.locationid].picks[req.requestid] = { qty: req.qty, status: req.status, requestedAt: req.created, resolvedAt: req.updated, + productid: req.productid, productname: req.productname, productimage: req.productimage, requestid: req.requestid @@ -223,17 +245,7 @@ export default function InventoryView({ const updateStockRequestMutation = useFiestaUpdateStockRequest(); - const updateProductRequestStatus = (locationid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => { - // Find the requestid from the current state - let requestid = 0; - storeRequests.forEach(r => { - if (r.locationid === locationid && r.picks[productid]) { - requestid = r.picks[productid].requestid; - } - }); - - if (!requestid) return; - + const updateProductRequestStatus = (locationid: number, requestid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => { updateStockRequestMutation.mutate({ tenantid: tenantId, locationid, @@ -244,12 +256,12 @@ export default function InventoryView({ // Optimistic UI update setStoreRequests(prev => prev.map(r => { - if (r.locationid === locationid && r.picks[productid]) { + if (r.locationid === locationid && r.picks[requestid]) { return { ...r, picks: { ...r.picks, - [productid]: { ...r.picks[productid], status, resolvedAt: new Date().toISOString() } + [requestid]: { ...r.picks[requestid], status, resolvedAt: new Date().toISOString() } } }; } @@ -257,9 +269,9 @@ export default function InventoryView({ })); }; - // Hide compare bar when not in Global Catalogue + // Hide compare bar everywhere for now useEffect(() => { - setHideCompareBar(activeTab !== 'import_branding'); + setHideCompareBar(true); }, [activeTab, setHideCompareBar]); // Live product categories (for the Add-Product modal dropdown). @@ -311,15 +323,23 @@ export default function InventoryView({ ); }; - const [globalSelectedCategories, setGlobalSelectedCategories] = useState([]); + // Note: globalSelectedCategories is declared at the top now! const globalCategories = useMemo(() => { - const cats = Array.from(new Set(MOCK_GLOBAL_CATALOG.map((p) => p.category.split(' / ')[0]))); - return cats.sort(); - }, []); + if (!globalCategoriesQ.data) return []; + // The new API returns an array of strings for categories + return (globalCategoriesQ.data as unknown as string[]).filter(Boolean); + }, [globalCategoriesQ.data]); + + const toggleGlobalCategory = (cat: string) => { + setGlobalSelectedCategories((prev) => + prev.includes(cat) ? [] : [cat], // Only allow one at a time for API compatibility, or filter client-side + ); + }; const filteredGlobalProducts = useMemo(() => { - let result = MOCK_GLOBAL_CATALOG; + let result = masterCatalogProducts; + // We already pass keyword to API, but can double filter client-side just in case if (globalCatalogSearch) { const q = globalCatalogSearch.toLowerCase(); result = result.filter( @@ -330,20 +350,13 @@ export default function InventoryView({ result = result.filter((p) => globalSelectedCategories.includes(p.category.split(' / ')[0])); } return result; - }, [globalCatalogSearch, globalSelectedCategories]); + }, [masterCatalogProducts, globalCatalogSearch, globalSelectedCategories]); - const toggleGlobalCategory = (cat: string) => { - setGlobalSelectedCategories((prev) => - prev.includes(cat) ? [] : [cat], - ); - }; const handleToggleProductExposure = (id: string) => { - setProducts(prev => - prev.map(p => p.id === id ? { ...p, verified: !p.verified } : p) - ); + // Left empty for now, as products are managed via storeCat }; // Custom Raw CSV import @@ -385,7 +398,7 @@ export default function InventoryView({ }); if (parsedCount > 0) { - setProducts(prev => [...newProds, ...prev]); + newProds.forEach(p => storeCat.add({ productid: p.id, name: p.name, sku: p.sku, category: p.category, price: p.price || 0, image: p.image, unit: p.exposure, qty: 100, status: 'Active' })); alert(`Synchronized ${parsedCount} regional products into Catalogue database successfully!`); } else { alert('All the specified SKU codes are already active in the catalogue ledger.'); @@ -396,8 +409,9 @@ export default function InventoryView({ .filter(r => requestStoreFilter === 'All Stores' || r.locationname === requestStoreFilter) .flatMap(store => { return Object.entries(store.picks) - .filter(([_, pick]) => String(pick.status).toLowerCase() === 'pending') - .map(([productId, pick]) => { + .filter(([_, pick]) => requestStatusFilter === 'All Statuses' || String((pick as any).status).toLowerCase() === requestStatusFilter.toLowerCase()) + .map(([requestId, pick]) => { + const productId = (pick as any).productid; let product = products.find(p => String(p.id) === String(productId)); if (!product) { const liveMatch = liveMasterCatalog.find((r: any) => String(r.productid) === String(productId)); @@ -410,20 +424,21 @@ export default function InventoryView({ category: String(liveMatch.categoryname || 'Uncategorized'), } as any; } else { - product = MOCK_GLOBAL_CATALOG.find(p => String(p.id) === String(productId)) as any; + product = masterCatalogProducts.find(p => String(p.id) === String(productId)) as any; } } return { locationid: store.locationid, locationname: store.locationname, productid: productId, + requestid: Number(requestId), product, pickData: pick }; }); }) .sort((a, b) => new Date(b.pickData.requestedAt).getTime() - new Date(a.pickData.requestedAt).getTime()); - }, [storeRequests, products, requestStoreFilter, liveMasterCatalog]); + }, [storeRequests, products, requestStoreFilter, requestStatusFilter, liveMasterCatalog]); const requestingStores = useMemo(() => { return Array.from(new Set(locations.map(l => l.locationname))).sort(); @@ -438,51 +453,51 @@ export default function InventoryView({ {/* Header and Metrics */} {activeTab === 'catalog' && ( -
+
{/* Small card metrics grid */} -
+
{/* Card 1: Total SKUs */} -
+
- Total SKUs -
+ Total SKUs +
-
-

+
+

{products.length}

-

Master catalogue

+

Master catalogue

{/* Card 2: Synced Outlets */} -
+
- Active Outlets -
+ Active Outlets +
-
-

+
+

{locations.length}

-

Synced locations

+

Synced locations

{/* Card 3: Total On-Hand Volume */} -
+
- Total Stock -
+ Total Stock +
-
-

+
+

{storesStock.reduce((total, store) => { return total + (store.rows || []).reduce((subTotal, r) => { const inv = stockRowToInventory(r, store.locationname); @@ -490,23 +505,23 @@ export default function InventoryView({ }, 0); }, 0).toLocaleString('en-IN')}

-

Units on hand

+

Units on hand

{/* Card 4: Catalog Health */} -
+
- Catalogue Sync Ratio -
+ Catalogue Sync Ratio +
-
-

+
+

{products.length > 0 ? `${Math.round((products.filter(p => p.verified).length / products.length) * 100)}%` : '100%'}

-

Active Portfolio

+

Active Portfolio

@@ -516,41 +531,20 @@ export default function InventoryView({ {activeTab === 'catalog' ? ( <>
- {/* ── Sticky Sidebar (Filters) ── */} -
-
-
-

- Filter Product + {isLocalSidebarOpen && ( +
+
+
+

+ Filter Product

-
- {/* Search Input */} -
- setLocalSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] focus:bg-white focus:ring-1 focus:ring-[#662582]/20 transition-all font-medium" - /> - - {localSearch && ( - - )} -
- -
+

Categories

-
+
{categories.map((cat) => (
-
+ )} {/* ── Main Content Area ── */} -
-
-
+
+
+

- Product Catalogue Assortment -
- + Product Catalogue Assortment +
+ {storeCat.items.length} in store catalogue - + {filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded

-

Pick products & set quantities β€” selected items appear in every store's catalogue.

+

Pick products & set quantities β€” selected items appear in every store's catalogue.

-
+
+ + +
+ setLocalSearch(e.target.value)} + className="w-full h-full pl-8 pr-3 py-1.5 bg-white border border-slate-200 rounded-lg text-[10px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium" + /> + + {localSearch && ( + + )} +
+ {storesLoading && products.length === 0 ? ( -
+
{Array.from({ length: 10 }).map((_, i) => (
@@ -641,16 +662,16 @@ export default function InventoryView({
{/* Left Side: Normal Catalogue */}
-
+
{filteredProducts.map((prod) => (
setSelectedAdminProduct(prod)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden"> {/* Image Section - Top */} -
+
{prod.name}
@@ -672,11 +693,13 @@ export default function InventoryView({
{/* Cannot be removed from local catalogue if it is active in the store catalogue */} - {!storeCat.has(prod.id) && ( + {!(storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active') && (
- {storeCat.has(prod.id) ? ( + {storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active' ? (
In Store Catalogue
+ ) : addingPriceProdId === prod.id ? ( +
e.stopPropagation()}> + + setCardImportPrice(e.target.value)} + placeholder="e.g. 50" + className="px-2 py-1.5 border border-slate-200 rounded-md text-[11px] font-bold focus:outline-none focus:border-[#662582]" + autoFocus + /> +
+ + +
+
) : ( -
-
-

+
+
+

Filter Global Product

-
- {/* Search Input */} -
- setGlobalCatalogSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] focus:bg-white focus:ring-1 focus:ring-[#662582]/20 transition-all font-medium" - /> - - {globalCatalogSearch && ( - - )} -
- -
-
-

- Categories -

-
- {globalCategories.map((cat) => ( - - ))} +
+
+

+ Brands +

+
+ {globalBrandsQ.isLoading ? ( + Loading brands... + ) : (globalBrandsQ.data || []).map((b) => { + const brandName = String(b.brand || b.name || b.brandname || b.id || 'Unknown'); + return ( +
+ {globalSelectedBrand && ( + <> +
+
+

+ Categories +

+
+ {globalCategoriesQ.isLoading ? ( + Loading categories... + ) : globalCategories.map((cat) => ( + + ))} +
+
+ + )} + {(globalSelectedCategories.length > 0 || globalCatalogSearch) && (
+ )} {/* ── Main Content Area ── */} -
-
+
+ + {/* Global Catalogue Header with Search and Filter */} +

+ {!isGlobalSidebarOpen && ( + + )} Global Catalogue
@@ -825,13 +911,39 @@ export default function InventoryView({

Select products from the global ledger to import them into your own catalogue.

-
+
+ + +
+ setGlobalCatalogSearch(e.target.value)} + className="w-full h-full pl-9 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium" + /> + + {globalCatalogSearch && ( + + )} +
+ {globalCatalogPicks.size > 0 && (
+ )}
{/* Right Side: Recently Added Items */} @@ -1009,24 +1118,11 @@ export default function InventoryView({
{filteredGlobalProducts.filter(p => p.isNew).map((prod) => { const isAlreadyInCatalog = products.some(p => p.sku === prod.sku); - - const wholesalePrice = prod.unitsSold > 0 ? Math.max(1, Math.round(prod.revenue / prod.unitsSold)) : Math.floor(Math.random() * 50) + 20; - const mrp = Math.round(wholesalePrice * 1.35); - const profitMargin = Math.round(((mrp - wholesalePrice) / mrp) * 100); - const globalSales = Math.floor(Math.random() * 5000) + 1000; const rating = Number((4.0 + Math.random()).toFixed(1)); - const premiumTagSeed = (prod.name.length + prod.sku.length) % 3; - const premiumTag = premiumTagSeed === 0 ? 'Great Deal' : premiumTagSeed === 1 ? 'High Margin' : 'Trending'; - const tagColor = premiumTag === 'Great Deal' ? 'bg-emerald-100 text-emerald-800 border-emerald-200' : - premiumTag === 'High Margin' ? 'bg-amber-100 text-amber-800 border-amber-200' : - 'bg-indigo-100 text-indigo-800 border-indigo-200'; - const tagIcon = premiumTag === 'Great Deal' ? : - premiumTag === 'High Margin' ? : - ; - const sparklineColor = premiumTag === 'Great Deal' ? 'text-emerald-500' : - premiumTag === 'High Margin' ? 'text-amber-500' : - 'text-indigo-500'; + const tagIcon = ; + const tagColor = 'bg-indigo-100 text-indigo-800 border-indigo-200'; + const premiumTag = prod.brand || 'Global'; return (
{/* Image Section - Top */} -
+
{prod.name}
@@ -1070,53 +1166,25 @@ export default function InventoryView({ {tagIcon} {premiumTag}
- -
{ - e.stopPropagation(); - toggleProduct({ - id: prod.id, - name: prod.name, - sku: prod.sku, - category: prod.category.split(' / ')[0], - price: mrp, - image: prod.image, - wholesalePrice, - mrp, - profitMargin, - rating, - globalSales, - isGlobal: true, - verified: prod.verified - }); - }} - > - -
-
-
- Wholesale - β‚Ή{wholesalePrice} -
-
- Margin - +{profitMargin}% -
-
- Retail - β‚Ή{mrp} +
+
+ Price Range + {prod.priceRange || 'N/A'}
+ {prod.providers && prod.providers.length > 0 && ( +
+ {prod.providers.slice(0, 3).map(provider => ( + + {provider} + + ))} + {prod.providers.length > 3 && ( + +{prod.providers.length - 3} more + )} +
+ )}
@@ -1136,7 +1204,7 @@ export default function InventoryView({
) : activeTab === 'requests' ? ( -
-
+
+
+
-

+

Pending Stock Requests

-
+
Store Branch Inventory Requests {flattenedRequests.length} Total @@ -1177,10 +1252,11 @@ export default function InventoryView({
+ setRequestStatusFilter(e.target.value)} + className="appearance-none bg-white border border-slate-200 text-slate-700 pl-4 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20" + > + + + + + + +
+ +
+
+ +
+ setRequestDate(e.target.value)} + className="px-3 py-2 border border-slate-200 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-sm font-semibold text-slate-700 bg-white hover:border-[#662582]/40 hover:shadow-md transition-all" + /> +
{flattenedRequests.length === 0 ? ( @@ -1291,14 +1386,14 @@ export default function InventoryView({ {isPending ? (
- {/* Clean Margin Engine */} + {/* Real Price and Providers */}
-

- Margin Summary -

-
- Wholesale Price -
β‚Ή{wholesalePrice}
-
- -
- -
- Retail (MRP) -
β‚Ή{mrp}
-
- -
- -
- Your Profit -
β‚Ή{profit} ({profitMargin}%)
+ Price Range +
{selectedAdminProduct.priceRange || 'N/A'}
-
- - {/* Minimal Global Stats */} -
-
- - Monthly Sales - - - {Math.floor(Math.random() * 5000) + 1000} Units - -
-
- - Global Rating - - - {(4.0 + Math.random()).toFixed(1)} β˜… - -
+ {selectedAdminProduct.providers && selectedAdminProduct.providers.length > 0 && ( +
+ Available Providers +
+ {selectedAdminProduct.providers.map(provider => ( + + {provider} + + ))} +
+
+ )}
{/* Action Area */}
{isAlreadyInCatalog ? (
-
- - Synced to Local Catalogue -
+ {activeTab === 'import_branding' && ( +
+ + Synced to Local Catalogue +
+ )} {storeCat.has(selectedAdminProduct.id) ? ( ) : ( - +
+
+ + setImportPrice(e.target.value)} + placeholder="e.g. 50" + className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-[#662582]" + /> +
+ +
)} {!storeCat.has(selectedAdminProduct.id) && (
)} - -
- {!isAlreadyInCatalog && ( - - )} -

@@ -1487,7 +1543,7 @@ export default function InventoryView({

Retail Packaging Info

- +
); @@ -1554,14 +1610,14 @@ export default function InventoryView({ {isPending ? (
+ +

@@ -554,7 +597,7 @@ function SelectedOrdersPage({ No orders selected. ) : ( -
+
{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => )} @@ -632,7 +675,7 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void return createPortal(
{ if (e.target === e.currentTarget) onClose(); }}> -
+

Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}

diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx index c3a3121..c78303d 100644 --- a/src/components/ReportsView.tsx +++ b/src/components/ReportsView.tsx @@ -104,7 +104,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba return coimbatoreZones.some(zone => name.toLowerCase().includes(zone)); }; - const locSummaryQ = useFiestaLocationSummary(tenantId); + const locSummaryQ = useFiestaLocationSummary(tenantId, ymd(yearStart), todate); const regionLocations = useMemo(() => { const rawLocations = [...(locSummaryQ.data ?? [])]; @@ -639,7 +639,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Revenue Heatmap table - 8 Cols */} -
+
@@ -716,7 +716,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Leaderboard nodes bar list - 4 Cols */} -
+
Top Performing Nodes @@ -888,7 +888,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Detailed Performance Matrix table */} -
+
{/* Table header with filters control */} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 7201425..e6d4f54 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -255,29 +255,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett }, [rolesQ.data]); return ( -
- {/* Header */} -
-
- {tenantsQ.isLoading ? ( - - Loading store profile… - - ) : tenant ? ( - - Active Β· {fstr(tenant.tenantname)} Β· Store #{tenantId} - - ) : ( - - Store details unavailable - - )} -
-
+
+ {/* Header Removed */}
{/* Tab rail & Merchant Card */} -
+
{/* Merchant ID Card */}
{/* Background design accents */} @@ -329,7 +312,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett {/* Panel */}
{activeTab === 'profile' && ( -
+
Store Profile

Identity & Contacts

@@ -427,7 +410,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett )} {activeTab === 'outlets' && ( -
+
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cf9d42b..d5992fd 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,7 +12,8 @@ import { TrendingUp, ShieldAlert, Users, - Truck + Truck, + Box } from 'lucide-react'; import { NavLink, useLocation } from 'react-router-dom'; import { MainSection } from '../types'; @@ -37,6 +38,7 @@ export default function Sidebar({ const navItems = [ { id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard }, { id: 'inventory' as MainSection, label: 'Products', icon: Layers }, + { id: 'catalogue' as MainSection, label: 'Global Catalogue', icon: Box }, { id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp }, { id: 'dispatch' as MainSection, label: 'Console', icon: Truck }, { id: 'settings' as MainSection, label: 'Settings', icon: Settings } @@ -53,7 +55,7 @@ export default function Sidebar({ )}
{h}