diff --git a/src/App.tsx b/src/App.tsx index 0e49eda..9f9df98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -276,18 +276,7 @@ export default function App() { if (activeStore) { return (
- {isSoleStore && ( -
-
-

- Store Console -

-

- This merchant operates a single store. Add a branch to manage multiple outlets. -

-
-
- )} + setSelectedStore(null)} diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 8d2f0b9..60ad50e 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -49,7 +49,7 @@ import { } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; -import { useStoreCatalogue } from '../services/storeCatalogue'; +import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue'; import BulkCartDrawer from './BulkCartDrawer'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi'; @@ -169,13 +169,17 @@ export default function InventoryView({ stockStatus: 'Healthy', trend: 'flat', exposure: 'All Outlets', - verified: item.status === 'Active', // Active means it's fully published with price + // "Fully published with price" — tested on the price itself, not on + // status. Status is not a publish flag: the backend overwrites our + // 'Active' with an availability value, which used to flip published + // products back to unverified. See storeCatalogue.isPublishedItem. + verified: item.price > 0, }); } 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'; + existing.verified = existing.verified || item.price > 0; } }); @@ -557,7 +561,10 @@ export default function InventoryView({
{filteredProducts.map((prod) => { - const isPublished = storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active'; + // Presence in the store catalogue IS publication — the row + // stays until the admin removes it. Don't gate on status; + // the backend rewrites it to 'available'/'outofstock'. + const isPublished = isPublishedItem(storeCat.items.find(i => i.productid === prod.id)); return (
setSelectedAdminProduct(prod)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none 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 cursor-pointer"> diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx index b6b644d..d583d88 100644 --- a/src/components/StoreCatalogView.tsx +++ b/src/components/StoreCatalogView.tsx @@ -13,16 +13,19 @@ * and chooses which products they need, each with their own quantity. * 3. Those picks are the user's request for their store. * - * The catalogue source is the shared store catalogue (localStorage bridge for now; - * backend: GET /products/getlocationproducts). The user's picks persist per store - * and `commitSelectionToStore()` is the single backend integration point - * (POST /products/createproductlocation / a stock-request endpoint). + * All three tabs render database rows — nothing here is mock or local-only: + * • Catalogue → GET /products/getlocationproducts (via services/storeCatalogue) + * • Inventory → GET /products/getstockstatement (the stock ledger) + * • Requests → GET /products/getstockrequests + * Requests are written with POST /products/createstockrequest and confirmed + * with PUT /products/updatestockrequest. Every call is scoped by the logged-in + * tenant + outlet; see `storeLocationId`. */ import React, { useEffect, useMemo, useState } from 'react'; import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react'; import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, useFiestaCreateProductLocation, FIESTA_TENANT_ID } from '../services/fiestaQueries'; -import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi'; +import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { useStoreCatalogue } from '../services/storeCatalogue'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi'; @@ -44,6 +47,25 @@ function stockStatus(closing: number): { label: string; color: string } { return { label: 'Healthy', color: '#10b981' }; } +/** + * Has stock ever actually moved for this product at this outlet? + * + * getstockstatement returns a row for every product in the outlet's catalogue, + * so a product the admin published a minute ago comes back with all four ledger + * columns at 0. That is a catalogue entry, not inventory. A row only counts as + * inventory once any of opening/credit/debit/closing is non-zero — which also + * keeps a product that was stocked and has since sold down to 0, so it stays + * listed as "Out of stock" instead of disappearing. + */ +function hasStockHistory(r: Row): boolean { + return ( + fnum(r.opening) !== 0 || + fnum(r.credit) !== 0 || + fnum(r.debit) !== 0 || + fnum(r.closing) !== 0 + ); +} + /** Category → pill badge classes (mirrors the admin Global Catalogue card). */ function catBadgeClass(category: string): string { const c = String(category || '').toLowerCase(); @@ -67,12 +89,24 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', const [tempQty, setTempQty] = useState(1); const [notice, setNotice] = useState(false); + // Every read and write on this page is scoped to the tenant + outlet the + // logged-in user is linked to. There is no safe default here: falling back to + // a hard-coded outlet made this page show — and write — another store's + // stock. 0 means "not resolved yet", and the queries stay parked until it is. + const storeLocationId = locationid ?? 0; + // The admin-curated catalogue (what the user is allowed to pick from). - const storeCat = useStoreCatalogue(tenantid, locationid ?? FIESTA_PRIMARY_LOCATION_ID); + const storeCat = useStoreCatalogue(tenantid, storeLocationId); + // No status filter here. `status` is not a publish flag — we write 'Active' + // when the admin publishes, but the backend overwrites it with an + // availability value ('available' / 'outofstock') as soon as it recomputes. + // Filtering on 'Active' made a product show the moment it was added and then + // vanish from the store catalogue on the next refresh. useStoreCatalogue has + // already dropped genuinely removed rows, so everything left belongs here and + // stays until the admin deletes it. const products = useMemo( () => storeCat.items - .filter((it) => it.status === 'Active') .map((it) => ({ id: it.productid, name: it.name, @@ -86,7 +120,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', [storeCat.items], ); - const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500, date: requestDate }); + const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate }); const [picks, setPicks] = useState>({}); useEffect(() => { @@ -109,37 +143,37 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', const createRequestMutation = useFiestaCreateStockRequest(); const updateRequestMutation = useFiestaUpdateStockRequest(); - const stockInMutation = useFiestaCreateProductLocation(); - // User confirms an approved request actually arrived — this is the only - // point a request turns into real, live stock. It first writes the stock-in - // movement, then marks the request "Received" once that succeeds. - // - // createproductlocation sets the product's quantity rather than incrementing - // it (same assumption the publish-to-catalogue flow relies on), so this must - // send the new total on hand, not just the newly-received amount, or a - // receive here would wipe out whatever was already in stock. + /** + * User confirms an approved request actually arrived. + * + * This ONLY flips the request to "Received". The backend credits the stock + * itself off that transition — do not also post a stock movement here. + * + * Previously this fired createproductlocation with `stocktype: 'in'` first + * and marked the request received afterwards, which credited the same goods + * twice. Measured on outlet 1185: 20 units received, ledger `credit` 40; 25 + * units received, ledger `credit` 50 — exactly double, every time. The + * inventory list then added the request qty a third time on top, which is + * how 10 requested became 30 shown. + */ const handleMarkReceived = (data: any) => { - const existingQty = storeCat.items.find(i => i.productid === String(data.productid))?.qty ?? 0; - stockInMutation.mutate( + // Guard against a double submit re-applying the same receipt. + if (picks[String(data.productid)]?.status === 'Received') return; + if (updateRequestMutation.isPending) return; + if (!storeLocationId) { + alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.'); + return; + } + updateRequestMutation.mutate( { tenantid, - locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, + locationid: storeLocationId, productid: Number(data.productid), - quantity: existingQty + (Number(data.qty) || 0), - stocktype: 'in', - status: 'Active', + requestid: data.requestid, + status: 'Received', }, { - onSuccess: () => { - updateRequestMutation.mutate({ - tenantid, - locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, - productid: Number(data.productid), - requestid: data.requestid, - status: 'Received', - }); - }, onError: (err: any) => { alert(err.message || 'Failed to record received stock.'); }, @@ -153,10 +187,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', if (existing != null && existing.status !== 'Cancelled') { setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } })); } else { + if (!storeLocationId) { + alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.'); + return; + } setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } })); createRequestMutation.mutate({ tenantid, - locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, + locationid: storeLocationId, productid: Number(id), qty: 1, status: 'Pending', @@ -167,6 +205,10 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', const setPickQty = (id: string, qty: number) => { const safeQty = Math.max(1, Math.round(qty) || 1); + if (!storeLocationId) { + alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.'); + return; + } setPicks((prev) => { const existing = prev[id] || {}; return { @@ -181,7 +223,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', }); createRequestMutation.mutate({ tenantid, - locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, + locationid: storeLocationId, productid: Number(id), qty: safeQty, status: 'Pending', @@ -191,68 +233,57 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', const pickCount = Object.keys(picks).length; // Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags. - const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 }); - - const inStore = useMemo(() => { - const set = new Set((stockQ.data ?? []).map((r) => fstr(r.productid))); - // Only "Received" requests represent real, confirmed stock — "Approved" - // just means the admin signed off, not that it has physically arrived. - Object.entries(picks).forEach(([pid, data]: [string, any]) => { - if (data.status === 'Received') set.add(pid); - }); - return set; - }, [stockQ.data, picks]); - + const stockQ = useFiestaStockStatement({ tenantid, locationid: storeLocationId, pagesize: 200 }); + + /** + * The stock statement is the ONE source of truth for quantity on hand. + * + * Received stock deliberately is not added on top of it any more. The backend + * credits the ledger when a request flips to "Received", so anything this + * page adds is a second count of the same goods — measured on outlet 1185, + * 20 units received read back as `credit: 40, closing: 40`, and the old + * overlay pushed the card to 60. + */ + const inStore = useMemo( + () => new Set((stockQ.data ?? []).filter(hasStockHistory).map((r) => fstr(r.productid))), + [stockQ.data], + ); + const inventory = useMemo( () => { - const baseInventory = (stockQ.data ?? []).map((r: Row) => { + // The stock statement lists every product in the outlet's catalogue, + // including ones the admin only just published and that have never been + // stocked (all four ledger columns 0). Those don't belong in inventory — + // a product earns its place here once stock has actually moved for it, + // and from then on it stays, showing "Out of stock" when it hits 0. + const baseInventory = (stockQ.data ?? []).filter(hasStockHistory).map((r: Row) => { const closing = fnum(r.closing) ?? 0; + const productid = fstr(r.productid); + // The stock statement carries productname/productunit/retailprice but no + // sku or image, so fill those from the catalogue row for the same + // product. The price used to be `Math.floor(Math.random() * 50) + 10` — + // a fabricated number the product drawer rendered as real, changing on + // every render. + const catItem = products.find((p) => p.id === productid); return { - id: fstr(r.productid), - name: fstr(r.productname) || 'Unnamed product', - sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, - image: fstr(r.productimage) || PLACEHOLDER, - category: fstr(r.categoryname) || 'General', + id: productid, + name: fstr(r.productname) || catItem?.name || 'Unnamed product', + sku: catItem?.sku || `SKU-${productid}`, + image: fstr(r.productimage) || catItem?.image || PLACEHOLDER, + category: fstr(r.categoryname) || catItem?.category || 'General', + unit: fstr(r.productunit) || catItem?.unit || '', closing, ...stockStatus(closing), - price: Math.floor(Math.random() * 50) + 10, // mock price + // Per-store price first (the catalogue row carries productlocations + // .price); the stock statement only knows the master retailprice. + price: catItem?.price || fnum(r.retailprice) || 0, qty: closing, // fallback if actual qty not mapped }; }); - // Merge received picks into the inventory — stock only actually lands - // once the user confirms receipt, not merely once the admin approves. - const inventoryMap = new Map(baseInventory.map(item => [item.id, item])); - - Object.entries(picks).forEach(([pid, data]: [string, any]) => { - if (data.status === 'Received') { - if (inventoryMap.has(pid)) { - const item = inventoryMap.get(pid)!; - item.qty += data.qty; - item.closing += data.qty; - Object.assign(item, stockStatus(item.closing)); - } else { - // Find product info from catalogue - const prod = products.find(p => p.id === pid); - if (prod) { - inventoryMap.set(pid, { - id: prod.id, - name: prod.name, - sku: prod.sku, - image: prod.image, - category: prod.category, - ...stockStatus(data.qty), - price: prod.price, - qty: data.qty, - closing: data.qty - }); - } - } - } - }); - return Array.from(inventoryMap.values()); + return baseInventory; }, - [stockQ.data, picks, products], + [stockQ.data, products], ); const filteredInventory = useMemo(() => { @@ -290,7 +321,9 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', }, [filteredInventory, selectedCategories, stockHealthFilter]); // ── Integration point ────────────────────────────────────────────────────────── - // The request is saved to localStorage automatically via the useEffect on `picks`. + // Requests are persisted server-side via POST /products/createstockrequest + // (see togglePick / setPickQty); `picks` is only the local mirror of the rows + // that getstockrequests returns. return (
@@ -641,7 +674,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', e.stopPropagation(); handleMarkReceived(data); }} - disabled={stockInMutation.isPending || updateRequestMutation.isPending} + disabled={updateRequestMutation.isPending} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" > Mark as Received @@ -666,7 +699,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', ) : !locationid ? ( } title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." /> ) : inventory.length === 0 ? ( - } title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." /> + } title="No products stocked yet" sub="Products appear here once stock actually arrives — request stock from the catalogue, then confirm it as received." /> ) : finalFilteredInventory.length === 0 ? ( } @@ -925,7 +958,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store', setSelectedProduct(null); handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid }); }} - disabled={stockInMutation.isPending || updateRequestMutation.isPending} + disabled={updateRequestMutation.isPending} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-indigo-600 text-white hover:bg-indigo-700 font-semibold text-sm transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" > Mark as Received diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts index 5c8a66b..59b2565 100644 --- a/src/services/fiestaApi.ts +++ b/src/services/fiestaApi.ts @@ -800,7 +800,13 @@ export interface CreateProductLocationInput { /** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */ export async function createProductLocation(input: CreateProductLocationInput): Promise { - const payload = { + // The selling price field is `price` — it binds to models.Productlocations + // .Price, and CreateProductLocation's upsert lists "price" in its + // DoUpdates, so this both inserts and updates the per-store price. + // (`retailprice` is the MASTER price on the products table; that struct + // ignores it, so sending it here persisted nothing.) Only send when a price + // was supplied, so a quantity-only write can't blank an existing one. + const payload: Record = { tenantid: input.tenantid, locationid: input.locationid, productid: input.productid, @@ -808,6 +814,7 @@ export async function createProductLocation(input: CreateProductLocationInput): stocktype: input.stocktype || 'in', status: input.status || 'Active', }; + if (num(input.price) > 0) payload.price = input.price; return fiestaSend('products/createproductlocation', 'POST', [payload]); } diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts index a94067a..82357d4 100644 --- a/src/services/fiestaQueries.ts +++ b/src/services/fiestaQueries.ts @@ -757,7 +757,12 @@ export function useFiestaGetStockRequests(opts: { return useQuery({ queryKey: fiestaKeys.stockRequests(opts), queryFn: () => getStockRequests(opts), - enabled: Boolean(opts.tenantid), + // Omitting locationid is a deliberate tenant-wide query (the admin console + // needs every outlet's requests). But passing an EMPTY one is not: the + // backend treats locationid=0 as "no filter" and returns every outlet, so a + // store user whose outlet hasn't resolved yet would see other stores' + // requests. Wait for a real id in that case. + enabled: Boolean(opts.tenantid) && (opts.locationid === undefined || Boolean(opts.locationid)), }); } diff --git a/src/services/storeCatalogue.ts b/src/services/storeCatalogue.ts index 8c61945..ada3280 100644 --- a/src/services/storeCatalogue.ts +++ b/src/services/storeCatalogue.ts @@ -9,12 +9,16 @@ * users** see and pick from. It's the design-stage bridge between the admin * catalogue page (InventoryView) and the user catalogue page (StoreCatalogView). * - * Persisted in localStorage so the flow is fully demonstrable on one device; it - * syncs live across tabs/pages via a storage event. The backend equivalents - * (once built) are: - * • admin curates → POST /products/createproductlocation (productid, qty, …) - * • user reads → GET /products/getlocationproducts - * Swap `read`/`write` for those calls when the API is ready; the hook API stays. + * Backed entirely by the database — there is no localStorage here (an earlier + * revision of this comment described a localStorage bridge that no longer + * exists). Every read and write goes to Fiesta, scoped by tenant + outlet: + * • read → GET /products/getlocationproducts (useFiestaProductLocations) + * • add / update qty / update price + * → POST /products/createproductlocation + * • remove → DELETE /products/deleteproductlocation + * + * Mutations invalidate the React Query cache, so admin edits show up on the + * store user's catalogue on its next fetch. */ import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries'; @@ -30,9 +34,41 @@ export interface StoreCatalogueItem { unit: string; /** Quantity the admin intends to stock for this product. */ qty: number; + /** + * The row's status as the backend reports it — NOT a publish/unpublish flag. + * + * This field is overloaded. We write 'Active' when publishing, but the + * backend later overwrites it with a stock-availability value ('available', + * 'outofstock'), so a published product does not stay 'Active'. Membership of + * this list IS publication; treat status as display metadata only and never + * as a visibility gate. Use `isPublishedItem` / `isRemovedStatus` instead. + */ status: string; } +/** + * Statuses that mean the row is no longer part of the store catalogue. + * + * Deliberately a small deny-list rather than an 'Active' allow-list: the + * backend mixes lifecycle values ('Active') with availability values + * ('available', 'outofstock') in the same field, so anything not explicitly + * removed is still catalogued — an out-of-stock product is still on the menu. + */ +const REMOVED_STATUSES = new Set(['inactive', 'deleted', 'removed', 'archived']); + +export function isRemovedStatus(status: unknown): boolean { + return REMOVED_STATUSES.has(String(status ?? '').trim().toLowerCase()); +} + +/** + * Is this product published to the store catalogue? A product-location row + * exists for it and has not been removed — that is the whole test. It stays + * published, whatever its stock level, until the admin deletes it. + */ +export function isPublishedItem(item: StoreCatalogueItem | undefined | null): boolean { + return Boolean(item) && !isRemovedStatus(item!.status); +} + /** * Live view of the store catalogue + curation helpers. Re-renders whenever the * catalogue changes via React Query invalidation. @@ -44,14 +80,20 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi pagesize: 500, }); - const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => r.status !== 'Inactive').map((r: any) => ({ + const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => !isRemovedStatus(r.status)).map((r: any) => ({ productid: String(r.productid), name: String(r.name || r.productname || ''), image: String(r.image || r.productimage || ''), category: String(r.category || r.categoryname || 'General'), - sku: String(r.sku || ''), - price: Number(r.price || 0), - unit: String(r.unit || ''), + // The row's columns are `productsku` / `productunit` — the bare `sku` / + // `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002" + // rendered as "SKU-7075" and a 100g unit rendered as "Pc". + sku: String(r.productsku || r.sku || ''), + // Per-store price (productlocations.price) wins; `retailprice` is the + // master price on the products row and is the fallback for an outlet that + // hasn't set its own. + price: Number(r.price || r.retailprice || 0), + unit: String(r.productunit || r.unit || ''), qty: Number(r.quantity ?? r.qty ?? 0), status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(), }));