The upload modal made the admin choose a branch, then generated and validated the workbook against that choice. A merchant with several outlets had to repeat the whole cycle per branch, and the picker defaulted to the first outlet, so an admin who never opened it credited the wrong store — the file and the selection agreed, so nothing flagged it. The sheet now carries tenantid, locationid and the store name as locked columns on every row, and the row's own locationid routes its sale. One file covers the whole business: fill in qtysold wherever something sold, across as many branches as needed, and upload once. The picker is gone from the admin surface entirely, and the store user's page keeps passing its locationId, which pins the upload to that branch and rejects rows for any other before they are even sent. Bills are keyed on branch first and bill number second. Counter books at different outlets restart numbering from 1, so a shared number is two sales rather than a duplicate, and keying on the number alone would have dropped the second one. Because a single upload can now move stock at six outlets, one total is no longer enough to check before committing: the preview gains a store count, a per-store table of lines, units, amount and problems, and a Store column on every row, and results name the branch on each bill. Rows are ordered store then product with an Excel autofilter, so a branch can isolate its own rows in a file spanning the business. Verified end to end against a two-branch tenant sharing a product id across both outlets: one upload deducted each branch independently, a pinned upload refused the other branch's rows, and re-uploading deducted nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1125 lines
61 KiB
TypeScript
1125 lines
61 KiB
TypeScript
/**
|
|
* @license
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Layers,
|
|
Search,
|
|
Plus,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
TrendingUp,
|
|
Sparkles,
|
|
Check,
|
|
Package,
|
|
ChevronRight,
|
|
TrendingDown,
|
|
Trash2,
|
|
PackageCheck,
|
|
ShieldCheck,
|
|
Tag,
|
|
UploadCloud,
|
|
FileSpreadsheet,
|
|
Palette,
|
|
Info,
|
|
X,
|
|
Server,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
CheckCircle,
|
|
ShoppingCart,
|
|
Inbox,
|
|
Store,
|
|
Activity,
|
|
Award,
|
|
Filter,
|
|
Box,
|
|
ArrowLeft
|
|
} from 'lucide-react';
|
|
import { ProductMatrixItem } from '../types';
|
|
import {
|
|
useFiestaTenantLocations,
|
|
useFiestaStoresStock,
|
|
useFiestaProductCategories,
|
|
useFiestaUpdateStockRequest,
|
|
useFiestaGetStockRequests,
|
|
} from '../services/fiestaQueries';
|
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
|
|
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
|
import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
|
|
import BulkCartDrawer from './BulkCartDrawer';
|
|
import AwaitingApi from './AwaitingApi';
|
|
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
|
import CatalogueBrowser from './CatalogueBrowser';
|
|
import OfflineSalesUpload from './OfflineSalesUpload';
|
|
import { useCompare } from '../contexts/CompareContext';
|
|
|
|
|
|
|
|
type StockRow = Record<string, unknown>;
|
|
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? '');
|
|
|
|
|
|
interface InventoryViewProps {
|
|
searchQuery: string;
|
|
isCoimbatoreView: boolean;
|
|
tenantId?: number;
|
|
isSidebarOpen?: boolean;
|
|
}
|
|
|
|
export default function InventoryView({
|
|
searchQuery,
|
|
isCoimbatoreView,
|
|
tenantId = FIESTA_TENANT_ID,
|
|
isSidebarOpen = false
|
|
}: InventoryViewProps) {
|
|
const { setHideCompareBar } = useCompare();
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
|
|
const [showOfflineSales, setShowOfflineSales] = useState(false);
|
|
const navigate = useNavigate();
|
|
|
|
// ── 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(tenantId);
|
|
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],
|
|
);
|
|
|
|
// The admin catalogue (imports, curation) is scoped to this tenant's own
|
|
// primary/hub outlet — not the hardcoded Fiesta defaults, so onboarded
|
|
// tenants other than Fiesta see their own imports reflected here.
|
|
const primaryLocationId = locations[0]?.locationid ?? FIESTA_PRIMARY_LOCATION_ID;
|
|
|
|
const storesStock = useFiestaStoresStock(
|
|
tenantId,
|
|
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. Computes live from storesStock and storeCat.items.
|
|
const [selectedAdminProduct, setSelectedAdminProduct] = useState<ProductMatrixItem | null>(null);
|
|
const [selectedRequest, setSelectedRequest] = useState<any>(null);
|
|
|
|
const allStoreRows = storesStock.flatMap((s) => s.rows);
|
|
|
|
const [activeTab, setActiveTab] = useState<'catalog' | 'requests' | 'global_catalogue'>('catalog');
|
|
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(false);
|
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
|
const [hoveredAdminProduct, setHoveredAdminProduct] = useState<ProductMatrixItem | null>(null);
|
|
const [localSearch, setLocalSearch] = useState('');
|
|
const storeCat = useStoreCatalogue(tenantId, primaryLocationId);
|
|
const [importPrice, setImportPrice] = useState<string>('');
|
|
const [isSettingPrice, setIsSettingPrice] = useState(false);
|
|
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null);
|
|
const [cardImportPrice, setCardImportPrice] = useState<string>('');
|
|
|
|
const selectedProductId = selectedAdminProduct?.id;
|
|
useEffect(() => {
|
|
if (selectedAdminProduct) {
|
|
const storeItem = storeCat.items.find(i => i.productid === selectedAdminProduct.id);
|
|
const existingPrice = storeItem?.price || selectedAdminProduct.price;
|
|
setImportPrice(
|
|
existingPrice && existingPrice > 0
|
|
? String(existingPrice)
|
|
: (selectedAdminProduct.unitsSold > 0 ? String(Math.round(selectedAdminProduct.revenue / selectedAdminProduct.unitsSold)) : '')
|
|
);
|
|
setIsSettingPrice(false);
|
|
} else {
|
|
setImportPrice('');
|
|
setIsSettingPrice(false);
|
|
}
|
|
}, [selectedProductId]);
|
|
|
|
const products = useMemo(() => {
|
|
const byId = new Map<string, ProductMatrixItem>();
|
|
|
|
allStoreRows.forEach((r) => {
|
|
const id = rowId(r);
|
|
if (id && !byId.has(id)) byId.set(id, stockRowToProduct(r));
|
|
});
|
|
|
|
// Ensure all items published to the store catalogue or imported as drafts are preserved
|
|
storeCat.items.forEach((item) => {
|
|
const id = String(item.productid);
|
|
if (!byId.has(id)) {
|
|
byId.set(id, {
|
|
id: id,
|
|
name: item.name,
|
|
sku: item.sku || `SKU-${item.productid}`,
|
|
image: item.image,
|
|
category: item.category,
|
|
price: item.price,
|
|
unitsSold: 0,
|
|
revenue: 0,
|
|
stockStatus: 'Healthy',
|
|
trend: 'flat',
|
|
exposure: 'All Outlets',
|
|
// "Fully published with price" — tested on the price itself, not on
|
|
// status. Status is not a publish flag: the backend overwrites our
|
|
// 'Active' with an availability value, which used to flip published
|
|
// products back to unverified. See storeCatalogue.isPublishedItem.
|
|
verified: item.price > 0,
|
|
});
|
|
} else {
|
|
// If it exists but we have curated price in storeCat, we could apply it here if needed
|
|
const existing = byId.get(id)!;
|
|
existing.price = item.price || existing.price;
|
|
existing.verified = existing.verified || item.price > 0;
|
|
}
|
|
});
|
|
|
|
return Array.from(byId.values());
|
|
}, [allStoreRows, storeCat.items]);
|
|
|
|
const [requestStoreFilter, setRequestStoreFilter] = useState<string>('All Stores');
|
|
const [requestStatusFilter, setRequestStatusFilter] = useState<string>('All Statuses');
|
|
const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]);
|
|
|
|
// Fetch store stock requests
|
|
const stockRequestsQ = useFiestaGetStockRequests({ tenantid: tenantId, pagesize: 1000, date: requestDate });
|
|
|
|
const [storeRequests, setStoreRequests] = useState<{ locationid: number, locationname: string, picks: Record<string, any> }[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'requests' && stockRequestsQ.data) {
|
|
const reqsMap: Record<number, any> = {};
|
|
stockRequestsQ.data.forEach((req: any) => {
|
|
if (!reqsMap[req.locationid]) {
|
|
const loc = locations.find(l => String(l.locationid) === String(req.locationid));
|
|
reqsMap[req.locationid] = {
|
|
locationid: req.locationid,
|
|
locationname: req.locationname || (loc ? loc.locationname : `Outlet #${req.locationid}`),
|
|
picks: {}
|
|
};
|
|
}
|
|
// Key by requestid so multiple requests for the same product are all shown
|
|
if (!reqsMap[req.locationid].picks[req.requestid]) {
|
|
reqsMap[req.locationid].picks[req.requestid] = {
|
|
qty: req.qty,
|
|
status: req.status,
|
|
requestedAt: req.created,
|
|
resolvedAt: req.updated,
|
|
productid: req.productid,
|
|
productname: req.productname,
|
|
productimage: req.productimage,
|
|
requestid: req.requestid
|
|
};
|
|
}
|
|
});
|
|
setStoreRequests(Object.values(reqsMap).filter(r => Object.keys(r.picks).length > 0));
|
|
}
|
|
}, [activeTab, locations, stockRequestsQ.data]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'requests') {
|
|
stockRequestsQ.refetch();
|
|
}
|
|
}, [activeTab, stockRequestsQ]);
|
|
|
|
const updateStockRequestMutation = useFiestaUpdateStockRequest();
|
|
|
|
const updateProductRequestStatus = (locationid: number, requestid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => {
|
|
updateStockRequestMutation.mutate({
|
|
tenantid: tenantId,
|
|
locationid,
|
|
productid: Number(productid),
|
|
requestid,
|
|
status
|
|
});
|
|
|
|
// Optimistic UI update
|
|
setStoreRequests(prev => prev.map(r => {
|
|
if (r.locationid === locationid && r.picks[requestid]) {
|
|
return {
|
|
...r,
|
|
picks: {
|
|
...r.picks,
|
|
[requestid]: { ...r.picks[requestid], status, resolvedAt: new Date().toISOString() }
|
|
}
|
|
};
|
|
}
|
|
return r;
|
|
}));
|
|
};
|
|
|
|
// Hide compare bar everywhere for now
|
|
useEffect(() => {
|
|
setHideCompareBar(true);
|
|
}, [activeTab, setHideCompareBar]);
|
|
|
|
// Live product categories (for the Add-Product modal dropdown).
|
|
const productCategoriesQ = useFiestaProductCategories();
|
|
const productCategoryNames = useMemo(
|
|
() =>
|
|
(productCategoriesQ.data ?? [])
|
|
.map((c) => fstr(c.categoryname))
|
|
.filter((name): name is string => Boolean(name)),
|
|
[productCategoriesQ.data],
|
|
);
|
|
|
|
// Categories derived from the live catalog (falls back to ALL only).
|
|
const categories = useMemo(() => {
|
|
const cats = Array.from(new Set(products.map((p) => p.category.split(' / ')[0])));
|
|
return cats.sort();
|
|
}, [products]);
|
|
|
|
// Filter criteria
|
|
const filteredProducts = useMemo(() => {
|
|
let result = products;
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase();
|
|
result = result.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
p.sku.toLowerCase().includes(q) ||
|
|
p.category.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
if (localSearch) {
|
|
const q = localSearch.toLowerCase();
|
|
result = result.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
p.sku.toLowerCase().includes(q) ||
|
|
p.category.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
if (selectedCategories.length > 0) {
|
|
result = result.filter((p) => selectedCategories.includes(p.category.split(' / ')[0]));
|
|
}
|
|
return result;
|
|
}, [products, searchQuery, localSearch, selectedCategories]);
|
|
|
|
const toggleCategory = (cat: string) => {
|
|
setSelectedCategories((prev) =>
|
|
prev.includes(cat) ? [] : [cat],
|
|
);
|
|
};
|
|
|
|
const handleToggleProductExposure = (id: string) => {
|
|
// Left empty for now, as products are managed via storeCat
|
|
};
|
|
|
|
const flattenedRequests = useMemo(() => {
|
|
return storeRequests
|
|
.filter(r => requestStoreFilter === 'All Stores' || r.locationname === requestStoreFilter)
|
|
.flatMap(store => {
|
|
return Object.entries(store.picks)
|
|
.filter(([_, pick]) => requestStatusFilter === 'All Statuses' || String((pick as any).status).toLowerCase() === requestStatusFilter.toLowerCase())
|
|
.map(([requestId, pick]) => {
|
|
const productId = (pick as any).productid;
|
|
const product = products.find(p => String(p.id) === String(productId));
|
|
return {
|
|
locationid: store.locationid,
|
|
locationname: store.locationname,
|
|
productid: productId,
|
|
requestid: Number(requestId),
|
|
product,
|
|
pickData: pick
|
|
};
|
|
});
|
|
})
|
|
.sort((a, b) => new Date(b.pickData.requestedAt).getTime() - new Date(a.pickData.requestedAt).getTime());
|
|
}, [storeRequests, products, requestStoreFilter, requestStatusFilter]);
|
|
|
|
const requestingStores = useMemo(() => {
|
|
return Array.from(new Set(locations.map(l => l.locationname))).sort();
|
|
}, [locations]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full animate-in fade-in duration-500 font-sans relative overflow-hidden">
|
|
|
|
{/* Immersive Background Blur Blobs */}
|
|
<div className="absolute top-10 left-1/4 w-96 h-96 bg-purple-400/5 rounded-full blur-[120px] pointer-events-none -z-10 animate-pulse" />
|
|
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
|
|
|
|
{/* Header and Metrics */}
|
|
{activeTab === 'catalog' && (
|
|
<div className="mb-2 z-40 relative">
|
|
{/* Small card metrics grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
|
{/* Card 1: Total SKUs */}
|
|
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-purple-200 transition-all group">
|
|
<div className="flex justify-between items-start">
|
|
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Total SKUs</span>
|
|
<div className="p-1 rounded-md bg-purple-50 text-purple-600 group-hover:scale-110 transition-transform">
|
|
<Package className="w-3.5 h-3.5" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
|
{products.length}
|
|
</h3>
|
|
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Master catalogue</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 2: Synced Outlets */}
|
|
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-indigo-200 transition-all group">
|
|
<div className="flex justify-between items-start">
|
|
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Active Outlets</span>
|
|
<div className="p-1 rounded-md bg-indigo-50 text-indigo-600 group-hover:scale-110 transition-transform">
|
|
<Layers className="w-3.5 h-3.5" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
|
{locations.length}
|
|
</h3>
|
|
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Synced locations</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 3: Total On-Hand Volume */}
|
|
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-emerald-200 transition-all group">
|
|
<div className="flex justify-between items-start">
|
|
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Total Stock</span>
|
|
<div className="p-1 rounded-md bg-emerald-50 text-emerald-600 group-hover:scale-110 transition-transform">
|
|
<Check className="w-3.5 h-3.5" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
|
{storesStock.reduce((total, store) => {
|
|
return total + (store.rows || []).reduce((subTotal, r) => {
|
|
const inv = stockRowToInventory(r, store.locationname);
|
|
return subTotal + (inv.stockLevel || 0);
|
|
}, 0);
|
|
}, 0).toLocaleString('en-IN')}
|
|
</h3>
|
|
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Units on hand</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card 4: Catalog Health */}
|
|
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-amber-200 transition-all group">
|
|
<div className="flex justify-between items-start">
|
|
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Catalogue Sync Ratio</span>
|
|
<div className="p-1 rounded-md bg-amber-50 text-amber-600 group-hover:scale-110 transition-transform">
|
|
<ShieldCheck className="w-3.5 h-3.5" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
|
{products.length > 0 ? `${Math.round((products.filter(p => p.verified).length / products.length) * 100)}%` : '100%'}
|
|
</h3>
|
|
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Active Portfolio</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'catalog' ? (
|
|
<>
|
|
<div className="flex flex-col xl:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
|
|
{isLocalSidebarOpen && (
|
|
<div className="w-full xl:w-52 shrink-0 flex flex-col gap-5 pr-2 pb-2">
|
|
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col">
|
|
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0">
|
|
<h3 className="font-bold text-sm text-slate-800 flex items-center gap-2">
|
|
<Filter size={16} className="text-[#662582]" /> Filter Product
|
|
</h3>
|
|
</div>
|
|
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
|
|
<div>
|
|
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
|
<Layers size={14} className="text-[#662582]" /> Categories
|
|
</h3>
|
|
<div className="space-y-1.5">
|
|
{categories.map((cat) => (
|
|
<label key={cat} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
|
<div className="relative flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCategories.includes(cat)}
|
|
onChange={() => toggleCategory(cat)}
|
|
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
|
|
/>
|
|
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
|
</div>
|
|
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(cat) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
|
{cat}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{(selectedCategories.length > 0 || localSearch) && (
|
|
<button
|
|
onClick={() => { setSelectedCategories([]); setLocalSearch(''); }}
|
|
className="w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4"
|
|
>
|
|
Clear All Filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Main Content Area ── */}
|
|
<div className="flex-1 min-w-0 overflow-y-auto custom-scrollbar bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-5 shadow-sm flex flex-col">
|
|
<div className={`flex justify-between gap-4 mb-5 shrink-0 ${isSidebarOpen && isLocalSidebarOpen ? 'flex-col items-start' : 'flex-col lg:flex-row lg:items-center items-start'}`}>
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap">
|
|
<Package size={15} className="text-[#662582] shrink-0" /> <span className="truncate">Product Catalogue Assortment</span>
|
|
<div className="flex items-center gap-1.5 ml-1">
|
|
<span className="text-[9px] text-emerald-700 font-bold bg-emerald-50 px-1.5 py-0.5 rounded-md border border-emerald-100 whitespace-nowrap">
|
|
{storeCat.items.length} in store catalogue
|
|
</span>
|
|
<span className="text-[9px] text-[#662582] font-bold bg-purple-50 px-1.5 py-0.5 rounded-md border border-purple-100/50 whitespace-nowrap">
|
|
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded
|
|
</span>
|
|
</div>
|
|
</h3>
|
|
<p className="text-[9px] text-zinc-400 font-medium mt-1 truncate">Pick products & set quantities — selected items appear in every store's catalogue.</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto shrink-0">
|
|
<button
|
|
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
|
|
className="flex items-center gap-1.5 bg-white border border-slate-200 px-2.5 py-1.5 rounded-lg text-[10px] font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
|
>
|
|
<Filter size={12} /> Filter
|
|
</button>
|
|
|
|
<div className="relative flex-1 sm:flex-none w-full sm:w-32 xl:w-48 h-[32px]">
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
value={localSearch}
|
|
onChange={(e) => setLocalSearch(e.target.value)}
|
|
className="w-full h-full pl-8 pr-3 py-1.5 bg-white border border-slate-200 rounded-lg text-[10px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
|
|
/>
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
|
{localSearch && (
|
|
<button
|
|
onClick={() => setLocalSearch('')}
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setActiveTab('requests')}
|
|
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
|
>
|
|
<Inbox size={12} /> Stock Requests
|
|
{storeRequests.length > 0 && (
|
|
<span className="ml-1 bg-rose-500 text-white text-[8px] px-1 py-0.5 rounded-sm leading-none">{storeRequests.length}</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowOfflineSales(true)}
|
|
title="Upload an Excel sheet of counter sales to deduct stock and record the revenue"
|
|
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
|
>
|
|
<UploadCloud size={12} /> Offline Sales
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('global_catalogue')}
|
|
className="flex items-center gap-1.5 bg-[#662582] hover:bg-purple-800 text-white px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
|
>
|
|
<Plus size={12} /> Import Product
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{storesLoading && products.length === 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
|
{Array.from({ length: 10 }).map((_, i) => (
|
|
<div key={i} className="bg-white border border-slate-200 rounded-2xl flex flex-col p-2 gap-2 h-64">
|
|
<Skeleton className="w-full h-28 rounded-xl" />
|
|
<Skeleton className="w-3/4 h-4 mt-2" />
|
|
<Skeleton className="w-1/2 h-3" />
|
|
<div className="grid grid-cols-2 gap-2 mt-auto">
|
|
<Skeleton className="w-full h-8 rounded-lg" />
|
|
<Skeleton className="w-full h-8 rounded-lg" />
|
|
</div>
|
|
<Skeleton className="w-full h-8 rounded-xl mt-2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filteredProducts.length === 0 ? (
|
|
<div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalogue products match your selection.</div>
|
|
) : (
|
|
<div className="flex flex-col xl:flex-row gap-6">
|
|
{/* Left Side: Normal Catalogue */}
|
|
<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`}>
|
|
{filteredProducts.map((prod) => {
|
|
// 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 (
|
|
<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">
|
|
{/* Image Section - Top */}
|
|
<div className="w-full h-48 bg-white p-4 relative overflow-hidden shrink-0 border-b border-slate-100 flex items-center justify-center">
|
|
<img
|
|
src={prod.image}
|
|
alt={prod.name}
|
|
referrerPolicy="no-referrer"
|
|
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
|
{prod.isSample && (
|
|
<div className="absolute top-2 left-2 flex flex-col items-start gap-1 z-10">
|
|
<span className="px-1.5 py-0.5 rounded-none bg-indigo-100 text-indigo-800 text-[8px] font-extrabold uppercase shadow-sm flex items-center gap-1 border border-indigo-200">
|
|
<Package size={10} /> SAMPLE
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content Section */}
|
|
<div className="p-3 flex flex-col flex-1 gap-2">
|
|
<div className="flex-1 min-w-0 pb-2">
|
|
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2" title={prod.name}>{prod.name}</h4>
|
|
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{prod.sku}</p>
|
|
</div>
|
|
|
|
<div className="mt-auto pt-2 border-t border-slate-100/50 flex flex-col gap-2">
|
|
<div className="flex items-center justify-between text-[10px] text-slate-500 font-medium">
|
|
<span>Sold: <strong className="text-slate-800 font-mono">{prod.unitsSold.toLocaleString()}</strong></span>
|
|
<span className="font-extrabold font-mono text-emerald-600">₹{prod.revenue.toLocaleString()}</span>
|
|
</div>
|
|
|
|
{isPublished ? (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); storeCat.remove(prod.id); }}
|
|
title="Remove from store catalogue"
|
|
className="w-full bg-emerald-50 hover:bg-rose-50 text-emerald-700 hover:text-rose-600 border border-emerald-200 hover:border-rose-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
|
|
>
|
|
<CheckCircle size={12} />
|
|
Remove from Store
|
|
</button>
|
|
) : addingPriceProdId === prod.id ? (
|
|
<div className="flex flex-col gap-1.5 p-2 bg-slate-50 border border-slate-200 rounded-none" onClick={(e) => e.stopPropagation()}>
|
|
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center">Set Selling Price (₹)</label>
|
|
<input
|
|
type="number"
|
|
value={cardImportPrice}
|
|
onChange={(e) => setCardImportPrice(e.target.value)}
|
|
placeholder="e.g. 50"
|
|
className="px-2 py-1 border border-slate-200 rounded-none text-[10px] font-bold focus:outline-none focus:border-[#662582]"
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-1">
|
|
<button onClick={(e) => { e.stopPropagation(); setAddingPriceProdId(null); }} className="flex-1 py-1 bg-white text-slate-500 border border-slate-200 text-[9px] font-bold">Cancel</button>
|
|
<button
|
|
disabled={!cardImportPrice || Number(cardImportPrice) <= 0}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
// qty: 0 — publishing to the catalogue is a listing/pricing action,
|
|
// not a stock delivery. Real quantity only lands via the
|
|
// request → approve → Mark as Received flow.
|
|
storeCat.add({ productid: prod.id, name: prod.name, image: prod.image, category: prod.category, sku: prod.sku, price: Number(cardImportPrice), unit: prod.exposure, qty: 0, status: 'Active' });
|
|
setAddingPriceProdId(null);
|
|
}}
|
|
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
|
|
>
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setCardImportPrice(String(prod.price || (prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 0)));
|
|
setAddingPriceProdId(prod.id);
|
|
}}
|
|
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
|
|
title="Add to Store Catalogue"
|
|
>
|
|
<Plus size={12} /> Add to Store
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : activeTab === 'requests' ? (
|
|
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar animate-in fade-in duration-300">
|
|
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-end gap-4 mb-2">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => setActiveTab('catalog')}
|
|
className="p-2 -ml-2 hover:bg-slate-100 rounded-xl transition-colors cursor-pointer text-slate-500 hover:text-slate-900"
|
|
title="Back to Catalogue"
|
|
>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
|
</button>
|
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-100 flex items-center justify-center shadow-[0_2px_10px_-3px_rgba(102,37,130,0.15)] shrink-0">
|
|
<Inbox size={24} className="text-[#662582]" strokeWidth={2.5} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-xl font-black text-slate-900 tracking-tight mb-1">
|
|
Pending Stock Requests
|
|
</h2>
|
|
<div className="text-xs font-semibold text-slate-500 flex items-center gap-2">
|
|
Store Branch Inventory Requests
|
|
<span className="px-2 py-0.5 rounded-md bg-slate-100 text-slate-600 text-[10px] font-bold tracking-wider uppercase border border-slate-200 shadow-sm">
|
|
{flattenedRequests.length} Total
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative group">
|
|
<Store size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-[#662582] transition-colors z-10" />
|
|
<select
|
|
value={requestStoreFilter}
|
|
onChange={(e) => setRequestStoreFilter(e.target.value)}
|
|
className="appearance-none bg-white border border-slate-200 text-slate-700 pl-9 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20"
|
|
>
|
|
<option value="All Stores">All Stores</option>
|
|
{requestingStores.map(store => (
|
|
<option key={store} value={store}>{store}</option>
|
|
))}
|
|
</select>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 group-hover:text-[#662582] transition-colors">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative group">
|
|
<select
|
|
value={requestStatusFilter}
|
|
onChange={(e) => setRequestStatusFilter(e.target.value)}
|
|
className="appearance-none bg-white border border-slate-200 text-slate-700 pl-4 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20"
|
|
>
|
|
<option value="All Statuses">All Statuses</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="approved">Approved</option>
|
|
<option value="rejected">Rejected</option>
|
|
<option value="received">Received</option>
|
|
</select>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 group-hover:text-[#662582] transition-colors">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="date"
|
|
value={requestDate}
|
|
onChange={(e) => setRequestDate(e.target.value)}
|
|
className="px-3 py-2 border border-slate-200 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-sm font-semibold text-slate-700 bg-white hover:border-[#662582]/40 hover:shadow-md transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{flattenedRequests.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-80 bg-gradient-to-b from-white to-slate-50 border border-slate-200/60 border-dashed rounded-2xl shadow-sm">
|
|
<div className="w-20 h-20 rounded-full bg-slate-100/80 flex items-center justify-center mb-6 shadow-inner">
|
|
<Inbox size={32} className="text-slate-400" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-slate-700 tracking-tight">No Pending Requests</h3>
|
|
<p className="text-sm font-medium text-slate-400 mt-2 text-center w-full max-w-[400px] leading-relaxed">
|
|
You're all caught up! All store stock requests have been processed and there is currently no pending action required.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse" style={{ minWidth: 960 }}>
|
|
<thead>
|
|
<tr className="bg-slate-50/80 border-b border-slate-200">
|
|
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
|
|
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{flattenedRequests.map((req, idx) => {
|
|
const isApproved = req.pickData.status === 'Approved';
|
|
const isRejected = req.pickData.status === 'Rejected';
|
|
const isPending = req.pickData.status === 'Pending';
|
|
const isCancelled = req.pickData.status === 'Cancelled';
|
|
const isReceived = req.pickData.status === 'Received';
|
|
|
|
return (
|
|
<tr
|
|
key={`${req.locationid}-${req.productid}-${idx}`}
|
|
className="transition-all duration-200 hover:bg-slate-50/80 group cursor-pointer"
|
|
onClick={() => setSelectedRequest(req)}
|
|
>
|
|
<td className="px-5 py-4 whitespace-nowrap">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-semibold text-slate-900">
|
|
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
|
|
</span>
|
|
<span className="text-xs font-medium text-slate-500">
|
|
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4 whitespace-nowrap">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50 transition-colors group-hover:bg-indigo-100/50">
|
|
<Store size={14} className="text-indigo-500" />
|
|
<span className="font-bold text-xs">{req.locationname}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
|
|
<img src={req.product?.image} alt={req.product?.name} className="w-full h-full object-cover" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{req.product?.name}</p>
|
|
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{req.product?.sku}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 rounded-md bg-slate-100 text-slate-700 font-bold text-sm border border-slate-200/60">
|
|
{req.pickData.qty || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${
|
|
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' :
|
|
isApproved ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
|
|
isRejected ? 'bg-rose-50 text-rose-700 border-rose-200' :
|
|
isCancelled ? 'bg-slate-50 text-slate-700 border-slate-200' :
|
|
'bg-amber-50 text-amber-700 border-amber-200'
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
|
isReceived ? 'bg-indigo-500' :
|
|
isApproved ? 'bg-emerald-500' :
|
|
isRejected ? 'bg-rose-500' :
|
|
isCancelled ? 'bg-slate-400' :
|
|
'bg-amber-500 animate-pulse'
|
|
}`} />
|
|
{req.pickData.status || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4 whitespace-nowrap">
|
|
<span className="text-xs font-medium text-slate-500">
|
|
{req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4 whitespace-nowrap text-right">
|
|
{isPending ? (
|
|
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:text-emerald-800 border border-emerald-200 transition-all active:scale-95"
|
|
title="Approve Request"
|
|
>
|
|
<CheckCircle size={14} /> Approve
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold text-rose-700 bg-rose-50 hover:bg-rose-100 hover:text-rose-800 border border-rose-200 transition-all active:scale-95"
|
|
title="Reject Request"
|
|
>
|
|
<X size={14} /> Reject
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest">
|
|
Processed
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : activeTab === 'global_catalogue' ? (
|
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden animate-in fade-in duration-300">
|
|
<CatalogueBrowser
|
|
tenantid={tenantId}
|
|
locationid={primaryLocationId}
|
|
onClose={() => setActiveTab('catalog')}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ── Slide Drawer ── */}
|
|
<SlideDrawer
|
|
isOpen={selectedAdminProduct !== null}
|
|
onClose={() => setSelectedAdminProduct(null)}
|
|
title="Administrative Product Details"
|
|
>
|
|
{selectedAdminProduct && (() => {
|
|
|
|
const isAlreadyInCatalog = products.some(p => p.sku === selectedAdminProduct.sku);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8 pb-8">
|
|
|
|
{/* Clean Image Container */}
|
|
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4">
|
|
<img src={selectedAdminProduct.image} alt={selectedAdminProduct.name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
|
|
</div>
|
|
|
|
{/* Title & Basics */}
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-500 tracking-wider mb-1">SKU: {selectedAdminProduct.sku}</p>
|
|
<h3 className="text-2xl font-bold text-slate-900 leading-tight mb-3">{selectedAdminProduct.name}</h3>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="px-2.5 py-1 rounded bg-slate-100 text-slate-600 text-[10px] font-semibold uppercase tracking-widest border border-slate-200">
|
|
{String(selectedAdminProduct.category || '').split(' / ')[0]}
|
|
</span>
|
|
{isAlreadyInCatalog && (
|
|
<span className="px-2.5 py-1 rounded bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-widest border border-emerald-200 flex items-center gap-1.5">
|
|
<CheckCircle size={12} strokeWidth={2.5} /> In Catalogue
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Real Price and Providers */}
|
|
<div className="bg-slate-50 rounded-2xl border border-slate-200 p-6 space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<span className="text-xs font-semibold text-slate-500">Price Range</span>
|
|
<div className="text-xl font-bold text-slate-900">{selectedAdminProduct.priceRange || 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
{selectedAdminProduct.providers && selectedAdminProduct.providers.length > 0 && (
|
|
<div className="pt-4 border-t border-slate-200">
|
|
<span className="text-xs font-semibold text-slate-500 mb-2 block">Available Providers</span>
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedAdminProduct.providers.map(provider => (
|
|
<span key={provider} className="px-3 py-1 bg-white border border-slate-200 text-slate-700 text-[10px] font-bold uppercase tracking-wider rounded-lg shadow-sm">
|
|
{provider}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Selling Price & Store Catalogue Action Area */}
|
|
<div className="bg-slate-50/90 border border-slate-200 rounded-2xl p-5 space-y-4 shadow-xs">
|
|
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
|
<div>
|
|
<h4 className="font-extrabold text-slate-800 text-sm flex items-center gap-2">
|
|
<Tag size={16} className="text-[#662582]" /> Store Selling Price (₹)
|
|
</h4>
|
|
<p className="text-[10px] font-semibold text-slate-500 mt-0.5">
|
|
{storeCat.has(selectedAdminProduct.id) ? 'Currently active in store catalogue' : 'Set selling price to publish in store catalogue'}
|
|
</p>
|
|
</div>
|
|
{storeCat.has(selectedAdminProduct.id) && (
|
|
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-800 text-[10px] font-extrabold uppercase border border-emerald-200 flex items-center gap-1">
|
|
<CheckCircle size={10} /> Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5" onClick={(e) => e.stopPropagation()}>
|
|
<label className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Selling Price (₹)</label>
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold pointer-events-none">₹</span>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={importPrice}
|
|
onChange={(e) => setImportPrice(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
placeholder="e.g. 50"
|
|
className="w-full pl-7 pr-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] bg-white transition-all shadow-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
disabled={!importPrice || Number(importPrice) <= 0}
|
|
onClick={() => {
|
|
// First-time publish gets qty 0 — it's a listing/pricing action, not a
|
|
// stock delivery; real quantity only lands via Mark as Received. If this
|
|
// product is already published, preserve its existing qty so a mere price
|
|
// edit here doesn't zero out stock that's since been received.
|
|
const existingQty = storeCat.items.find(i => i.productid === selectedAdminProduct.id)?.qty ?? 0;
|
|
storeCat.add({
|
|
productid: selectedAdminProduct.id,
|
|
name: selectedAdminProduct.name,
|
|
image: selectedAdminProduct.image,
|
|
category: selectedAdminProduct.category,
|
|
sku: selectedAdminProduct.sku,
|
|
price: Number(importPrice),
|
|
unit: selectedAdminProduct.exposure || 'Pc',
|
|
qty: existingQty,
|
|
status: 'Active'
|
|
});
|
|
}}
|
|
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
|
>
|
|
<CheckCircle size={16} strokeWidth={2.5} />
|
|
{storeCat.has(selectedAdminProduct.id) ? 'Save Price to Database' : 'Save Price & Publish to Store'}
|
|
</button>
|
|
|
|
{storeCat.has(selectedAdminProduct.id) && (
|
|
<button
|
|
onClick={() => storeCat.remove(selectedAdminProduct.id)}
|
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white text-rose-600 border border-slate-200 font-bold text-xs transition-colors hover:bg-slate-50 cursor-pointer mt-1"
|
|
>
|
|
<X size={14} strokeWidth={2} /> Remove from Store Catalogue
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2">
|
|
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
|
<FMCGHoverOverlay productId={selectedAdminProduct.id} category={selectedAdminProduct.category} productName={selectedAdminProduct.name} product={selectedAdminProduct} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</SlideDrawer>
|
|
|
|
{/* ── Slide Drawer for Stock Requests ── */}
|
|
<SlideDrawer
|
|
isOpen={selectedRequest !== null}
|
|
onClose={() => setSelectedRequest(null)}
|
|
title="Stock Request Details"
|
|
>
|
|
{selectedRequest && (() => {
|
|
const req = selectedRequest;
|
|
const isPending = req.pickData.status === 'Pending';
|
|
const isApproved = req.pickData.status === 'Approved';
|
|
const isRejected = req.pickData.status === 'Rejected';
|
|
const isCancelled = req.pickData.status === 'Cancelled';
|
|
const prod = req.product;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
{prod ? (
|
|
<div className="w-full h-64 bg-slate-50 rounded-2xl overflow-hidden border border-slate-100 relative">
|
|
<img src={prod.image} alt={prod.name} className="w-full h-full object-cover" />
|
|
<div className="absolute top-3 left-3 flex flex-col gap-2">
|
|
<span className="px-2.5 py-1.5 rounded-lg bg-white/90 text-slate-800 text-[10px] font-black uppercase shadow-sm tracking-wider">
|
|
{String(prod.category || '').split(' / ')[0]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-32 bg-slate-100 rounded-2xl flex items-center justify-center border border-slate-200">
|
|
<span className="text-slate-400 font-bold">Product Image Not Found</span>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{prod?.name || 'Unknown Product'}</h3>
|
|
<p className="text-xs font-bold text-slate-400 font-mono tracking-wider">{prod?.sku || `SKU-${req.productid}`}</p>
|
|
|
|
<div className="mt-4 flex flex-col gap-2 p-4 bg-slate-50 rounded-xl border border-slate-100">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400">Requesting Store</span>
|
|
<span className="text-sm font-bold text-slate-800">{req.locationname}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400">Requested Qty</span>
|
|
<span className="text-sm font-black font-mono text-[#662582]">{req.pickData.qty} Units</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400">Status</span>
|
|
<span className={`text-xs font-black uppercase tracking-wider ${isApproved ? 'text-emerald-600' : isRejected ? 'text-rose-600' : 'text-amber-600'}`}>
|
|
{req.pickData.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Area */}
|
|
<div className="space-y-3 pt-3 border-t border-slate-100">
|
|
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Process Request</h4>
|
|
|
|
{isPending ? (
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); setSelectedRequest(null); }}
|
|
className="flex-1 py-3 rounded-xl font-extrabold cursor-pointer transition-colors text-white flex items-center justify-center gap-2 shadow-sm"
|
|
style={{ background: '#10b981', border: '1px solid #059669' }}
|
|
>
|
|
<CheckCircle size={18} /> Approve Stock
|
|
</button>
|
|
<button
|
|
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); setSelectedRequest(null); }}
|
|
className="flex-1 py-3 rounded-xl font-extrabold cursor-pointer transition-colors text-rose-600 flex items-center justify-center gap-2 shadow-sm"
|
|
style={{ background: tint('#f43f5e'), border: `1px solid ${edge('#f43f5e')}` }}
|
|
>
|
|
<X size={18} /> Reject
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center gap-2 p-3 bg-slate-50 rounded-xl border border-slate-200 shadow-sm text-center">
|
|
<span className="text-sm font-bold text-slate-500">
|
|
This request has been {req.pickData.status.toLowerCase()}.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{prod && (
|
|
<div className="mt-2">
|
|
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
|
<FMCGHoverOverlay productId={prod.id} category={prod.category} productName={prod.name} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</SlideDrawer>
|
|
|
|
{/* Offline (counter) sales import. No locationId is passed: the admin gets
|
|
one workbook covering every branch, and each row's own locationid
|
|
routes its sale to the right store. This view operates on the tenant's
|
|
first outlet elsewhere, which would have been the wrong store to
|
|
credit for most of these sales. */}
|
|
{showOfflineSales && (
|
|
<OfflineSalesUpload
|
|
tenantId={tenantId}
|
|
onClose={() => setShowOfflineSales(false)}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|