main stocks update
This commit is contained in:
13
src/App.tsx
13
src/App.tsx
@@ -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)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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),
|
|
||||||
quantity: existingQty + (Number(data.qty) || 0),
|
|
||||||
stocktype: 'in',
|
|
||||||
status: 'Active',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
updateRequestMutation.mutate({
|
|
||||||
tenantid,
|
|
||||||
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
|
||||||
productid: Number(data.productid),
|
productid: Number(data.productid),
|
||||||
requestid: data.requestid,
|
requestid: data.requestid,
|
||||||
status: 'Received',
|
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
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user