main stocks update

This commit is contained in:
2026-07-27 19:00:09 +05:30
parent 8885c55817
commit eb6e750b6a
6 changed files with 200 additions and 117 deletions

View File

@@ -276,18 +276,7 @@ export default function App() {
if (activeStore) { if (activeStore) {
return ( return (
<div className="space-y-md animate-in fade-in duration-300"> <div className="space-y-md animate-in fade-in duration-300">
{isSoleStore && (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Store Console
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
This merchant operates a single store. Add a branch to manage multiple outlets.
</p>
</div>
</div>
)}
<StoreDetailView <StoreDetailView
store={activeStore} store={activeStore}
onBack={() => setSelectedStore(null)} onBack={() => setSelectedStore(null)}

View File

@@ -49,7 +49,7 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue'; import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
import BulkCartDrawer from './BulkCartDrawer'; import BulkCartDrawer from './BulkCartDrawer';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi'; 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', stockStatus: 'Healthy',
trend: 'flat', trend: 'flat',
exposure: 'All Outlets', 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 { } else {
// If it exists but we have curated price in storeCat, we could apply it here if needed // If it exists but we have curated price in storeCat, we could apply it here if needed
const existing = byId.get(id)!; const existing = byId.get(id)!;
existing.price = item.price || existing.price; 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({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4`}> <div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4`}>
{filteredProducts.map((prod) => { {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 ( return (
<div key={prod.id} onClick={() => 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"> <div key={prod.id} onClick={() => 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">

View File

@@ -13,16 +13,19 @@
* and chooses which products they need, each with their own quantity. * and chooses which products they need, each with their own quantity.
* 3. Those picks are the user's request for their store. * 3. Those picks are the user's request for their store.
* *
* The catalogue source is the shared store catalogue (localStorage bridge for now; * All three tabs render database rows — nothing here is mock or local-only:
* backend: GET /products/getlocationproducts). The user's picks persist per store * • Catalogue → GET /products/getlocationproducts (via services/storeCatalogue)
* and `commitSelectionToStore()` is the single backend integration point * • Inventory → GET /products/getstockstatement (the stock ledger)
* (POST /products/createproductlocation / a stock-request endpoint). * • 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 React, { useEffect, useMemo, useState } from 'react';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-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 { 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 { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi'; 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' }; 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). */ /** Category → pill badge classes (mirrors the admin Global Catalogue card). */
function catBadgeClass(category: string): string { function catBadgeClass(category: string): string {
const c = String(category || '').toLowerCase(); const c = String(category || '').toLowerCase();
@@ -67,12 +89,24 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const [tempQty, setTempQty] = useState(1); const [tempQty, setTempQty] = useState(1);
const [notice, setNotice] = useState(false); 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). // 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( const products = useMemo(
() => () =>
storeCat.items storeCat.items
.filter((it) => it.status === 'Active')
.map((it) => ({ .map((it) => ({
id: it.productid, id: it.productid,
name: it.name, name: it.name,
@@ -86,7 +120,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
[storeCat.items], [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<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({}); const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
useEffect(() => { useEffect(() => {
@@ -109,37 +143,37 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const createRequestMutation = useFiestaCreateStockRequest(); const createRequestMutation = useFiestaCreateStockRequest();
const updateRequestMutation = useFiestaUpdateStockRequest(); 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 * User confirms an approved request actually arrived.
// movement, then marks the request "Received" once that succeeds. *
// * This ONLY flips the request to "Received". The backend credits the stock
// createproductlocation sets the product's quantity rather than incrementing * itself off that transition — do not also post a stock movement here.
// 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 * Previously this fired createproductlocation with `stocktype: 'in'` first
// receive here would wipe out whatever was already in stock. * 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 handleMarkReceived = (data: any) => {
const existingQty = storeCat.items.find(i => i.productid === String(data.productid))?.qty ?? 0; // Guard against a double submit re-applying the same receipt.
stockInMutation.mutate( 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, tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, locationid: storeLocationId,
productid: Number(data.productid), productid: Number(data.productid),
quantity: existingQty + (Number(data.qty) || 0), requestid: data.requestid,
stocktype: 'in', status: 'Received',
status: 'Active',
}, },
{ {
onSuccess: () => {
updateRequestMutation.mutate({
tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
productid: Number(data.productid),
requestid: data.requestid,
status: 'Received',
});
},
onError: (err: any) => { onError: (err: any) => {
alert(err.message || 'Failed to record received stock.'); 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') { if (existing != null && existing.status !== 'Cancelled') {
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } })); setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
} else { } 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() } })); setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
createRequestMutation.mutate({ createRequestMutation.mutate({
tenantid, tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, locationid: storeLocationId,
productid: Number(id), productid: Number(id),
qty: 1, qty: 1,
status: 'Pending', status: 'Pending',
@@ -167,6 +205,10 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const setPickQty = (id: string, qty: number) => { const setPickQty = (id: string, qty: number) => {
const safeQty = Math.max(1, Math.round(qty) || 1); 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) => { setPicks((prev) => {
const existing = prev[id] || {}; const existing = prev[id] || {};
return { return {
@@ -181,7 +223,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
}); });
createRequestMutation.mutate({ createRequestMutation.mutate({
tenantid, tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, locationid: storeLocationId,
productid: Number(id), productid: Number(id),
qty: safeQty, qty: safeQty,
status: 'Pending', status: 'Pending',
@@ -191,68 +233,57 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const pickCount = Object.keys(picks).length; const pickCount = Object.keys(picks).length;
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags. // Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 }); const stockQ = useFiestaStockStatement({ tenantid, locationid: storeLocationId, pagesize: 200 });
const inStore = useMemo(() => { /**
const set = new Set((stockQ.data ?? []).map((r) => fstr(r.productid))); * The stock statement is the ONE source of truth for quantity on hand.
// Only "Received" requests represent real, confirmed stock — "Approved" *
// just means the admin signed off, not that it has physically arrived. * Received stock deliberately is not added on top of it any more. The backend
Object.entries(picks).forEach(([pid, data]: [string, any]) => { * credits the ledger when a request flips to "Received", so anything this
if (data.status === 'Received') set.add(pid); * 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
return set; * overlay pushed the card to 60.
}, [stockQ.data, picks]); */
const inStore = useMemo(
() => new Set((stockQ.data ?? []).filter(hasStockHistory).map((r) => fstr(r.productid))),
[stockQ.data],
);
const inventory = useMemo( 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 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 { return {
id: fstr(r.productid), id: productid,
name: fstr(r.productname) || 'Unnamed product', name: fstr(r.productname) || catItem?.name || 'Unnamed product',
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, sku: catItem?.sku || `SKU-${productid}`,
image: fstr(r.productimage) || PLACEHOLDER, image: fstr(r.productimage) || catItem?.image || PLACEHOLDER,
category: fstr(r.categoryname) || 'General', category: fstr(r.categoryname) || catItem?.category || 'General',
unit: fstr(r.productunit) || catItem?.unit || '',
closing, closing,
...stockStatus(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 qty: closing, // fallback if actual qty not mapped
}; };
}); });
// Merge received picks into the inventory — stock only actually lands return baseInventory;
// 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());
}, },
[stockQ.data, picks, products], [stockQ.data, products],
); );
const filteredInventory = useMemo(() => { const filteredInventory = useMemo(() => {
@@ -290,7 +321,9 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
}, [filteredInventory, selectedCategories, stockHealthFilter]); }, [filteredInventory, selectedCategories, stockHealthFilter]);
// ── Integration point ────────────────────────────────────────────────────────── // ── 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 ( return (
<div className="animate-in fade-in duration-300 font-sans pb-28"> <div className="animate-in fade-in duration-300 font-sans pb-28">
@@ -641,7 +674,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
e.stopPropagation(); e.stopPropagation();
handleMarkReceived(data); 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" 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"
> >
<CheckCircle2 size={14} /> Mark as Received <CheckCircle2 size={14} /> Mark as Received
@@ -666,7 +699,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
) : !locationid ? ( ) : !locationid ? (
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." /> <CenterState icon={<Store size={34} />} 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 ? ( ) : inventory.length === 0 ? (
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." /> <CenterState icon={<PackageSearch size={34} />} 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 ? ( ) : finalFilteredInventory.length === 0 ? (
<CenterState <CenterState
icon={<Boxes size={34} />} icon={<Boxes size={34} />}
@@ -925,7 +958,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
setSelectedProduct(null); setSelectedProduct(null);
handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid }); 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" 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"
> >
<CheckCircle2 size={18} /> Mark as Received <CheckCircle2 size={18} /> Mark as Received

View File

@@ -800,7 +800,13 @@ export interface CreateProductLocationInput {
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */ /** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> { export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
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<string, unknown> = {
tenantid: input.tenantid, tenantid: input.tenantid,
locationid: input.locationid, locationid: input.locationid,
productid: input.productid, productid: input.productid,
@@ -808,6 +814,7 @@ export async function createProductLocation(input: CreateProductLocationInput):
stocktype: input.stocktype || 'in', stocktype: input.stocktype || 'in',
status: input.status || 'Active', status: input.status || 'Active',
}; };
if (num(input.price) > 0) payload.price = input.price;
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]); return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
} }

View File

@@ -757,7 +757,12 @@ export function useFiestaGetStockRequests(opts: {
return useQuery({ return useQuery({
queryKey: fiestaKeys.stockRequests(opts), queryKey: fiestaKeys.stockRequests(opts),
queryFn: () => getStockRequests(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)),
}); });
} }

View File

@@ -9,12 +9,16 @@
* users** see and pick from. It's the design-stage bridge between the admin * users** see and pick from. It's the design-stage bridge between the admin
* catalogue page (InventoryView) and the user catalogue page (StoreCatalogView). * catalogue page (InventoryView) and the user catalogue page (StoreCatalogView).
* *
* Persisted in localStorage so the flow is fully demonstrable on one device; it * Backed entirely by the database — there is no localStorage here (an earlier
* syncs live across tabs/pages via a storage event. The backend equivalents * revision of this comment described a localStorage bridge that no longer
* (once built) are: * exists). Every read and write goes to Fiesta, scoped by tenant + outlet:
* • admin curates → POST /products/createproductlocation (productid, qty, …) * • read → GET /products/getlocationproducts (useFiestaProductLocations)
* • user reads → GET /products/getlocationproducts * • add / update qty / update price
* Swap `read`/`write` for those calls when the API is ready; the hook API stays. * → 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'; import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
@@ -30,9 +34,41 @@ export interface StoreCatalogueItem {
unit: string; unit: string;
/** Quantity the admin intends to stock for this product. */ /** Quantity the admin intends to stock for this product. */
qty: number; 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; 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 * Live view of the store catalogue + curation helpers. Re-renders whenever the
* catalogue changes via React Query invalidation. * catalogue changes via React Query invalidation.
@@ -44,14 +80,20 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi
pagesize: 500, 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), productid: String(r.productid),
name: String(r.name || r.productname || ''), name: String(r.name || r.productname || ''),
image: String(r.image || r.productimage || ''), image: String(r.image || r.productimage || ''),
category: String(r.category || r.categoryname || 'General'), category: String(r.category || r.categoryname || 'General'),
sku: String(r.sku || ''), // The row's columns are `productsku` / `productunit` — the bare `sku` /
price: Number(r.price || 0), // `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002"
unit: String(r.unit || ''), // 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), qty: Number(r.quantity ?? r.qty ?? 0),
status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(), status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(),
})); }));