diff --git a/package-lock.json b/package-lock.json index bf2120d..d3b8b28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -933,9 +934,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -949,9 +947,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -965,9 +960,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -981,9 +973,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -997,9 +986,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1013,9 +999,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1029,9 +1012,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1045,9 +1025,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1061,9 +1038,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1077,9 +1051,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1093,9 +1064,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1109,9 +1077,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1125,9 +1090,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1337,9 +1299,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1356,9 +1315,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1375,9 +1331,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1394,9 +1347,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1861,6 +1811,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2840,9 +2791,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2863,9 +2811,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2886,9 +2831,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2909,9 +2851,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3251,6 +3190,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3277,6 +3217,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -3374,6 +3315,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3383,6 +3325,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4265,6 +4208,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/App.tsx b/src/App.tsx index f175d32..84fe447 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,6 @@ import React, { useState, useEffect } from 'react'; import { Network, Truck, - Users, Sliders, Calendar, AlertTriangle, @@ -34,10 +33,8 @@ import { MainSection } from './types'; import { useFiestaTenantLocations, useFiestaLocationSummary, - useFiestaUsers, - useFiestaCreateUser, } from './services/fiestaQueries'; -import { FIESTA_TENANT_ID, str as fstr, roleName } from './services/fiestaApi'; +import { FIESTA_TENANT_ID, str as fstr } from './services/fiestaApi'; import Sidebar from './components/Sidebar'; import Header from './components/Header'; import DashboardView from './components/DashboardView'; @@ -64,19 +61,10 @@ export default function App() { // ── Live data for the secondary sections (Fiesta) ───────────────────────── // Stores ← tenant locations + per-location order summary (seeded into local - // state so the "Add Store" handler keeps working). Users come straight from - // the live Users API and render directly from the query, with the "Add User" - // form posting back through the create-user mutation. + // state so the "Add Store" handler keeps working). Users management now lives + // under Settings → Users & Access (see UsersPanel). const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); - const usersQ = useFiestaUsers({ tenantid: FIESTA_TENANT_ID, pagesize: 100 }); - const createUserMut = useFiestaCreateUser(); - - const USER_AVATARS = [ - 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80', - 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80', - 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80', - ]; const STORE_COVERS = [ 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80', @@ -103,41 +91,6 @@ export default function App() { return STORE_COVERS[idx]; }; - // Live users mapped to display rows (rendered directly from the query). - const users = (usersQ.data ?? []).map((u, i) => { - const shift = fstr(u.shiftname).trim(); - return { - userid: Number(u.userid), - name: - fstr(u.fullname).trim() || - `${fstr(u.firstname)} ${fstr(u.lastname)}`.trim() || - fstr(u.authname) || - 'User', - email: fstr(u.email) || fstr(u.authname) || '—', - contact: fstr(u.contactno) || '—', - roleid: Number(u.roleid), - role: roleName(Number(u.roleid)), - shift: shift && shift !== '-' ? shift : '—', - location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore', - status: fstr(u.status) || 'Active', - avatar: USER_AVATARS[i % USER_AVATARS.length], - }; - }); - - // Role filter for the Users section ('ALL' or a numeric roleid). - const [userRoleFilter, setUserRoleFilter] = useState('ALL'); - const filteredUsers = users.filter((u) => { - const q = searchQuery.toLowerCase(); - const matchesSearch = - !q || - u.name.toLowerCase().includes(q) || - u.email.toLowerCase().includes(q) || - u.contact.toLowerCase().includes(q); - const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter; - return matchesSearch && matchesRole; - }); - const roleOptions = Array.from(new Set(users.map((u) => u.roleid))); - // Dynamic Secondary Modules list states (seeded from live data once it loads). const [storesList, setStoresList] = useState>([]); @@ -193,18 +146,9 @@ export default function App() { // Secondary sub-sections modals triggers const [showAddStoreModal, setShowAddStoreModal] = useState(false); - const [showAddUserModal, setShowAddUserModal] = useState(false); // New forms states const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' }); - const [newUser, setNewUser] = useState({ - firstname: '', - lastname: '', - email: '', - contactno: '', - password: '', - roleid: 4, - }); // Form submission handles for secondary sections const handleCreateStore = (e: React.FormEvent) => { @@ -229,30 +173,6 @@ export default function App() { alert(`Node outlet "${newStore.name}" commissioned to live operations feed successfully.`); }; - const handleCreateUser = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) { - alert('Please provide first name, email, contact number, and a password.'); - return; - } - try { - await createUserMut.mutateAsync({ - firstname: newUser.firstname, - lastname: newUser.lastname, - email: newUser.email, - contactno: newUser.contactno, - password: newUser.password, - roleid: Number(newUser.roleid), - tenantid: FIESTA_TENANT_ID, - }); - setShowAddUserModal(false); - setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: 4 }); - alert(`User "${newUser.firstname}" created successfully and synced to the live Users directory.`); - } catch (err) { - alert(`Could not create user: ${err instanceof Error ? err.message : 'Unknown error'}`); - } - }; - // Calendar Event Modal state const [showCalendarModal, setShowCalendarModal] = useState(false); @@ -550,141 +470,6 @@ export default function App() { ); - case 'users': - return ( -
-
-
-

- Users & Access -

-

- Tenant staff accounts, roles, assigned shifts, and account status — live from the Users API. -

-
- {usersQ.isLoading ? ( - - Loading live users… - - ) : usersQ.isError ? ( - - Live data unavailable - - ) : ( - - Live · {users.length} users - - )} -
-
- - -
- - {/* Role filter pills */} -
- - {roleOptions.map((rid) => ( - - ))} -
- - {/* Users table */} -
-
-

- Directory ({filteredUsers.length}) -

- Tenant {FIESTA_TENANT_ID} -
- -
- - - - - - - - - - - - - {filteredUsers.length === 0 ? ( - - - - ) : ( - filteredUsers.map((u) => ( - - - - - - - - - )) - )} - -
UserRoleContactShiftLocationStatus
- {usersQ.isLoading ? 'Loading live users…' : 'No users match this filter.'} -
-
- {u.name} -
-

{u.name}

-

{u.email}

-
-
-
- - {u.role} - - {u.contact}{u.shift}{u.location} - - {u.status} - -
-
-
-
- ); - case 'settings': return ; @@ -742,8 +527,8 @@ export default function App() { )} - {/* Handle alternative sections: Stores, Logistics, Staffing, Settings */} - {['stores', 'users', 'settings'].includes(currentSection) && + {/* Handle alternative sections: Stores, Settings */} + {['stores', 'settings'].includes(currentSection) && renderSecondarySection() } @@ -900,130 +685,6 @@ export default function App() { )} - {/* CREATE NEW USER MODAL */} - {showAddUserModal && ( -
{ if (e.target === e.currentTarget) setShowAddUserModal(false); }} - > -
-
-

- - Create User Account -

- -
- -
-
-

- Creates a real user against the live Users API for tenant {FIESTA_TENANT_ID}. -

-
-
-
- - setNewUser({ ...newUser, firstname: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" - required - /> -
-
- - setNewUser({ ...newUser, lastname: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" - /> -
-
- -
- - setNewUser({ ...newUser, email: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" - required - /> -
- -
-
- - setNewUser({ ...newUser, contactno: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" - required - /> -
-
- - -
-
- -
- - setNewUser({ ...newUser, password: e.target.value })} - className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-mono" - required - /> -
-
-
- -
- - -
-
-
-
- )} ); } diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 02d971d..3619fd7 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -45,16 +45,23 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) { const s = summaryQ.data; const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`; - // Profit comes from the live invoice/financial insight. When the tenant has no - // invoice records we show "—" rather than a misleading ₹0. + // Revenue + profit come from the live invoice/financial insight. The endpoint + // returns two distinct figures (revenue and profit); we surface both rather than + // repeating one. When the tenant has no invoice records we show "—" instead of a + // misleading ₹0. const insight = insightQ.data; const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`); - const todaysProfit = insight ? insight.profit : null; + const monthlyRevenue = insight ? insight.revenue : null; const monthlyProfit = insight ? insight.profit : null; const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID); const summaries = locSummaryQ.data ?? []; + // Region fulfillment — live month-to-date delivered ÷ total orders for the tenant. + const ordersTotal = s?.total ?? 0; + const ordersDelivered = s?.delivered ?? 0; + const regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null; + const locations = (locationsQ.data ?? []).filter((loc) => { if (!searchQuery) return true; const q = searchQuery.toLowerCase(); @@ -75,8 +82,8 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) { const kpis = [ { title: 'ACTIVE OUTLETS', display: `${activeStoresCount} / ${totalStoresCount}`, icon: Store, chip: 'bg-purple-50 text-[#581c87]', loading: locationsQ.isLoading }, - { title: 'REGION FULFILLMENT', display: '98.2%', icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: false }, - { title: "TODAY'S PROFIT", display: money(todaysProfit), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading }, + { title: 'REGION FULFILLMENT', display: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`, icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: summaryQ.isLoading }, + { title: 'MONTHLY REVENUE', display: money(monthlyRevenue), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading }, { title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading }, ]; diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 612b29b..9652aa6 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Layers, Search, @@ -18,6 +18,7 @@ import { TrendingDown, Trash2, PackageCheck, + ShieldCheck, Zap, Tag, UploadCloud, @@ -27,12 +28,15 @@ import { Info, X } from 'lucide-react'; -import { ProductMatrixItem, InventoryItem, ImportLog } from '../types'; +import { ProductMatrixItem, ImportLog } from '../types'; import { initialImportLogs } from '../data'; -import { useFiestaStockStatement, useFiestaTenantLocations } from '../services/fiestaQueries'; -import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi'; +import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries'; +import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; +type StockRow = Record; +const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? ''); + interface InventoryViewProps { searchQuery: string; isCoimbatoreView: boolean; @@ -42,42 +46,51 @@ export default function InventoryView({ searchQuery, isCoimbatoreView }: InventoryViewProps) { - // ── Live stock data (Fiesta) ───────────────────────────────────────────── - // The catalog grid and the hub-balance ledger are both derived from the live - // stock statement for the tenant's primary outlet. We seed local state from - // it once it loads so the existing add / CSV / replenish interactions keep - // mutating in-session without losing the live baseline. + // ── Live stock across every outlet (Fiesta) ─────────────────────────────── + // This page is the admin's command surface. The GLOBAL CATALOG is the deduped + // union of products across all outlets the tenant owns (admin-only import adds + // to it); the STORE STOCK section shows each outlet's live stock so the admin + // can see all the stores under them at a glance. const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); - const primaryLocation = - (locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) || - (locationsQ.data ?? [])[0]; - const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID; - const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet'; + const locations = useMemo( + () => + (locationsQ.data ?? []).map((l) => ({ + locationid: Number(l.locationid), + locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`, + status: fstr(l.status) || 'Active', + })), + [locationsQ.data], + ); - const stockQ = useFiestaStockStatement({ - tenantid: FIESTA_TENANT_ID, - locationid: locationId, - keyword: '', - pageno: 1, - pagesize: 100, - }); + const storesStock = useFiestaStoresStock( + FIESTA_TENANT_ID, + locations.map(({ locationid, locationname }) => ({ locationid, locationname })), + ); + const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading); + const storesError = + 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 [inventory, setInventory] = useState([]); const [importLogs, setImportLogs] = useState(initialImportLogs); + const [seeded, setSeeded] = useState(false); + const allStoreRows = storesStock.flatMap((s) => s.rows); useEffect(() => { - if (stockQ.data) { - setProducts(stockQ.data.map(stockRowToProduct)); - setInventory(stockQ.data.map((r) => stockRowToInventory(r, locationName))); - } - // locationName is derived from the same query chain; safe to depend on data. - }, [stockQ.data, locationName]); + 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); + }); + setProducts(Array.from(byId.values()).map(stockRowToProduct)); + setSeeded(true); + }, [allStoreRows, seeded]); const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog'); const [selectedCategory, setSelectedCategory] = useState('ALL'); const [showAddProductModal, setShowAddProductModal] = useState(false); - const [replenishmentList, setReplenishmentList] = useState([]); // CSV Textarea input const [csvText, setCsvText] = useState( @@ -109,29 +122,7 @@ export default function InventoryView({ products.forEach((p) => categorySet.add(p.category)); const categories: string[] = ['ALL', ...Array.from(categorySet)]; - // Handle SKU quantity change - const handleUpdateStock = (sku: string, delta: number) => { - setInventory(prev => prev.map(item => { - if (item.sku === sku) { - const newLevel = Math.max(0, item.stockLevel + delta); - const status = newLevel < 25 ? 'Critical' : newLevel < 120 ? 'Low Stock' : 'Optimal'; - return { ...item, stockLevel: newLevel, status }; - } - return item; - })); - }; - - // Trigger quick reorder recommendation - const handleReplenishSku = (sku: string) => { - if (replenishmentList.includes(sku)) return; - setReplenishmentList(prev => [...prev, sku]); - handleUpdateStock(sku, 500); // Add 500 units to stock - setTimeout(() => { - alert(`Auto-Replenish complete! 500 units ordered and allocated directly to corresponding hub for SKU ${sku}`); - }, 100); - }; - - // Filter criteria + // Filter criteria const filteredProducts = products.filter(p => { const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) || p.sku.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -161,20 +152,9 @@ export default function InventoryView({ verified: true }; - const createdInv: InventoryItem = { - sku: newProduct.sku, - name: newProduct.name, - warehouse: 'RS Puram Hub (CBE-01)', - stockLevel: newProduct.initialStock, - maxCapacity: 1000, - status: 'Optimal', - region: 'CBE-NORTH' - }; - setProducts([createdProd, ...products]); - setInventory([createdInv, ...inventory]); setShowAddProductModal(false); - alert(`Fresh product "${createdProd.name}" incorporated into Master Grocery Catalog and standard ledger!`); + alert(`Fresh product "${createdProd.name}" added to the Global Catalog. It is now available to roll out to all outlets.`); setNewProduct({ name: '', @@ -196,7 +176,6 @@ export default function InventoryView({ let parsedCount = 0; const newProds: ProductMatrixItem[] = []; - const newInvs: InventoryItem[] = []; lines.forEach(line => { const parts = line.split(',').map(p => p.trim()); @@ -204,8 +183,6 @@ export default function InventoryView({ const name = parts[0]; const sku = parts[1]; const category = parts[2] || 'Staples / Rice'; - const price = Number(parts[3]) || 120; - const initialStock = Number(parts[4]) || 150; if (!products.some(p => p.sku === sku)) { newProds.push({ @@ -221,16 +198,6 @@ export default function InventoryView({ exposure: 'All Outlets', verified: true }); - - newInvs.push({ - sku, - name, - warehouse: 'RS Puram Hub (CBE-01)', - stockLevel: initialStock, - maxCapacity: 1000, - status: 'Optimal', - region: 'CBE-NORTH' - }); parsedCount++; } } @@ -238,7 +205,6 @@ export default function InventoryView({ if (parsedCount > 0) { setProducts(prev => [...newProds, ...prev]); - setInventory(prev => [...newInvs, ...prev]); const logEntry: ImportLog = { timestamp: new Date().toLocaleTimeString() + ' (IST)', @@ -259,7 +225,6 @@ export default function InventoryView({ const handleImportPreset = (presetName: string, itemsList: Array<{name: string, sku: string, cat: string, price: number, stock: number, img: string}>) => { let imported = 0; const newProds: ProductMatrixItem[] = []; - const newInvs: InventoryItem[] = []; itemsList.forEach((itm) => { if (!products.some(p => p.sku === itm.sku)) { @@ -276,23 +241,12 @@ export default function InventoryView({ exposure: 'All Outlets', verified: true }); - - newInvs.push({ - sku: itm.sku, - name: itm.name, - warehouse: 'Peelamedu Sort Center', - stockLevel: itm.stock, - maxCapacity: 800, - status: 'Optimal', - region: 'CBE-EAST' - }); imported++; } }); if (imported > 0) { setProducts(prev => [...newProds, ...prev]); - setInventory(prev => [...newInvs, ...prev]); const logEntry: ImportLog = { timestamp: new Date().toLocaleTimeString() + ' (IST)', @@ -331,23 +285,23 @@ export default function InventoryView({

- Coimbatore Grocery Assortment & Catalogue Studio + Product Catalog · Global Assortment

- Build regional catalogues, update localized stock balances, parse batch imports, and style brand bag templates. + The master product catalog for all your outlets. Import products into the global catalog and monitor live stock across every store under you.

- {stockQ.isLoading ? ( + {storesLoading ? ( - Loading live stock… + Loading live stock across outlets… - ) : stockQ.isError ? ( + ) : storesError ? ( Live data unavailable ) : ( - Live · {locationName} · {products.length} SKUs + Live · {locations.length} outlet{locations.length === 1 ? '' : 's'} · {products.length} catalog SKUs )}
@@ -362,7 +316,7 @@ export default function InventoryView({ : 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]' }`} > - 🌾 Catalog Grid & Ledger + 🌐 Global Catalog & Stocks ))}
- +
+ + +
- {/* Multi-Pane Layout: Left Catalog Grid, Right Stock balances */} -
- - {/* Left Grid: Grocery Catalogue Items Showcase */} -
-
-

Active Assortment Items

-

Primary catalog schema synchronized on customer booking apps. Total: {filteredProducts.length} items

+ {/* Global Catalog — master assortment grid (full width) */} +
+
+

Global Product Catalog

+ + {filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} + +
+

+ Master assortment available to roll out to every outlet — imported by the admin and synced to the customer booking apps. +

-
- {filteredProducts.map((prod) => { - return ( -
-
- {prod.name} + {storesLoading && products.length === 0 ? ( +
Loading global catalog…
+ ) : filteredProducts.length === 0 ? ( +
No catalog products match your search or category.
+ ) : ( +
+ {filteredProducts.map((prod) => ( +
+
+ {prod.name} +
+
+
+
+

{prod.name}

+ {prod.sku}
- -
-
-
-

{prod.name}

- {prod.sku} -
- - {prod.category.split(' / ')[0]} - -
- -
-
- Sold (Units) - {prod.unitsSold.toLocaleString()} -
-
- Total revenue - ₹{prod.revenue.toLocaleString()} -
-
+ + {prod.category.split(' / ')[0]} + +
+
+
+ Sold (Units) + {prod.unitsSold.toLocaleString()} +
+
+ Total revenue + ₹{prod.revenue.toLocaleString()}
- ); - })} -
+
+
+ ))}
+ )} +
+ + {/* Store Stock — live per-outlet breakdown for every store under the admin */} +
+
+
+

+ Store Stock · All Outlets Under You +

+

Live on-hand balances for each store you manage.

+
+ + {locations.length} store{locations.length === 1 ? '' : 's'} +
- {/* Right Pane: Stock level adjustment ledgers */} -
-
-
-

Hub Balances Ledger

-

Physical checkout balances across localized Coimbatore warehouse locations.

-
- -
- {inventory.map((item, idx) => { - const percentage = (item.stockLevel / item.maxCapacity) * 100; - return ( -
-
-
-

{item.name}

-

{item.warehouse}

-
- {item.region} - - ● {item.status} - -
-
- -
- {item.stockLevel.toLocaleString()} units - -
- - -
-
-
- - {/* Gauge percentage */} -
-
-
-
- -
- Verification Level: {Math.round(percentage)}% - {item.status !== 'Optimal' && ( - - )} + {locations.length === 0 ? ( +
+ {locationsQ.isLoading ? 'Loading outlets…' : 'No outlets found under this tenant.'} +
+ ) : ( +
+ {storesStock.map((store) => { + const items = store.rows + .map((r) => stockRowToInventory(r, store.locationname)) + .filter((it) => !searchQuery || it.name.toLowerCase().includes(searchQuery.toLowerCase())); + const totalUnits = items.reduce((a, it) => a + it.stockLevel, 0); + const lowCount = items.filter((it) => it.status !== 'Optimal').length; + const meta = locations.find((l) => l.locationid === store.locationid); + const status = meta?.status ?? 'Active'; + return ( +
+
+
+
+

{store.locationname}

+

+ {store.isLoading ? 'Syncing…' : `${items.length} SKUs · ${totalUnits.toLocaleString('en-IN')} units on hand`} +

+ + {status} +
+ {lowCount > 0 && !store.isLoading && ( +

+ {lowCount} low / critical SKU{lowCount === 1 ? '' : 's'} +

+ )}
- ); - })} -
-
-
+
+ {store.isLoading ? ( +
Loading store stock…
+ ) : store.isError ? ( +
Couldn't load this store's stock.
+ ) : items.length === 0 ? ( +
No stock items{searchQuery ? ' match your search' : ''}.
+ ) : ( + items.map((it, idx) => { + const pct = Math.min(100, (it.stockLevel / it.maxCapacity) * 100); + return ( +
+
+

{it.name}

+ {it.stockLevel.toLocaleString('en-IN')} +
+
+
+
+
+ + {it.status} + +
+
+ ); + }) + )} +
+
+ ); + })} +
+ )}
) : ( diff --git a/src/components/OperationsView.tsx b/src/components/OperationsView.tsx index 2faafdd..8491a58 100644 --- a/src/components/OperationsView.tsx +++ b/src/components/OperationsView.tsx @@ -23,18 +23,15 @@ import { Download, AlertOctagon, X, - Calendar, - FileSpreadsheet } from 'lucide-react'; import { initialImportLogs } from '../data'; -import { InventoryItem, OrderItem } from '../types'; +import { InventoryItem } from '../types'; import { useFiestaStockStatement, - useFiestaDeliveries, useFiestaTenantLocations, } from '../services/fiestaQueries'; -import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi'; -import { stockRowToProduct, stockRowToInventory, mapOrderStatus, shortTime } from '../services/fiestaMappers'; +import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi'; +import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; interface OperationsViewProps { searchQuery: string; @@ -43,29 +40,9 @@ interface OperationsViewProps { export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) { // Sub-tabs state - const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'orders' | 'import'>('inventory'); + const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory'); // ── Live operations data (Fiesta) ───────────────────────────────────────── - const today = new Date(); - const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); - - // Date-range filter for the Orders sub-tab (drives the live deliveries query). - const [fromdate, setFromdate] = useState(ymd(monthStart)); - const [todate, setTodate] = useState(ymd(today)); - const dayOffset = (n: number) => { - const d = new Date(); - d.setDate(d.getDate() - n); - return ymd(d); - }; - const datePresets: Array<{ key: string; label: string; from: string; to: string }> = [ - { key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, - { key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) }, - { key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) }, - { key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) }, - { key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) }, - ]; - const activePreset = datePresets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom'; - const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID); const primaryLocation = (locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) || @@ -80,8 +57,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat pageno: 1, pagesize: 100, }); - const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate }); - // Total inventory value = Σ closing × unit cost across the live stock statement. const inventoryValue = (stockQ.data ?? []).reduce( (sum, r) => sum + fnum(r.closing) * fnum(r.productcost), @@ -91,7 +66,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat // Dynamic state arrays for interaction (seeded from live data once it loads). const [inventoryList, setInventoryList] = useState([]); const [productList, setProductList] = useState[]>([]); - const [orderList, setOrderList] = useState([]); const [importLogs, setImportLogs] = useState(initialImportLogs); useEffect(() => { @@ -101,23 +75,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat } }, [stockQ.data, locationName]); - useEffect(() => { - if (deliveriesQ.data) { - setOrderList( - deliveriesQ.data.map((r): OrderItem => { - const cust = mapOrderStatus(fstr(r.orderstatus)); - return { - id: fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`, - store: fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Location ${fstr(r.locationid)}`, - amount: fnum(r.deliveryamt) || fnum(r.orderamount), - time: shortTime(r.assigntime || r.deliverydate), - status: cust === 'DELIVERED' ? 'SHIPPED' : fstr(r.orderstatus).toLowerCase() === 'cancelled' ? 'FLAGGED' : 'PROCESSING', - }; - }), - ); - } - }, [deliveriesQ.data]); - // Modal open states const [showAddSkuModal, setShowAddSkuModal] = useState(false); const [showTransferModal, setShowTransferModal] = useState(false); @@ -153,11 +110,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat prod.category.toLowerCase().includes(searchQuery.toLowerCase()) ); - const filteredOrders = orderList.filter(ord => - ord.id.toLowerCase().includes(searchQuery.toLowerCase()) || - ord.store.toLowerCase().includes(searchQuery.toLowerCase()) - ); - // Form submit handles const handleAddSku = (e: React.FormEvent) => { e.preventDefault(); @@ -239,7 +191,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat {/* Dynamic Nav Sub-Tabs */}
)} - {activeSubTab === 'orders' && ( -
- {/* Day-wise date filter — drives the live deliveries/orders query */} -
-
- - View - - {datePresets.map((p) => ( - - ))} -
- -
-
- - setFromdate(e.target.value)} - className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer" - /> -
- -
- - setTodate(e.target.value)} - className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer" - /> -
-
-
- -
-
-

- Orders ({filteredOrders.length}) -

- - {fromdate === todate ? fromdate : `${fromdate} → ${todate}`} - -
- - - - - - - - - - - - {filteredOrders.length === 0 ? ( - - - - ) : ( - filteredOrders.map((ord) => ( - - - - - - - - )))} - -
Order IDOrigin Store TerminalInvoice AmountCommitted Time (IST)System state status
- {deliveriesQ.isLoading ? 'Loading live orders…' : 'No orders in this date range.'} -
{ord.id}{ord.store}₹{ord.amount.toLocaleString()}{ord.time} - - {ord.status} - -
-
-
- )} - {activeSubTab === 'import' && (
{/* Upload panel zone */} diff --git a/src/components/OrdersDeliveriesView.tsx b/src/components/OrdersDeliveriesView.tsx index 7443690..a07f9d8 100644 --- a/src/components/OrdersDeliveriesView.tsx +++ b/src/components/OrdersDeliveriesView.tsx @@ -483,7 +483,11 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie ))}
- {selectedOrder ? ( +
+ + {/* Right column — Order Details, shown parallel to the orders feed */} +
+ {selectedOrder ? (
Order Details: {selectedOrder.id} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 9e1acfb..82f74b1 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -10,6 +10,7 @@ import { Truck, CreditCard, SlidersHorizontal, + Users, MapPin, Phone, Mail, @@ -19,12 +20,13 @@ import { } from 'lucide-react'; import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi'; +import UsersPanel from './UsersPanel'; interface SettingsViewProps { tenantId?: number; } -type TabKey = 'profile' | 'outlets' | 'delivery' | 'payment' | 'preferences'; +type TabKey = 'profile' | 'outlets' | 'users' | 'delivery' | 'payment' | 'preferences'; /** Locally-persisted merchant preferences (survive reload via localStorage). */ interface MerchantSettings { @@ -175,6 +177,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [ { key: 'profile', label: 'Business Profile', icon: Building2 }, { key: 'outlets', label: 'Outlets', icon: Store }, + { key: 'users', label: 'Users & Access', icon: Users }, { key: 'delivery', label: 'Delivery', icon: Truck }, { key: 'payment', label: 'Payment & Tax', icon: CreditCard }, { key: 'preferences', label: 'Preferences', icon: SlidersHorizontal }, @@ -366,6 +369,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
)} + {activeTab === 'users' && ( + + )} + {activeTab === 'delivery' && (
@@ -474,8 +481,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
)} - {/* Save / Reset — lives with the settings card, not pinned to the screen */} -
+ {/* Save / Reset — lives with the settings card, not pinned to the screen. + Hidden on the Users tab, which manages accounts via the live API. */} +
{dirty ? '● You have unsaved changes' : 'All changes saved'} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 79860a3..d7defdc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,7 +9,6 @@ import { Store, Layers, ShoppingBag, - Users, Settings } from 'lucide-react'; import { MainSection } from '../types'; @@ -33,8 +32,7 @@ export default function Sidebar({ const navItems = [ { id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard }, { id: 'stores' as MainSection, label: 'Stores', icon: Store }, - { id: 'inventory' as MainSection, label: 'Inventory Catalog', icon: Layers }, - { id: 'users' as MainSection, label: 'Users', icon: Users }, + { id: 'inventory' as MainSection, label: 'Product Catalog', icon: Layers }, { id: 'settings' as MainSection, label: 'Settings', icon: Settings } ]; diff --git a/src/components/UsersPanel.tsx b/src/components/UsersPanel.tsx new file mode 100644 index 0000000..f53812e --- /dev/null +++ b/src/components/UsersPanel.tsx @@ -0,0 +1,378 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Users & Access — tenant staff directory with role filtering and user creation. + * Rendered as a tab inside SettingsView (it used to be a standalone sidebar page). + * Self-contained: owns its search box, role filter, live query, and Add User modal. + */ + +import React, { useState } from 'react'; +import { Users, Search, X } from 'lucide-react'; +import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries'; +import { FIESTA_TENANT_ID, str as fstr, roleName } from '../services/fiestaApi'; + +interface UsersPanelProps { + tenantId?: number; + /** Pre-selected role in the Add User dialog (from workspace preferences). */ + defaultNewUserRole?: number; +} + +const USER_AVATARS = [ + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80', + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80', +]; + +export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) { + const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 }); + const createUserMut = useFiestaCreateUser(); + + const [search, setSearch] = useState(''); + const [userRoleFilter, setUserRoleFilter] = useState('ALL'); + const [showAddUserModal, setShowAddUserModal] = useState(false); + const [newUser, setNewUser] = useState({ + firstname: '', + lastname: '', + email: '', + contactno: '', + password: '', + roleid: defaultNewUserRole, + }); + + // Live users mapped to display rows (rendered directly from the query). + const users = (usersQ.data ?? []).map((u, i) => { + const shift = fstr(u.shiftname).trim(); + return { + userid: Number(u.userid), + name: + fstr(u.fullname).trim() || + `${fstr(u.firstname)} ${fstr(u.lastname)}`.trim() || + fstr(u.authname) || + 'User', + email: fstr(u.email) || fstr(u.authname) || '—', + contact: fstr(u.contactno) || '—', + roleid: Number(u.roleid), + role: roleName(Number(u.roleid)), + shift: shift && shift !== '-' ? shift : '—', + location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore', + status: fstr(u.status) || 'Active', + avatar: USER_AVATARS[i % USER_AVATARS.length], + }; + }); + + const filteredUsers = users.filter((u) => { + const q = search.toLowerCase(); + const matchesSearch = + !q || + u.name.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + u.contact.toLowerCase().includes(q); + const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter; + return matchesSearch && matchesRole; + }); + const roleOptions = Array.from(new Set(users.map((u) => u.roleid))); + + const handleCreateUser = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) { + alert('Please provide first name, email, contact number, and a password.'); + return; + } + try { + await createUserMut.mutateAsync({ + firstname: newUser.firstname, + lastname: newUser.lastname, + email: newUser.email, + contactno: newUser.contactno, + password: newUser.password, + roleid: Number(newUser.roleid), + tenantid: tenantId, + }); + setShowAddUserModal(false); + setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: defaultNewUserRole }); + alert(`User "${newUser.firstname}" created successfully and synced to the live Users directory.`); + } catch (err) { + alert(`Could not create user: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + }; + + return ( +
+
+
+ Users & Access +

+ Tenant staff accounts, roles, shifts, and status — live from the Users API. +

+
+ {usersQ.isLoading ? ( + + Loading live users… + + ) : usersQ.isError ? ( + + Live data unavailable + + ) : ( + + Live · {users.length} users + + )} +
+
+ + +
+ + {/* Search */} +
+ + + + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-zinc-50 border border-zinc-200 rounded-lg text-xs font-medium text-zinc-800 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[#581c87]/20 focus:border-[#581c87] transition-all" + /> + {search && ( + + )} +
+ + {/* Role filter pills */} +
+ + {roleOptions.map((rid) => ( + + ))} +
+ + {/* Users table */} +
+
+

Directory ({filteredUsers.length})

+ Tenant {tenantId} +
+ +
+ + + + + + + + + + + + + {filteredUsers.length === 0 ? ( + + + + ) : ( + filteredUsers.map((u) => ( + + + + + + + + + )) + )} + +
UserRoleContactShiftLocationStatus
+ {usersQ.isLoading ? 'Loading live users…' : 'No users match this filter.'} +
+
+ {u.name} +
+

{u.name}

+

{u.email}

+
+
+
+ + {u.role} + + {u.contact}{u.shift}{u.location} + + {u.status} + +
+
+
+ + {/* CREATE NEW USER MODAL */} + {showAddUserModal && ( +
{ if (e.target === e.currentTarget) setShowAddUserModal(false); }} + > +
+
+

+ + Create User Account +

+ +
+ +
+
+

+ Creates a real user against the live Users API for tenant {tenantId}. +

+
+
+
+ + setNewUser({ ...newUser, firstname: e.target.value })} + className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" + required + /> +
+
+ + setNewUser({ ...newUser, lastname: e.target.value })} + className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" + /> +
+
+ +
+ + setNewUser({ ...newUser, email: e.target.value })} + className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" + required + /> +
+ +
+
+ + setNewUser({ ...newUser, contactno: e.target.value })} + className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]" + required + /> +
+
+ + +
+
+ +
+ + setNewUser({ ...newUser, password: e.target.value })} + className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-mono" + required + /> +
+
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts index 89e90a2..41ccf94 100644 --- a/src/services/fiestaQueries.ts +++ b/src/services/fiestaQueries.ts @@ -12,7 +12,8 @@ * continues to use the Hasura hooks in `./queries`. */ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query'; +import type { Row } from './fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, @@ -204,6 +205,39 @@ export function useFiestaProductsCount(opts: { tenantid: number; categoryid: num }); } +export interface StoreStock { + locationid: number; + locationname: string; + isLoading: boolean; + isError: boolean; + rows: Row[]; +} + +/** + * Live stock statement for every outlet under the tenant — powers the admin's + * "all stores' stock" view. One query per location (deduped/cached by React + * Query); the returned array stays aligned with the `locations` input. + */ +export function useFiestaStoresStock( + tenantid: number, + locations: Array<{ locationid: number; locationname: string }>, +): StoreStock[] { + const results = useQueries({ + queries: locations.map((loc) => ({ + queryKey: fiestaKeys.stockStatement({ scope: 'stores', tenantid, locationid: loc.locationid }), + queryFn: () => getStockStatement({ tenantid, locationid: loc.locationid, pagesize: 200 }), + enabled: Boolean(tenantid && loc.locationid), + })), + }); + return locations.map((loc, i) => ({ + locationid: loc.locationid, + locationname: loc.locationname, + isLoading: results[i]?.isLoading ?? true, + isError: results[i]?.isError ?? false, + rows: (results[i]?.data as Row[]) ?? [], + })); +} + // ── Users ───────────────────────────────────────────────────────────────────── export function useFiestaUsers(opts: { tenantid: number;