1166 lines
63 KiB
TypeScript
1166 lines
63 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, usePriceEverywhere, isPublishedItem } from '../services/storeCatalogue';
|
|
import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
|
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) reads through this tenant's own
|
|
// primary/hub outlet — not the hardcoded Fiesta defaults, so onboarded
|
|
// tenants other than Fiesta see their own imports reflected here.
|
|
//
|
|
// READS are scoped here; PRICE WRITES are not. A price the admin sets is a
|
|
// business-level decision that has to reach every branch, so it goes out to
|
|
// `allLocationIds` via priceEverywhere() below. Writing only to this outlet
|
|
// left every other branch at ₹0.
|
|
const primaryLocationId = locations[0]?.locationid ?? FIESTA_PRIMARY_LOCATION_ID;
|
|
const allLocationIds = useMemo(
|
|
() => (locations.length ? locations.map((l) => l.locationid) : [primaryLocationId]),
|
|
[locations, primaryLocationId],
|
|
);
|
|
|
|
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);
|
|
/**
|
|
* productid → every photo, for the catalogue cards.
|
|
*
|
|
* Built once rather than searching storeCat.items inside each card's render:
|
|
* the grid draws hundreds of cards and a linear scan per card is a scan per
|
|
* card per keystroke in the search box.
|
|
*/
|
|
const imagesByProduct = useMemo(() => {
|
|
const m = new Map<string, string[]>();
|
|
storeCat.items.forEach((i) => {
|
|
if (i.images?.length) m.set(i.productid, i.images);
|
|
});
|
|
return m;
|
|
}, [storeCat.items]);
|
|
|
|
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
|
|
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">
|
|
{/* Every photo the product has. ProductMatrixItem
|
|
carries one `image` and is built by two mappers,
|
|
so the gallery is looked up by id — see
|
|
imagesByProduct. */}
|
|
<CardCarousel
|
|
images={imagesByProduct.get(prod.id) ?? [prod.image]}
|
|
alt={prod.name}
|
|
/>
|
|
<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>
|
|
<span className="text-[8px] font-bold text-slate-400 uppercase tracking-wider text-center -mt-1">
|
|
{allLocationIds.length === 1 ? 'Your store' : `All ${allLocationIds.length} stores`}
|
|
</span>
|
|
<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 || isPricing}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
// Publishing to the catalogue is a listing/pricing action, not a
|
|
// stock delivery — priceEverywhere() writes quantity 0 at every
|
|
// outlet. Real quantity only lands via the request → approve →
|
|
// Mark as Received flow.
|
|
priceEverywhere(prod.id, Number(cardImportPrice), allLocationIds, {
|
|
onSuccess: () => 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">
|
|
|
|
{/* Gallery. A product carries up to ten photos in the global
|
|
catalogue and only the first was ever stored, so most rows still
|
|
have one until the backfill runs — ProductGallery renders a
|
|
plain image in that case rather than empty chrome. */}
|
|
<ProductGallery
|
|
// Resolved from the catalogue row rather than from
|
|
// ProductMatrixItem, which carries a single `image` and is built
|
|
// by two different mappers — teaching both about a gallery to
|
|
// reach one drawer is more surface than this earns. Falls back to
|
|
// that single image, which is all a product has until the
|
|
// productimages backfill runs.
|
|
images={imagesByProduct.get(selectedAdminProduct.id) ?? [selectedAdminProduct.image]}
|
|
alt={selectedAdminProduct.name}
|
|
/>
|
|
|
|
{/* 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 || isPricing}
|
|
onClick={() => {
|
|
// Publishing is a listing/pricing action, never a stock delivery — real
|
|
// quantity only lands via Mark as Received. priceEverywhere() sends
|
|
// quantity 0, and because the upsert doesn't update quantity, an outlet
|
|
// that already holds stock keeps it while a new outlet starts at zero.
|
|
priceEverywhere(selectedAdminProduct.id, Number(importPrice), allLocationIds);
|
|
}}
|
|
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} />
|
|
{isPricing
|
|
? 'Saving…'
|
|
: storeCat.has(selectedAdminProduct.id)
|
|
? 'Save Price to All Stores'
|
|
: 'Save Price & Publish to All Stores'}
|
|
</button>
|
|
<p className="text-[10px] font-semibold text-slate-500 text-center -mt-1">
|
|
Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`}
|
|
</p>
|
|
|
|
{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>
|
|
);
|
|
}
|