new changes
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Search,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TrendingDown,
|
||||
Trash2,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Tag,
|
||||
UploadCloud,
|
||||
@@ -27,12 +28,15 @@ import {
|
||||
Info,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { ProductMatrixItem, InventoryItem, ImportLog } from '../types';
|
||||
import { ProductMatrixItem, ImportLog } from '../types';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { useFiestaStockStatement, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
|
||||
type StockRow = Record<string, unknown>;
|
||||
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? '');
|
||||
|
||||
interface InventoryViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
@@ -42,42 +46,51 @@ export default function InventoryView({
|
||||
searchQuery,
|
||||
isCoimbatoreView
|
||||
}: InventoryViewProps) {
|
||||
// ── Live stock data (Fiesta) ─────────────────────────────────────────────
|
||||
// The catalog grid and the hub-balance ledger are both derived from the live
|
||||
// stock statement for the tenant's primary outlet. We seed local state from
|
||||
// it once it loads so the existing add / CSV / replenish interactions keep
|
||||
// mutating in-session without losing the live baseline.
|
||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
||||
// union of products across all outlets the tenant owns (admin-only import adds
|
||||
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
||||
// can see all the stores under them at a glance.
|
||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||
const primaryLocation =
|
||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
||||
(locationsQ.data ?? [])[0];
|
||||
const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID;
|
||||
const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet';
|
||||
const locations = useMemo(
|
||||
() =>
|
||||
(locationsQ.data ?? []).map((l) => ({
|
||||
locationid: Number(l.locationid),
|
||||
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
|
||||
status: fstr(l.status) || 'Active',
|
||||
})),
|
||||
[locationsQ.data],
|
||||
);
|
||||
|
||||
const stockQ = useFiestaStockStatement({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: locationId,
|
||||
keyword: '',
|
||||
pageno: 1,
|
||||
pagesize: 100,
|
||||
});
|
||||
const storesStock = useFiestaStoresStock(
|
||||
FIESTA_TENANT_ID,
|
||||
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
|
||||
);
|
||||
const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading);
|
||||
const storesError =
|
||||
locationsQ.isError || (storesStock.length > 0 && storesStock.every((s) => s.isError));
|
||||
|
||||
// Global catalog = deduped union of every outlet's products, plus anything the
|
||||
// admin adds/imports in-session. Seeded once from the live data.
|
||||
const [products, setProducts] = useState<ProductMatrixItem[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
|
||||
const allStoreRows = storesStock.flatMap((s) => s.rows);
|
||||
useEffect(() => {
|
||||
if (stockQ.data) {
|
||||
setProducts(stockQ.data.map(stockRowToProduct));
|
||||
setInventory(stockQ.data.map((r) => stockRowToInventory(r, locationName)));
|
||||
}
|
||||
// locationName is derived from the same query chain; safe to depend on data.
|
||||
}, [stockQ.data, locationName]);
|
||||
if (seeded || allStoreRows.length === 0) return;
|
||||
const byId = new Map<string, StockRow>();
|
||||
allStoreRows.forEach((r) => {
|
||||
const id = rowId(r);
|
||||
if (id && !byId.has(id)) byId.set(id, r);
|
||||
});
|
||||
setProducts(Array.from(byId.values()).map(stockRowToProduct));
|
||||
setSeeded(true);
|
||||
}, [allStoreRows, seeded]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [replenishmentList, setReplenishmentList] = useState<string[]>([]);
|
||||
|
||||
// CSV Textarea input
|
||||
const [csvText, setCsvText] = useState(
|
||||
@@ -109,29 +122,7 @@ export default function InventoryView({
|
||||
products.forEach((p) => categorySet.add(p.category));
|
||||
const categories: string[] = ['ALL', ...Array.from(categorySet)];
|
||||
|
||||
// Handle SKU quantity change
|
||||
const handleUpdateStock = (sku: string, delta: number) => {
|
||||
setInventory(prev => prev.map(item => {
|
||||
if (item.sku === sku) {
|
||||
const newLevel = Math.max(0, item.stockLevel + delta);
|
||||
const status = newLevel < 25 ? 'Critical' : newLevel < 120 ? 'Low Stock' : 'Optimal';
|
||||
return { ...item, stockLevel: newLevel, status };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
};
|
||||
|
||||
// Trigger quick reorder recommendation
|
||||
const handleReplenishSku = (sku: string) => {
|
||||
if (replenishmentList.includes(sku)) return;
|
||||
setReplenishmentList(prev => [...prev, sku]);
|
||||
handleUpdateStock(sku, 500); // Add 500 units to stock
|
||||
setTimeout(() => {
|
||||
alert(`Auto-Replenish complete! 500 units ordered and allocated directly to corresponding hub for SKU ${sku}`);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Filter criteria
|
||||
// Filter criteria
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -161,20 +152,9 @@ export default function InventoryView({
|
||||
verified: true
|
||||
};
|
||||
|
||||
const createdInv: InventoryItem = {
|
||||
sku: newProduct.sku,
|
||||
name: newProduct.name,
|
||||
warehouse: 'RS Puram Hub (CBE-01)',
|
||||
stockLevel: newProduct.initialStock,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
};
|
||||
|
||||
setProducts([createdProd, ...products]);
|
||||
setInventory([createdInv, ...inventory]);
|
||||
setShowAddProductModal(false);
|
||||
alert(`Fresh product "${createdProd.name}" incorporated into Master Grocery Catalog and standard ledger!`);
|
||||
alert(`Fresh product "${createdProd.name}" added to the Global Catalog. It is now available to roll out to all outlets.`);
|
||||
|
||||
setNewProduct({
|
||||
name: '',
|
||||
@@ -196,7 +176,6 @@ export default function InventoryView({
|
||||
|
||||
let parsedCount = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
const newInvs: InventoryItem[] = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
@@ -204,8 +183,6 @@ export default function InventoryView({
|
||||
const name = parts[0];
|
||||
const sku = parts[1];
|
||||
const category = parts[2] || 'Staples / Rice';
|
||||
const price = Number(parts[3]) || 120;
|
||||
const initialStock = Number(parts[4]) || 150;
|
||||
|
||||
if (!products.some(p => p.sku === sku)) {
|
||||
newProds.push({
|
||||
@@ -221,16 +198,6 @@ export default function InventoryView({
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
});
|
||||
|
||||
newInvs.push({
|
||||
sku,
|
||||
name,
|
||||
warehouse: 'RS Puram Hub (CBE-01)',
|
||||
stockLevel: initialStock,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
});
|
||||
parsedCount++;
|
||||
}
|
||||
}
|
||||
@@ -238,7 +205,6 @@ export default function InventoryView({
|
||||
|
||||
if (parsedCount > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
setInventory(prev => [...newInvs, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
@@ -259,7 +225,6 @@ export default function InventoryView({
|
||||
const handleImportPreset = (presetName: string, itemsList: Array<{name: string, sku: string, cat: string, price: number, stock: number, img: string}>) => {
|
||||
let imported = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
const newInvs: InventoryItem[] = [];
|
||||
|
||||
itemsList.forEach((itm) => {
|
||||
if (!products.some(p => p.sku === itm.sku)) {
|
||||
@@ -276,23 +241,12 @@ export default function InventoryView({
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
});
|
||||
|
||||
newInvs.push({
|
||||
sku: itm.sku,
|
||||
name: itm.name,
|
||||
warehouse: 'Peelamedu Sort Center',
|
||||
stockLevel: itm.stock,
|
||||
maxCapacity: 800,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-EAST'
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
});
|
||||
|
||||
if (imported > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
setInventory(prev => [...newInvs, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
@@ -331,23 +285,23 @@ export default function InventoryView({
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] flex items-center gap-xs">
|
||||
<Layers className="text-[#581c87]" size={24} />
|
||||
Coimbatore Grocery Assortment & Catalogue Studio
|
||||
Product Catalog · Global Assortment
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
Build regional catalogues, update localized stock balances, parse batch imports, and style brand bag templates.
|
||||
The master product catalog for all your outlets. Import products into the global catalog and monitor live stock across every store under you.
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
{stockQ.isLoading ? (
|
||||
{storesLoading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live stock…
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live stock across outlets…
|
||||
</span>
|
||||
) : stockQ.isError ? (
|
||||
) : storesError ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {locationName} · {products.length} SKUs
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {locations.length} outlet{locations.length === 1 ? '' : 's'} · {products.length} catalog SKUs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -362,7 +316,7 @@ export default function InventoryView({
|
||||
: 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]'
|
||||
}`}
|
||||
>
|
||||
🌾 Catalog Grid & Ledger
|
||||
🌐 Global Catalog & Stocks
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -380,7 +334,23 @@ export default function InventoryView({
|
||||
|
||||
{activeTab === 'catalog' ? (
|
||||
<>
|
||||
{/* Quick Category Tab Filter Row */}
|
||||
{/* Admin access banner */}
|
||||
<div className="bg-[#faf5ff] border border-purple-100 rounded-xl p-md flex flex-col sm:flex-row sm:items-center justify-between gap-sm">
|
||||
<div className="flex items-start gap-sm">
|
||||
<ShieldCheck size={16} className="text-[#581c87] shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-sans text-xs text-zinc-700 font-semibold">Global Catalog — Admin access</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 leading-relaxed">
|
||||
As an admin you can import products into the global catalog. Store managers see it read-only. The stock below is live across every outlet under you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 self-start sm:self-center text-[9px] font-bold uppercase tracking-wider bg-[#581c87] text-white px-2 py-1 rounded">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category filter + admin import actions */}
|
||||
<div className="flex flex-wrap gap-2 py-1 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
@@ -388,156 +358,178 @@ export default function InventoryView({
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 rounded-lg font-sans text-xs font-semibold tracking-wide transition-all border outline-none cursor-pointer ${
|
||||
selectedCategory === cat
|
||||
selectedCategory === cat
|
||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
{cat === 'ALL' ? '🌾 All Catalog Items' : cat.replace('Groceries / ', '').replace('Staples / ', '').replace('Beverages / ', '').replace('Fresh Produce / ', '')}
|
||||
{cat === 'ALL' ? '🌐 All Catalog Items' : cat.replace('Groceries / ', '').replace('Staples / ', '').replace('Beverages / ', '').replace('Fresh Produce / ', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddProductModal(true)}
|
||||
className="bg-[#581c87] text-white px-xl py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual SKU
|
||||
</button>
|
||||
<div className="flex items-center gap-sm">
|
||||
<button
|
||||
onClick={() => setActiveTab('import_branding')}
|
||||
className="bg-white text-[#581c87] border border-purple-200 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-[#faf5ff] transition"
|
||||
>
|
||||
<UploadCloud size={14} />
|
||||
Import to Global Catalog
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddProductModal(true)}
|
||||
className="bg-[#581c87] text-white px-xl py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual SKU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-Pane Layout: Left Catalog Grid, Right Stock balances */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
||||
|
||||
{/* Left Grid: Grocery Catalogue Items Showcase */}
|
||||
<div className="lg:col-span-2 space-y-md">
|
||||
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl">
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] mb-xs">Active Assortment Items</h3>
|
||||
<p className="text-zinc-500 font-normal mb-md leading-relaxed text-[11px]">Primary catalog schema synchronized on customer booking apps. Total: {filteredProducts.length} items</p>
|
||||
{/* Global Catalog — master assortment grid (full width) */}
|
||||
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl text-xs font-sans">
|
||||
<div className="flex items-center justify-between mb-xs">
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">Global Product Catalog</h3>
|
||||
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded border border-purple-100">
|
||||
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-zinc-500 font-normal mb-md leading-relaxed text-[11px]">
|
||||
Master assortment available to roll out to every outlet — imported by the admin and synced to the customer booking apps.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
||||
{filteredProducts.map((prod) => {
|
||||
return (
|
||||
<div key={prod.id} className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden p-md flex gap-md shadow-sm hover:shadow-md transition-shadow relative">
|
||||
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{storesLoading && products.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">Loading global catalog…</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">No catalog products match your search or category.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-sm">
|
||||
{filteredProducts.map((prod) => (
|
||||
<div key={prod.id} className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden p-md flex gap-md shadow-sm hover:shadow-md transition-shadow relative">
|
||||
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50">
|
||||
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-zinc-900 leading-tight text-xs truncate">{prod.name}</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-bold tracking-tight">{prod.sku}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight text-xs">{prod.name}</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-bold tracking-tight">{prod.sku}</span>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-purple-700">
|
||||
{prod.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
||||
<span className="font-bold text-zinc-800 font-mono">{prod.unitsSold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Total revenue</span>
|
||||
<span className="font-bold text-emerald-600 font-mono">₹{prod.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-purple-700 shrink-0">
|
||||
{prod.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
||||
<span className="font-bold text-zinc-800 font-mono">{prod.unitsSold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Total revenue</span>
|
||||
<span className="font-bold text-emerald-600 font-mono">₹{prod.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Store Stock — live per-outlet breakdown for every store under the admin */}
|
||||
<div className="space-y-md text-xs font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-2">
|
||||
<PackageCheck size={16} className="text-[#581c87]" /> Store Stock · All Outlets Under You
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-[11px] mt-0.5">Live on-hand balances for each store you manage.</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded border border-purple-100">
|
||||
{locations.length} store{locations.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right Pane: Stock level adjustment ledgers */}
|
||||
<div className="space-y-md">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">Hub Balances Ledger</h3>
|
||||
<p className="text-zinc-500 font-normal leading-relaxed text-[11px] mt-0.5">Physical checkout balances across localized Coimbatore warehouse locations.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] select-none">
|
||||
{inventory.map((item, idx) => {
|
||||
const percentage = (item.stockLevel / item.maxCapacity) * 100;
|
||||
return (
|
||||
<div key={idx} className="py-md space-y-xs">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-1 font-medium">{item.warehouse}</p>
|
||||
<div className="flex gap-px pt-1 items-center">
|
||||
<span className="bg-[#f1f5f9] px-1 py-0.5 rounded text-[8px] font-bold text-zinc-500 font-mono tracking-tight mr-1">{item.region}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-bold tracking-wide uppercase ${
|
||||
item.status === 'Critical' ? 'bg-rose-50 text-rose-600 border border-rose-100 animate-pulse' : item.status === 'Low Stock' ? 'bg-amber-50 text-amber-600' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
● {item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-1">
|
||||
<span className="font-mono font-bold text-[#0f172a] block">{item.stockLevel.toLocaleString()} units</span>
|
||||
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
className="bg-zinc-100 hover:bg-zinc-200 p-1 px-2 rounded font-bold cursor-pointer text-[10px]"
|
||||
onClick={() => handleUpdateStock(item.sku, -5)}
|
||||
title="Decrement 5 units"
|
||||
>
|
||||
-5
|
||||
</button>
|
||||
<button
|
||||
className="bg-zinc-100 hover:bg-zinc-200 p-1 px-2 rounded font-bold cursor-pointer text-[10px]"
|
||||
onClick={() => handleUpdateStock(item.sku, 5)}
|
||||
title="Increment 5 units"
|
||||
>
|
||||
+5
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gauge percentage */}
|
||||
<div className="pt-1.5 space-y-1">
|
||||
<div className="w-full bg-[#eceef0] h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
item.status === 'Critical' ? 'bg-rose-500' : item.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#581c87]'
|
||||
}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-[9px] text-zinc-400 font-bold">
|
||||
<span>Verification Level: {Math.round(percentage)}%</span>
|
||||
{item.status !== 'Optimal' && (
|
||||
<button
|
||||
onClick={() => handleReplenishSku(item.sku)}
|
||||
className="text-[#581c87] hover:underline flex items-center gap-px font-bold cursor-pointer"
|
||||
>
|
||||
<Zap size={11} className="text-amber-500 animate-bounce" />
|
||||
Auto-Replenish
|
||||
</button>
|
||||
)}
|
||||
{locations.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white">
|
||||
{locationsQ.isLoading ? 'Loading outlets…' : 'No outlets found under this tenant.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-gutter">
|
||||
{storesStock.map((store) => {
|
||||
const items = store.rows
|
||||
.map((r) => stockRowToInventory(r, store.locationname))
|
||||
.filter((it) => !searchQuery || it.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const totalUnits = items.reduce((a, it) => a + it.stockLevel, 0);
|
||||
const lowCount = items.filter((it) => it.status !== 'Optimal').length;
|
||||
const meta = locations.find((l) => l.locationid === store.locationid);
|
||||
const status = meta?.status ?? 'Active';
|
||||
return (
|
||||
<div key={store.locationid} className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc]">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[#0f172a] truncate">{store.locationname}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">
|
||||
{store.isLoading ? 'Syncing…' : `${items.length} SKUs · ${totalUnits.toLocaleString('en-IN')} units on hand`}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
status.toLowerCase() === 'active'
|
||||
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-100'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
{lowCount > 0 && !store.isLoading && (
|
||||
<p className="text-[10px] text-amber-600 font-semibold mt-1.5 flex items-center gap-1">
|
||||
<AlertTriangle size={11} /> {lowCount} low / critical SKU{lowCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] max-h-72 overflow-y-auto">
|
||||
{store.isLoading ? (
|
||||
<div className="p-lg text-center text-zinc-400 text-[11px]">Loading store stock…</div>
|
||||
) : store.isError ? (
|
||||
<div className="p-lg text-center text-rose-500 text-[11px]">Couldn't load this store's stock.</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-lg text-center text-zinc-400 text-[11px]">No stock items{searchQuery ? ' match your search' : ''}.</div>
|
||||
) : (
|
||||
items.map((it, idx) => {
|
||||
const pct = Math.min(100, (it.stockLevel / it.maxCapacity) * 100);
|
||||
return (
|
||||
<div key={idx} className="p-sm">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<p className="font-semibold text-zinc-800 text-[11px] leading-tight min-w-0 truncate">{it.name}</p>
|
||||
<span className="font-mono font-bold text-[#0f172a] text-[11px] shrink-0">{it.stockLevel.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 bg-[#eceef0] h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
it.status === 'Critical' ? 'bg-rose-500' : it.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#581c87]'
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[8px] font-bold uppercase shrink-0 ${
|
||||
it.status === 'Critical' ? 'text-rose-600' : it.status === 'Low Stock' ? 'text-amber-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
{it.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user