import product catalogue
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"claude": "^0.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -2039,6 +2040,12 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/claude": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
||||
"integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"claude": "^0.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
147
setup-stock-trigger.js
Normal file
147
setup-stock-trigger.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env variables
|
||||
const envPath = path.resolve(process.cwd(), '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
const HASURA_ADMIN_SECRET = process.env.HASURA_ADMIN_SECRET || 'nearle-admin-secret';
|
||||
const HASURA_QUERY_URL = 'https://api.workolik.com/v2/query';
|
||||
const HASURA_METADATA_URL = 'https://api.workolik.com/v1/metadata';
|
||||
|
||||
let sourceName = 'default';
|
||||
|
||||
async function getSourceName() {
|
||||
const response = await fetch(HASURA_METADATA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-hasura-admin-secret': HASURA_ADMIN_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'export_metadata',
|
||||
args: {}
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(`Hasura Metadata Error: ${data.error}`);
|
||||
}
|
||||
|
||||
if (data.sources && data.sources.length > 0) {
|
||||
return data.sources[0].name;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
async function runSql(sqlQuery) {
|
||||
const response = await fetch(HASURA_QUERY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-hasura-admin-secret': HASURA_ADMIN_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: sourceName,
|
||||
sql: sqlQuery,
|
||||
cascade: false,
|
||||
check_metadata_consistency: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(`Hasura SQL Error: ${data.error}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('🔄 Connecting to Hasura Database...');
|
||||
sourceName = await getSourceName();
|
||||
console.log(`✅ Using database source: "${sourceName}"`);
|
||||
|
||||
// 1. Let's introspect the tables to ensure we have the correct table names before running the trigger.
|
||||
const targetTables = ['orders', 'orderdetails', 'productstocks'];
|
||||
const columnsCheckSql = `
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('orders', 'orderdetails', 'productstocks');
|
||||
`;
|
||||
|
||||
console.log('🔍 Introspecting columns for orders, orderdetails, productstocks...');
|
||||
const colData = await runSql(columnsCheckSql);
|
||||
const cols = colData.result.slice(1);
|
||||
|
||||
const getCols = (table) => cols.filter(r => r[0] === table).map(r => r[1]);
|
||||
const ordersCols = getCols('orders');
|
||||
const orderDetailsCols = getCols('orderdetails');
|
||||
const productStockCols = getCols('productstocks');
|
||||
|
||||
console.log(`✅ orders columns: ${ordersCols.join(', ')}`);
|
||||
console.log(`✅ orderdetails columns: ${orderDetailsCols.join(', ')}`);
|
||||
console.log(`✅ productstocks columns: ${productStockCols.join(', ')}`);
|
||||
|
||||
// Determine exact column names
|
||||
const orderPk = ordersCols.includes('orderheaderid') ? 'orderheaderid' : 'orderid';
|
||||
const detailOrderId = orderDetailsCols.includes('orderheaderid') ? 'orderheaderid' : 'orderid';
|
||||
const qtyCol = orderDetailsCols.includes('qty') ? 'qty' : 'orderqty';
|
||||
const stockCol = productStockCols.includes('physicalstock') ? 'physicalstock' : (productStockCols.includes('closing') ? 'closing' : 'stock');
|
||||
|
||||
// 2. Define the SQL for the Trigger Functions
|
||||
console.log('🛠️ Creating trigger functions...');
|
||||
|
||||
const createTriggerSql = `
|
||||
-- Function to reduce stock when a new order detail is inserted
|
||||
CREATE OR REPLACE FUNCTION update_stock_on_order_insert()
|
||||
RETURNS trigger AS $$
|
||||
DECLARE
|
||||
v_locationid INT;
|
||||
BEGIN
|
||||
-- Try to fetch locationid from orders
|
||||
BEGIN
|
||||
SELECT locationid INTO v_locationid FROM orders WHERE ${orderPk} = NEW.${detailOrderId};
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_locationid := NULL;
|
||||
END;
|
||||
|
||||
IF v_locationid IS NOT NULL THEN
|
||||
UPDATE productstocks
|
||||
SET ${stockCol} = GREATEST(0, COALESCE(${stockCol}, 0) - COALESCE(NEW.${qtyCol}, 1))
|
||||
WHERE productid = NEW.productid AND locationid = v_locationid;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger on orderdetails table
|
||||
DROP TRIGGER IF EXISTS trigger_reduce_stock_on_order ON orderdetails;
|
||||
CREATE TRIGGER trigger_reduce_stock_on_order
|
||||
AFTER INSERT ON orderdetails
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_stock_on_order_insert();
|
||||
`;
|
||||
|
||||
console.log('Deploying trigger...');
|
||||
await runSql(createTriggerSql);
|
||||
console.log('✅ Stock Reduction Trigger successfully deployed!');
|
||||
|
||||
console.log('🎉 Setup complete. The database will now automatically reduce physicalstock when an order is created.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error executing deployment script:');
|
||||
console.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
24
src/App.tsx
24
src/App.tsx
@@ -31,7 +31,8 @@ import {
|
||||
Store,
|
||||
Settings,
|
||||
LayoutDashboard,
|
||||
Users
|
||||
Users,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
|
||||
import { MainSection } from './types';
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
useFiestaCreateLocation,
|
||||
useFiestaOrderSummary,
|
||||
} from './services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from './services/fiestaApi';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr, num as fnum } from './services/fiestaApi';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
import DashboardView from './components/DashboardView';
|
||||
@@ -55,6 +56,7 @@ import LoginView from './components/LoginView';
|
||||
import UserStorePage from './components/UserStorePage';
|
||||
import AwaitingApi from './components/AwaitingApi';
|
||||
import ComparisonModal from './components/ComparisonModal';
|
||||
import CatalogueBrowser from './components/CatalogueBrowser';
|
||||
import type { AuthUser } from './services/auth';
|
||||
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
||||
|
||||
@@ -581,14 +583,18 @@ export default function App() {
|
||||
? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console')
|
||||
: currentSection === 'inventory'
|
||||
? 'Products'
|
||||
: currentSection === 'dispatch'
|
||||
: currentSection === 'catalogue'
|
||||
? 'Global Catalogue'
|
||||
: currentSection === 'dispatch'
|
||||
? 'Console'
|
||||
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
|
||||
icon: currentSection === 'dashboard'
|
||||
? LayoutDashboard
|
||||
: currentSection === 'inventory'
|
||||
? Layers
|
||||
: currentSection === 'stores'
|
||||
: currentSection === 'catalogue'
|
||||
? Box
|
||||
: currentSection === 'stores'
|
||||
? Store
|
||||
: currentSection === 'reports'
|
||||
? TrendingUp
|
||||
@@ -614,7 +620,7 @@ export default function App() {
|
||||
|
||||
{/* Main core pages payload area */}
|
||||
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-16'} ${currentSection === 'inventory' || currentSection === 'dispatch' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'}`}>
|
||||
<div className={`w-full transition-all duration-300 ${currentSection === 'dispatch' ? 'h-full overflow-hidden' : currentSection === 'inventory' ? 'h-full p-4 md:p-6 overflow-hidden' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
||||
<div className={`w-full transition-all duration-300 ${currentSection === 'dispatch' ? 'h-full overflow-hidden' : currentSection === 'inventory' ? 'h-full px-4 md:px-6 pt-2 pb-4 overflow-hidden' : currentSection === 'settings' ? 'p-4 md:p-6 space-y-lg' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={
|
||||
@@ -635,6 +641,14 @@ export default function App() {
|
||||
searchQuery={searchQuery}
|
||||
isCoimbatoreView={isCoimbatoreView}
|
||||
tenantId={tenantId}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
/>
|
||||
} />
|
||||
|
||||
<Route path="catalogue" element={
|
||||
<CatalogueBrowser
|
||||
tenantid={tenantId}
|
||||
locationid={FIESTA_PRIMARY_LOCATION_ID}
|
||||
/>
|
||||
} />
|
||||
|
||||
|
||||
201
src/components/CatalogueBrowser.tsx
Normal file
201
src/components/CatalogueBrowser.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, CheckCircle2, DownloadCloud, Box, PackageOpen } from 'lucide-react';
|
||||
import {
|
||||
useCatalogueProducts,
|
||||
useCatalogueBrands,
|
||||
useImportedCatalogueRefs,
|
||||
useImportCatalogueProduct
|
||||
} from '../hooks/useCatalogueImport';
|
||||
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
|
||||
import ImportProductModal from './ImportProductModal';
|
||||
|
||||
interface CatalogueBrowserProps {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
}
|
||||
|
||||
export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrowserProps) {
|
||||
const [brand, setBrand] = useState<string | undefined>(undefined);
|
||||
const [keyword, setKeyword] = useState<string>('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState<string>('');
|
||||
|
||||
const [importingProduct, setImportingProduct] = useState<CatalogueProduct | null>(null);
|
||||
|
||||
// Debounce search
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword);
|
||||
}, 400);
|
||||
return () => clearTimeout(handler);
|
||||
}, [keyword]);
|
||||
|
||||
const { data: catalogueData, isLoading: isLoadingProducts } = useCatalogueProducts(brand, debouncedKeyword);
|
||||
const products = catalogueData?.products ?? [];
|
||||
const total = catalogueData?.total ?? 0;
|
||||
|
||||
const { data: brandsData = [], isLoading: isLoadingBrands } = useCatalogueBrands();
|
||||
|
||||
const { data: importedRefs = new Set<string>() } = useImportedCatalogueRefs(tenantid, brand);
|
||||
|
||||
const importProductMutation = useImportCatalogueProduct(tenantid, locationid);
|
||||
|
||||
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
|
||||
importProductMutation.mutate([item], {
|
||||
onSuccess: () => {
|
||||
setImportingProduct(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
alert(err.message || 'Failed to import product.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative">
|
||||
|
||||
{/* Top Filters */}
|
||||
<div className="flex flex-col p-6 border-b border-slate-100 gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
|
||||
<Box className="text-purple-650" size={24} />
|
||||
Global Catalogue
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Browse {total > 0 ? total : ''} global FMCG products and import them to your store.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search catalogue products..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 focus:bg-white transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brands Chip Row */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Filter by Brand</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setBrand(undefined)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||
brand === undefined
|
||||
? 'bg-purple-650 text-white shadow-sm shadow-purple-650/20'
|
||||
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
All Brands
|
||||
</button>
|
||||
{isLoadingBrands ? (
|
||||
<span className="text-sm text-slate-400 py-1.5 px-2">Loading brands...</span>
|
||||
) : (
|
||||
brandsData.map(b => (
|
||||
<button
|
||||
key={b.brand}
|
||||
onClick={() => setBrand(b.brand)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
brand === b.brand
|
||||
? 'bg-purple-650 text-white shadow-sm shadow-purple-650/20'
|
||||
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{b.brand.replace('brand_', '').toUpperCase()}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${brand === b.brand ? 'bg-white/20' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{b.count}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 bg-slate-50/50">
|
||||
{isLoadingProducts ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-slate-400 gap-3">
|
||||
<div className="w-6 h-6 border-2 border-purple-650 border-t-transparent rounded-full animate-spin"></div>
|
||||
Loading catalogue...
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-60 text-slate-400 gap-4">
|
||||
<PackageOpen size={48} className="text-slate-300" />
|
||||
<p>No products found in the catalogue.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{products.map(p => {
|
||||
const isImported = importedRefs.has(`${p.brand}:${p.id}`);
|
||||
|
||||
return (
|
||||
<div key={`${p.brand}:${p.id}`} className="group relative bg-white border border-slate-200 rounded-xl overflow-hidden hover:shadow-lg hover:shadow-slate-200/50 hover:border-purple-200 transition-all flex flex-col">
|
||||
{isImported && (
|
||||
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 bg-emerald-500 text-white text-xs font-semibold px-2.5 py-1 rounded-full shadow-sm">
|
||||
<CheckCircle2 size={14} />
|
||||
Imported
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="aspect-square bg-slate-50 border-b border-slate-100 p-4 flex items-center justify-center relative overflow-hidden">
|
||||
{p.images && p.images.length > 0 ? (
|
||||
<img src={p.images[0]} alt={p.product_name} className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300" />
|
||||
) : (
|
||||
<Box className="w-16 h-16 text-slate-200" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">{p.brand.replace('brand_', '')}</div>
|
||||
<h3 className="font-semibold text-slate-800 text-sm leading-tight mb-2 line-clamp-2" title={p.product_name}>
|
||||
{p.product_name}
|
||||
</h3>
|
||||
|
||||
<div className="mt-auto space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500 font-medium bg-slate-100 px-2 py-0.5 rounded">{p.size || 'N/A'}</span>
|
||||
<span className="text-xs font-bold text-slate-700">{p.price_range || 'Price N/A'}</span>
|
||||
</div>
|
||||
|
||||
{!isImported && (
|
||||
<button
|
||||
onClick={() => setImportingProduct(p)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 bg-slate-50 hover:bg-purple-50 text-slate-600 hover:text-purple-700 border border-slate-200 hover:border-purple-200 rounded-lg text-sm font-medium transition-all group-hover:bg-purple-650 group-hover:text-white group-hover:border-purple-650 shadow-sm"
|
||||
>
|
||||
<DownloadCloud size={16} />
|
||||
Import to Store
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{importingProduct && (
|
||||
<ImportProductModal
|
||||
product={importingProduct}
|
||||
tenantid={tenantid}
|
||||
locationid={locationid}
|
||||
onClose={() => setImportingProduct(null)}
|
||||
onImport={handleImportSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importProductMutation.isPending && (
|
||||
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-purple-650 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div className="text-sm font-medium text-slate-700 shadow-sm bg-white px-4 py-2 rounded-full border border-slate-200">Importing product...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useFiestaCustomerOrders } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee } from 'lucide-react';
|
||||
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react';
|
||||
import OrderDetailsModal from './OrderDetailsModal';
|
||||
import './CustomerDetailPanel.css';
|
||||
|
||||
@@ -108,6 +108,17 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
|
||||
</div>
|
||||
|
||||
<div className="cdp-contact-inline">
|
||||
{fstr(customer.locationname) || fstr(customer.storename) ? (
|
||||
<div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}>
|
||||
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
|
||||
{fstr(customer.locationname) || fstr(customer.storename)}
|
||||
</div>
|
||||
) : fnum(customer.locationid) ? (
|
||||
<div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}>
|
||||
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
|
||||
Store {fnum(customer.locationid)}
|
||||
</div>
|
||||
) : null}
|
||||
{phone && (
|
||||
<a href={`tel:${phone}`} className="cdp-contact-chip">
|
||||
<span className="cdp-contact-icon-bg"><Phone size={12} /></span>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
|
||||
const monthlyRevenue = insight ? Number(insight.grossrevenue || insight.overallrevenue || insight.revenue || 0) : null;
|
||||
const monthlyProfit = insight ? Number(insight.profit || insight.netrevenue || insight.margin || 0) : null;
|
||||
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate);
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
||||
@@ -133,9 +133,9 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 relative">
|
||||
{/* ── Immersive Executive Banner (cover image + slate→purple gradient overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 overflow-hidden animate-in fade-in duration-300">
|
||||
<div className="relative p-6 md:p-8 text-white shadow-xl border border-purple-500/20 overflow-hidden animate-in fade-in duration-300">
|
||||
{/* Cover image background & decorative glow */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<div className="absolute inset-0 z-0 overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
|
||||
alt="Executive operations dashboard"
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
||||
</FilterBar>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 1240 }}>
|
||||
<thead>
|
||||
@@ -268,7 +268,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
|
||||
// in the view tree is transformed/blurred (otherwise the panel collapses).
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} />
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
||||
|
||||
@@ -121,7 +121,7 @@ const Cnt = ({ n, color }: { n: number; color: string }) => (
|
||||
function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenantId: number; locationid?: number; fromdate: string; todate: string; }) {
|
||||
const [metric, setMetric] = useState<'revenue' | 'orders'>('revenue');
|
||||
|
||||
const q = useFiestaLocationSummary(tenantId);
|
||||
const q = useFiestaLocationSummary(tenantId, fromdate, todate);
|
||||
const ordersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
||||
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate, todate });
|
||||
|
||||
@@ -151,7 +151,7 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
return `${d.getDate()} ${months[d.getMonth()]}`;
|
||||
};
|
||||
|
||||
const totalRevenue = revenueQ.data?.grossrevenue ?? 0;
|
||||
const totalRevenue = revenueQ.data?.overallrevenue ?? 0;
|
||||
const locationRevenueMap = useMemo(() => {
|
||||
const map = new Map<number, number>();
|
||||
for (const r of (ordersQ.data ?? [])) {
|
||||
|
||||
@@ -131,6 +131,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
setFocusedId(null);
|
||||
}, [viewMode]);
|
||||
|
||||
const [customerStoreFilter, setCustomerStoreFilter] = useState<string>('all');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||
const [animateNonce, setAnimateNonce] = useState(0);
|
||||
@@ -167,6 +168,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
|
||||
if (viewMode === 'stores' && locationsQ.data) {
|
||||
for (const loc of locationsQ.data) {
|
||||
if (locationid && fnum(loc.locationid) !== locationid) continue;
|
||||
const id = String(fnum(loc.locationid)).toLowerCase();
|
||||
const name = fstr(loc.locationname) || `Store ${id}`;
|
||||
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} });
|
||||
@@ -175,6 +177,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
|
||||
if (viewMode === 'customers' && customersQ.data) {
|
||||
for (const cust of customersQ.data) {
|
||||
if (customerStoreFilter !== 'all') {
|
||||
const locId = String(fnum(cust.locationid));
|
||||
if (locId !== customerStoreFilter) continue;
|
||||
}
|
||||
const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
|
||||
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`;
|
||||
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, raw: cust });
|
||||
@@ -221,6 +227,12 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name));
|
||||
}, [rows, viewMode, locationsQ.data, customersQ.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) {
|
||||
setFocusedId(groups[0].id);
|
||||
}
|
||||
}, [viewMode, locationid, groups, focusedId]);
|
||||
|
||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||
const groupedByRider = viewMode !== 'riders';
|
||||
|
||||
@@ -361,7 +373,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
groupedByRider={groupedByRider}
|
||||
tripSort={tripSort}
|
||||
setTripSort={setTripSort}
|
||||
onBack={() => setFocusedId(null)}
|
||||
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
|
||||
fmtTime={fmtTime}
|
||||
riderLogs={riderLogsQ.data}
|
||||
riderLogsLoading={riderLogsQ.isLoading}
|
||||
@@ -370,8 +382,31 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
<div className="ph">{viewMode === 'customers' ? 'No customers found' : 'No deliveries for this day'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ph">
|
||||
{viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})
|
||||
<div className="ph relative flex items-center justify-between w-full h-8">
|
||||
<span>{viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})</span>
|
||||
{viewMode === 'customers' && !locationid && locationsQ.data && (
|
||||
<div className="absolute right-0 flex items-center group/filter cursor-pointer">
|
||||
<select
|
||||
className="appearance-none w-36 text-[11px] font-extrabold tracking-wide rounded-md pl-3 pr-8 py-1.5 bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5 hover:bg-slate-50 outline-none focus:ring-2 focus:ring-[#662582]/30 transition-all duration-300 cursor-pointer relative z-0 truncate"
|
||||
style={{ textOverflow: 'ellipsis' }}
|
||||
value={customerStoreFilter}
|
||||
onChange={(e) => {
|
||||
setCustomerStoreFilter(e.target.value);
|
||||
setFocusedId(null);
|
||||
}}
|
||||
>
|
||||
<option value="all">All Stores</option>
|
||||
{locationsQ.data.map(loc => (
|
||||
<option key={fnum(loc.locationid)} value={String(fnum(loc.locationid))}>
|
||||
{fstr(loc.locationname) || `Store ${fnum(loc.locationid)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2 text-slate-400 pointer-events-none group-hover/filter:text-[#662582] transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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>
|
||||
{groups.map((g) => {
|
||||
const isSelected = focusedId === g.id;
|
||||
@@ -580,16 +615,18 @@ function FocusedDetail({
|
||||
groupedByRider: boolean;
|
||||
tripSort: 'planned' | 'time';
|
||||
setTripSort: (v: 'planned' | 'time') => void;
|
||||
onBack: () => void;
|
||||
onBack?: () => void;
|
||||
fmtTime: (raw: unknown) => string;
|
||||
riderLogs?: Row[];
|
||||
riderLogsLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
||||
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
||||
</button>
|
||||
{onBack && (
|
||||
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
||||
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
||||
</button>
|
||||
)}
|
||||
|
||||
{riderLogs && riderLogs.length > 0 && (
|
||||
<RiderTelemetryPanel
|
||||
@@ -599,6 +636,16 @@ function FocusedDetail({
|
||||
/>
|
||||
)}
|
||||
|
||||
{tripBlocks.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center h-48 border-2 border-dashed border-slate-200 rounded-xl mt-4 bg-slate-50/50">
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3">
|
||||
<Package size={20} />
|
||||
</div>
|
||||
<p className="font-bold text-slate-700 text-sm">No orders to display</p>
|
||||
<p className="text-xs text-slate-500 mt-1">There are no deliveries matching this selection for the current date.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tripBlocks.map((blk, bi) => (
|
||||
<div className="trip-block" key={bi}>
|
||||
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
|
||||
|
||||
@@ -6,9 +6,10 @@ interface Props {
|
||||
productId: string;
|
||||
category: string;
|
||||
productName: string;
|
||||
product?: any; // We'll pass the full product object here
|
||||
}
|
||||
|
||||
export default function FMCGHoverOverlay({ productId, category, productName }: Props) {
|
||||
export default function FMCGHoverOverlay({ productId, category, productName, product }: Props) {
|
||||
const details = generateFMCGDetails(productId, category);
|
||||
|
||||
return (
|
||||
@@ -32,21 +33,36 @@ export default function FMCGHoverOverlay({ productId, category, productName }: P
|
||||
|
||||
{/* 2. Ingredients & Legal (Back Panel) */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||
<Beaker size={10} /> Ingredients
|
||||
</h5>
|
||||
<p className="text-[10px] text-slate-700 leading-relaxed font-medium">{details.ingredients}</p>
|
||||
<div className="mt-1.5 inline-block bg-amber-50 border border-amber-200 px-2 py-0.5 rounded text-[9px] font-bold text-amber-800">
|
||||
{details.allergens}
|
||||
{product?.description && (
|
||||
<div>
|
||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||
<Info size={10} /> Description
|
||||
</h5>
|
||||
<p className="text-[10px] text-slate-700 leading-relaxed font-medium line-clamp-4">{product.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(product?.nutrients?.length > 0 || !product) && (
|
||||
<div>
|
||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||
<Beaker size={10} /> {product ? 'Nutritional Highlights' : 'Ingredients'}
|
||||
</h5>
|
||||
<p className="text-[10px] text-slate-700 leading-relaxed font-medium">
|
||||
{product?.nutrients?.length > 0 ? product.nutrients.join(' • ') : details.ingredients}
|
||||
</p>
|
||||
{!product && (
|
||||
<div className="mt-1.5 inline-block bg-amber-50 border border-amber-200 px-2 py-0.5 rounded text-[9px] font-bold text-amber-800">
|
||||
{details.allergens}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||
<ShieldCheck size={10} /> FSSAI / Storage
|
||||
</h5>
|
||||
<p className="text-[10px] text-slate-700 font-mono font-bold">Lic No. {details.fssai}</p>
|
||||
<p className="text-[10px] text-slate-700 font-mono font-bold">Lic No. {product?.fssaiLicense || details.fssai}</p>
|
||||
<p className="text-[9px] text-slate-500 mt-0.5">{details.storage}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Header({
|
||||
.toUpperCase() || 'NA';
|
||||
|
||||
return (
|
||||
<header className="bg-[#662582] border-b border-[#662582] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-[2000] h-16 text-white shadow-sm">
|
||||
<header className="bg-[#662582] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-16 text-white">
|
||||
{/* Brand & Desktop Navigation Tabs */}
|
||||
<div className="flex items-center gap-md md:pl-0 pl-1">
|
||||
{/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
|
||||
@@ -80,7 +80,7 @@ export default function Header({
|
||||
}`}
|
||||
>
|
||||
{/* Brand Logo — full wordmark when sidebar open, icon only when collapsed */}
|
||||
<span className="select-none flex items-center shrink-0">
|
||||
<span className="select-none flex items-center shrink-0 -ml-1.5">
|
||||
<img
|
||||
src={isSidebarOpen ? '/logo.png' : '/favicon.png'}
|
||||
alt="nearledaily logo"
|
||||
|
||||
190
src/components/ImportProductModal.tsx
Normal file
190
src/components/ImportProductModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Save, AlertCircle } from 'lucide-react';
|
||||
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
|
||||
import { useProductSubcategories } from '../hooks/useCatalogueImport';
|
||||
|
||||
interface ImportProductModalProps {
|
||||
product: CatalogueProduct;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
onClose: () => void;
|
||||
onImport: (item: ImportCatalogueProductRequest) => void;
|
||||
}
|
||||
|
||||
export default function ImportProductModal({
|
||||
product,
|
||||
tenantid,
|
||||
locationid,
|
||||
onClose,
|
||||
onImport
|
||||
}: ImportProductModalProps) {
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [subcategoryId, setSubcategoryId] = useState<string>('');
|
||||
const [retailPrice, setRetailPrice] = useState<string>('');
|
||||
const [productCost, setProductCost] = useState<string>('');
|
||||
const [taxPercent, setTaxPercent] = useState<string>('0');
|
||||
const [quantity, setQuantity] = useState<string>('1');
|
||||
|
||||
// Load subcategories for this tenant (optional: filter by categoryId if category picker is also dynamic)
|
||||
// The spec says "getproductsubcategories" with optional categoryid. We will fetch all for now and pick.
|
||||
const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories(tenantid);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!categoryId || !subcategoryId || !retailPrice || !productCost) {
|
||||
alert("Please fill in all required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
onImport({
|
||||
tenantid,
|
||||
locationid,
|
||||
brand: product.brand,
|
||||
catalogueid: product.id,
|
||||
categoryid: Number(categoryId),
|
||||
subcategoryid: Number(subcategoryId),
|
||||
quantity: Number(quantity),
|
||||
stocktype: "in",
|
||||
status: "Active",
|
||||
retailprice: Number(retailPrice),
|
||||
productcost: Number(productCost),
|
||||
taxpercent: Number(taxPercent),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-800">Import Product</h2>
|
||||
<p className="text-sm text-slate-500">{product.product_name} ({product.brand})</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-slate-400 hover:bg-slate-50 hover:text-slate-600 rounded-full transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar flex-1">
|
||||
<form id="import-form" onSubmit={handleSubmit} className="space-y-5">
|
||||
|
||||
<div className="bg-blue-50/50 rounded-lg p-3 border border-blue-100 flex gap-3 text-sm text-blue-700">
|
||||
<AlertCircle size={18} className="shrink-0 mt-0.5" />
|
||||
<p>You need to map this catalogue product to your store's categories and set your own pricing.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Category ID *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={categoryId}
|
||||
onChange={e => setCategoryId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors"
|
||||
placeholder="e.g. 1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Subcategory *</label>
|
||||
{isLoadingSubcats ? (
|
||||
<div className="w-full px-3 py-2 border border-slate-200 rounded-lg text-slate-400">Loading...</div>
|
||||
) : (
|
||||
<select
|
||||
value={subcategoryId}
|
||||
onChange={e => setSubcategoryId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Select subcategory...</option>
|
||||
{subcategories.map((s: any) => (
|
||||
<option key={s.subcategoryid || s.id} value={s.subcategoryid || s.id}>
|
||||
{s.subcategoryname || s.name} (Cat {s.categoryid || '?'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Retail Price *</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">₹</span>
|
||||
<input
|
||||
type="number" step="0.01"
|
||||
value={retailPrice} onChange={e => setRetailPrice(e.target.value)}
|
||||
className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-colors"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Cost Price *</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">₹</span>
|
||||
<input
|
||||
type="number" step="0.01"
|
||||
value={productCost} onChange={e => setProductCost(e.target.value)}
|
||||
className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-colors"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Tax Percent</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number" step="0.1"
|
||||
value={taxPercent} onChange={e => setTaxPercent(e.target.value)}
|
||||
className="w-full pr-8 pl-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Initial Quantity</label>
|
||||
<input
|
||||
type="number" min="1"
|
||||
value={quantity} onChange={e => setQuantity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="import-form"
|
||||
className="flex items-center gap-2 px-5 py-2 bg-purple-650 hover:bg-purple-750 text-white text-sm font-medium rounded-lg shadow-sm shadow-purple-650/20 transition-all hover:-translate-y-0.5 active:translate-y-0"
|
||||
>
|
||||
<Save size={16} />
|
||||
Import Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
|
||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider } from '../services/fiestaQueries';
|
||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaUsers } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import { shortTime } from '../services/fiestaMappers';
|
||||
import {
|
||||
@@ -105,6 +105,8 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
setShowSelected(false);
|
||||
}, [fromdate, todate, status, branch, pageno, locationid]);
|
||||
|
||||
const [riderSource, setRiderSource] = useState<'own' | 'partner'>('own');
|
||||
|
||||
// Scope to the user's store when a locationid is supplied (server-side per the
|
||||
// backend's getordersummary/getorders locationid param); tenant-wide otherwise.
|
||||
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate, locationid);
|
||||
@@ -118,20 +120,45 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
// simply won't appear — the intended guard.
|
||||
const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]);
|
||||
const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]);
|
||||
|
||||
const ridersQ = useFiestaRiders({
|
||||
tenantid: tenantId,
|
||||
applocationid: orderApplocationId || undefined,
|
||||
partnerid: orderPartnerId || undefined,
|
||||
// We omit partnerid here to fetch all partner riders for the location at once.
|
||||
});
|
||||
|
||||
const internalRidersQ = useFiestaUsers({
|
||||
tenantid: tenantId,
|
||||
roleid: 5, // 5 = Rider role
|
||||
pagesize: 500
|
||||
});
|
||||
|
||||
const riderOptions = useMemo(
|
||||
() =>
|
||||
(ridersQ.data ?? [])
|
||||
() => {
|
||||
const externalRiders = ridersQ.data ?? [];
|
||||
const internalRiders = internalRidersQ.data ?? [];
|
||||
const allRiders = [...externalRiders, ...internalRiders];
|
||||
|
||||
const filtered = allRiders.filter((r) => {
|
||||
const pId = fnum(r.partnerid);
|
||||
if (riderSource === 'own') {
|
||||
// Store fleet riders are internal users (they have no partner id)
|
||||
return !pId || pId === 0;
|
||||
} else {
|
||||
// Partner riders belong to a 3rd party (partnerid > 0)
|
||||
// If the order already has a specific partnerid, we only show riders from that partner.
|
||||
return pId > 0 && (!orderPartnerId || pId === orderPartnerId);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered
|
||||
.map((r) => ({
|
||||
id: fnum(r.userid),
|
||||
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
||||
}))
|
||||
.filter((o) => o.id > 0 && o.label),
|
||||
[ridersQ.data],
|
||||
.filter((o) => o.id > 0 && o.label);
|
||||
},
|
||||
[ridersQ.data, internalRidersQ.data, riderSource, orderPartnerId],
|
||||
);
|
||||
|
||||
// Branches (app-locations) present in the data — drives the branch filter so the
|
||||
@@ -342,6 +369,22 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
|
||||
<UserCheck size={15} /> {selected.size} selected
|
||||
</span>
|
||||
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
|
||||
<button
|
||||
onClick={() => setRiderSource('own')}
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'own' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'own' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
>
|
||||
Store Fleet
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRiderSource('partner')}
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
>
|
||||
Partners
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={assignRiderId}
|
||||
onChange={(e) => setAssignRiderId(Number(e.target.value))}
|
||||
@@ -350,7 +393,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
|
||||
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
|
||||
>
|
||||
<option value={0}>{ridersQ.isLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
|
||||
<option value={0}>{ridersQ.isLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : `No ${riderSource === 'own' ? 'store' : 'partner'} riders available`}</option>
|
||||
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<button
|
||||
@@ -371,7 +414,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 960 }}>
|
||||
<thead>
|
||||
@@ -554,7 +597,7 @@ function SelectedOrdersPage({
|
||||
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 720 }}>
|
||||
<thead><tr>{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>)}</tr></thead>
|
||||
@@ -632,7 +675,7 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
return coimbatoreZones.some(zone => name.toLowerCase().includes(zone));
|
||||
};
|
||||
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId, ymd(yearStart), todate);
|
||||
|
||||
const regionLocations = useMemo(() => {
|
||||
const rawLocations = [...(locSummaryQ.data ?? [])];
|
||||
@@ -639,7 +639,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
|
||||
|
||||
{/* Revenue Heatmap table - 8 Cols */}
|
||||
<div className="lg:col-span-8 bg-white border border-slate-200/70 rounded-2xl overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||
<div className="lg:col-span-8 bg-white border border-slate-200/70 overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||
|
||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
|
||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||
@@ -716,7 +716,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Leaderboard nodes bar list - 4 Cols */}
|
||||
<div className="lg:col-span-4 bg-white border border-slate-200/70 rounded-2xl flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||
<div className="lg:col-span-4 bg-white border border-slate-200/70 flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 select-none">
|
||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||
Top Performing Nodes
|
||||
@@ -888,7 +888,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Detailed Performance Matrix table */}
|
||||
<div className="bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-2xl overflow-hidden shadow-sm relative z-10">
|
||||
<div className="bg-white/70 backdrop-blur-md border border-[#e2e8f0] overflow-hidden shadow-sm relative z-10">
|
||||
|
||||
|
||||
{/* Table header with filters control */}
|
||||
|
||||
@@ -255,29 +255,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
}, [rolesQ.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 font-sans text-slate-700">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div>
|
||||
{tenantsQ.isLoading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-350 animate-pulse" /> Loading store profile…
|
||||
</span>
|
||||
) : tenant ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-650 uppercase tracking-wider">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" /> Active · {fstr(tenant.tenantname)} · Store #{tenantId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-rose-600 uppercase tracking-wider">
|
||||
<span className="w-2 h-2 rounded-full bg-rose-500" /> Store details unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-sans text-slate-700">
|
||||
{/* Header Removed */}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
|
||||
{/* Tab rail & Merchant Card */}
|
||||
<div className="lg:col-span-1 space-y-md bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl shadow-sm">
|
||||
<div className="lg:col-span-1 space-y-md bg-slate-50/50 border border-slate-200/60 p-4 shadow-sm">
|
||||
{/* Merchant ID Card */}
|
||||
<div className="bg-gradient-to-br from-slate-900 via-slate-950 to-purple-955 border border-purple-500/20 p-5 rounded-2xl text-white shadow-md relative overflow-hidden select-none">
|
||||
{/* Background design accents */}
|
||||
@@ -329,7 +312,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
{/* Panel */}
|
||||
<div className="lg:col-span-3 space-y-gutter text-sm pb-24">
|
||||
{activeTab === 'profile' && (
|
||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||
<div className="bg-white border border-slate-200/60 p-6 shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Store Profile</span>
|
||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Identity & Contacts</h2>
|
||||
@@ -427,7 +410,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
)}
|
||||
|
||||
{activeTab === 'outlets' && (
|
||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-md animate-in fade-in duration-200">
|
||||
<div className="bg-white border border-slate-200/60 p-6 shadow-sm space-y-md animate-in fade-in duration-200">
|
||||
<div className="flex justify-between items-center pb-4 border-b border-slate-100">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
TrendingUp,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
Truck
|
||||
Truck,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { MainSection } from '../types';
|
||||
@@ -37,6 +38,7 @@ export default function Sidebar({
|
||||
const navItems = [
|
||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
||||
{ id: 'catalogue' as MainSection, label: 'Global Catalogue', icon: Box },
|
||||
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
|
||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||
@@ -53,7 +55,7 @@ export default function Sidebar({
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||
className={`fixed left-0 top-0 h-screen bg-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
|
||||
}`}
|
||||
>
|
||||
@@ -67,11 +69,11 @@ export default function Sidebar({
|
||||
to={`/admin/${item.id}`}
|
||||
title={item.label}
|
||||
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
||||
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
||||
}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
|
||||
@@ -23,7 +23,6 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
|
||||
import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
|
||||
import { categoryName } from '../services/fiestaMappers';
|
||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||
@@ -35,6 +34,7 @@ interface StoreCatalogViewProps {
|
||||
locationid?: number;
|
||||
storeName?: string;
|
||||
tenantId?: number;
|
||||
isSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
function stockStatus(closing: number): { label: string; color: string } {
|
||||
@@ -53,9 +53,10 @@ function catBadgeClass(category: string): string {
|
||||
return 'bg-rose-50 text-rose-600 border border-rose-100';
|
||||
}
|
||||
|
||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: StoreCatalogViewProps) {
|
||||
const tenantid = tenantId;
|
||||
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
||||
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||
@@ -70,16 +71,18 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
const storeCat = useStoreCatalogue();
|
||||
const products = useMemo(
|
||||
() =>
|
||||
storeCat.items.map((it) => ({
|
||||
id: it.productid,
|
||||
name: it.name,
|
||||
sku: it.sku || `SKU-${it.productid}`,
|
||||
image: it.image || PLACEHOLDER,
|
||||
category: it.category || 'General',
|
||||
price: it.price,
|
||||
unit: it.unit,
|
||||
adminQty: it.qty,
|
||||
})),
|
||||
storeCat.items
|
||||
.filter((it) => it.status === 'Active')
|
||||
.map((it) => ({
|
||||
id: it.productid,
|
||||
name: it.name,
|
||||
sku: it.sku || `SKU-${it.productid}`,
|
||||
image: it.image || PLACEHOLDER,
|
||||
category: it.category || 'General',
|
||||
price: it.price,
|
||||
unit: it.unit,
|
||||
adminQty: it.qty,
|
||||
})),
|
||||
[storeCat.items],
|
||||
);
|
||||
|
||||
@@ -160,6 +163,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
});
|
||||
return set;
|
||||
}, [stockQ.data, picks]);
|
||||
|
||||
const inventory = useMemo(
|
||||
() => {
|
||||
const baseInventory = (stockQ.data ?? []).map((r: Row) => {
|
||||
@@ -169,7 +173,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
name: fstr(r.productname) || 'Unnamed product',
|
||||
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
|
||||
image: fstr(r.productimage) || PLACEHOLDER,
|
||||
category: categoryName(fnum(r.categoryid)),
|
||||
category: fstr(r.categoryname) || 'General',
|
||||
closing,
|
||||
...stockStatus(closing),
|
||||
price: Math.floor(Math.random() * 50) + 10, // mock price
|
||||
@@ -252,77 +256,80 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<div className="animate-in fade-in duration-300 font-sans pb-28">
|
||||
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||
<button
|
||||
onClick={() => setView('catalogue')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Boxes size={14} /> Browse Catalogue ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('inventory')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('requests')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Inbox size={14} /> My Requests ({Object.keys(picks).length})
|
||||
</button>
|
||||
{/* Tabs and Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
|
||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||
<button
|
||||
onClick={() => setView('catalogue')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Boxes size={14} /> Browse Catalogue ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('inventory')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('requests')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Inbox size={14} /> My Requests ({Object.keys(picks).length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === 'requests' && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
|
||||
<input
|
||||
type="date"
|
||||
value={requestDate}
|
||||
onChange={(e) => setRequestDate(e.target.value)}
|
||||
className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start mt-4">
|
||||
<div className="flex flex-col md:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
|
||||
{/* Sticky Sidebar Filter */}
|
||||
{view !== 'requests' && (
|
||||
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Search size={14} className="text-[#662582]" /> Search
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Product name or SKU..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-3 pr-8 py-2.5 border border-[#e2e8f0] rounded-xl text-xs font-semibold outline-none bg-slate-50 focus:bg-white focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all shadow-sm"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{view !== 'requests' && isLocalSidebarOpen && (
|
||||
<div className="w-full xl:w-64 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-slate-800 flex items-center gap-2">
|
||||
<Layers size={16} className="text-[#662582]" /> Filter Product
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
|
||||
|
||||
<div className="w-full h-px bg-slate-100" />
|
||||
|
||||
<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-2">
|
||||
<div className="space-y-1.5">
|
||||
{categories.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(c)}
|
||||
onChange={() => toggleCategory(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedCategories.includes(c) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
|
||||
{selectedCategories.includes(c) && <Check size={10} className="text-white" strokeWidth={3} />}
|
||||
<label key={c} 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(c)}
|
||||
onChange={() => toggleCategory(c)}
|
||||
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 text-slate-700 group-hover:text-slate-900">{c}</span>
|
||||
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(c) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
||||
{c}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -335,19 +342,21 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Activity size={14} className="text-[#662582]" /> Stock Health
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
||||
<label key={h} className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stockHealthFilter.includes(h)}
|
||||
onChange={() => toggleStockHealth(h)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${stockHealthFilter.includes(h) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
|
||||
{stockHealthFilter.includes(h) && <Check size={10} className="text-white" strokeWidth={3} />}
|
||||
<label key={h} 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={stockHealthFilter.includes(h)}
|
||||
onChange={() => toggleStockHealth(h)}
|
||||
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 text-slate-700 group-hover:text-slate-900">{h}</span>
|
||||
<span className={`text-xs font-semibold select-none transition-colors ${stockHealthFilter.includes(h) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
||||
{h}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -363,32 +372,17 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Grid Area */}
|
||||
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
||||
{/* Mobile Search Input (Visible only on mobile) */}
|
||||
<div className="md:hidden mb-4">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-3 border border-[#e2e8f0] rounded-xl text-sm font-semibold outline-none bg-white focus:ring-2 focus:ring-purple-500/20 shadow-sm"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
|
||||
|
||||
{/* Results Summary & Toolbar */}
|
||||
<div className="flex justify-between items-end mb-4 px-1">
|
||||
<div className="flex justify-between items-end mb-4 px-1 gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
{view === 'catalogue' ? 'Store Catalogue' : view === 'inventory' ? 'My Inventory' : 'My Requests'}
|
||||
@@ -399,7 +393,31 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Can add sorting dropdown here if needed */}
|
||||
{/* Action Toolbar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
|
||||
className="flex items-center gap-2 bg-white border border-slate-200 px-3 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[36px]"
|
||||
>
|
||||
<Layers size={14} /> Filter
|
||||
</button>
|
||||
|
||||
<div className="relative hidden md:block md:w-64 h-[36px]">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer border-none bg-transparent">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Browse Catalogue ── */}
|
||||
@@ -417,7 +435,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4 pb-8">
|
||||
<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 pb-8`}>
|
||||
{filtered.map((p) => {
|
||||
const stocked = inStore.has(p.id);
|
||||
const pick = picks[p.id];
|
||||
@@ -516,19 +534,6 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
{/* ── My Requests ── */}
|
||||
{view === 'requests' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end bg-white px-4 py-3 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="requestDate" className="text-sm font-bold text-slate-500">Date:</label>
|
||||
<input
|
||||
type="date"
|
||||
id="requestDate"
|
||||
value={requestDate}
|
||||
onChange={(e) => setRequestDate(e.target.value)}
|
||||
className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm font-medium text-slate-700 bg-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!stockRequestsQ.data || stockRequestsQ.data.length === 0) ? (
|
||||
<CenterState
|
||||
icon={<Inbox size={34} />}
|
||||
@@ -655,13 +660,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
title="No stock matches your filters"
|
||||
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
|
||||
action={
|
||||
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
|
||||
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-[#531e6a] transition shadow-sm cursor-pointer">
|
||||
<X size={13} /> Clear filters
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4">
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4 pb-8`}>
|
||||
{finalFilteredInventory.map((it, i) => (
|
||||
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl 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">
|
||||
{/* Image Bento Block */}
|
||||
@@ -944,14 +949,14 @@ function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; titl
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gradient-to-b from-white to-[#faf9ff] border border-[#eceef2] rounded-3xl px-6 py-16 sm:py-20 text-center shadow-sm">
|
||||
{/* Soft decorative glows */}
|
||||
<div className="pointer-events-none absolute -top-20 -right-20 w-60 h-60 rounded-full bg-purple-200/30 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-20 w-60 h-60 rounded-full bg-indigo-200/30 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -top-20 -right-20 w-60 h-60 rounded-full bg-[#662582]/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-20 w-60 h-60 rounded-full bg-[#662582]/10 blur-3xl" />
|
||||
|
||||
<div className="relative flex flex-col items-center">
|
||||
{/* Icon with halo */}
|
||||
<div className="relative mb-5">
|
||||
<span className="absolute inset-0 -m-3 rounded-full bg-purple-300/25 blur-xl" />
|
||||
<span className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-[#662582] to-indigo-500 text-white shadow-lg shadow-purple-500/20 ring-8 ring-white">
|
||||
<span className="absolute inset-0 -m-3 rounded-full bg-[#662582]/15 blur-xl" />
|
||||
<span className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-[#662582] text-white shadow-lg shadow-[#662582]/20 ring-8 ring-white">
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
|
||||
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
||||
{showHero && (
|
||||
<div className="relative overflow-hidden rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
||||
<div className="relative overflow-hidden p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
||||
{/* Cover Image Background */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function StoreQRView({
|
||||
if (!locationid) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto animate-in fade-in duration-300">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center shadow-[0_10px_40px_rgba(0,0,0,0.06)]">
|
||||
<div className="bg-white border border-slate-200/70 p-10 text-center shadow-[0_10px_40px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@ export default function StoreQRView({
|
||||
<div className="mx-auto flex flex-col items-center" style={{ width: '100%', maxWidth: '380px', minWidth: '280px' }}>
|
||||
|
||||
{/* Premium Table Tent Mockup Card */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200/70 shadow-[0_20px_50px_rgba(88,28,135,0.08)] overflow-hidden relative group transition-all duration-355 hover:shadow-[0_25px_60px_rgba(88,28,135,0.14)]" style={{ width: '100%' }}>
|
||||
<div className="bg-white border border-slate-200/70 shadow-[0_20px_50px_rgba(88,28,135,0.08)] overflow-hidden relative group transition-all duration-355 hover:shadow-[0_25px_60px_rgba(88,28,135,0.14)]" style={{ width: '100%' }}>
|
||||
|
||||
{/* Ambient Background Glow inside card */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/5 rounded-full blur-xl -mr-6 -mt-6 pointer-events-none group-hover:bg-purple-500/10 transition-colors" />
|
||||
|
||||
@@ -24,10 +24,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaLocationSummary,
|
||||
useFiestaOrderSummary,
|
||||
FIESTA_TENANT_ID,
|
||||
} from '../services/fiestaQueries';
|
||||
import { str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import { str as fstr, num as fnum, roleName, ymd } from '../services/fiestaApi';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import Header from './Header';
|
||||
import StoreDetailView from './StoreDetailView';
|
||||
@@ -51,7 +51,7 @@ const NAV_ITEMS: UserNavItem[] = [
|
||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||
{ id: 'inventory', label: 'Products', icon: Layers },
|
||||
{ id: 'customers', label: 'Customers', icon: Users },
|
||||
{ id: 'dispatch', label: 'Dispatch', icon: RouteIcon },
|
||||
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
|
||||
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
||||
];
|
||||
|
||||
@@ -77,11 +77,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
// only a fallback for legacy sessions whose record predates tenantid capture.
|
||||
const tenantId = user.tenantid || FIESTA_TENANT_ID;
|
||||
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||
const todayStr = ymd(new Date());
|
||||
const locationsQ = useFiestaTenantLocations(tenantId, user.userid);
|
||||
|
||||
const locations = locationsQ.data ?? [];
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
// Resolve the user's store. Most tenants have exactly ONE store, so when the
|
||||
// tenant has a single location we just use it — no id matching needed. Only if
|
||||
@@ -89,13 +88,24 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
// (accepting a row whose locationid equals it too), then by locationid.
|
||||
const apploc = user.applocationid;
|
||||
const locid = user.locationid;
|
||||
const userEmail = user.email?.toLowerCase() || '';
|
||||
|
||||
const matchedLoc =
|
||||
locations.length === 1
|
||||
? locations[0]
|
||||
: (locations.find((l) => apploc != null && apploc > 0 && fnum(l.applocationid) === apploc) ??
|
||||
: (locations.find((l) => locid != null && locid > 0 && fnum(l.locationid) === locid) ??
|
||||
locations.find((l) => {
|
||||
// Backend bug fallback: when locationid is 0, we disambiguate using the email prefix
|
||||
// e.g. 'abhishek.peelamedu@suriya.com' matches store 'peelamedu@suriya.com'
|
||||
const storeEmail = fstr(l.email).toLowerCase();
|
||||
if (storeEmail && storeEmail.includes('@')) {
|
||||
const prefix = storeEmail.split('@')[0];
|
||||
if (prefix.length > 2 && userEmail.includes(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}) ??
|
||||
locations.find((l) => apploc != null && apploc > 0 && fnum(l.locationid) === apploc) ??
|
||||
locations.find((l) => locid != null && locid > 0 && fnum(l.locationid) === locid) ??
|
||||
locations.find((l) => locid != null && locid > 0 && fnum(l.applocationid) === locid) ??
|
||||
locations.find((l) => apploc != null && apploc > 0 && fnum(l.applocationid) === apploc) ??
|
||||
null);
|
||||
|
||||
// Resolve the locationid the store console queries by. Prefer the matched
|
||||
@@ -104,6 +114,9 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
const resolvedLocationId =
|
||||
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
||||
|
||||
const orderSummaryQ = useFiestaOrderSummary(tenantId, todayStr, todayStr, resolvedLocationId || undefined);
|
||||
const sum = orderSummaryQ.data;
|
||||
|
||||
const storeName =
|
||||
(matchedLoc && fstr(matchedLoc.locationname)) ||
|
||||
user.applocation ||
|
||||
@@ -131,7 +144,6 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
// Build the store shape StoreDetailView expects, mirroring the App registry
|
||||
// mapping so the console renders identically to the admin's store view.
|
||||
const buildStore = (): StoreShape => {
|
||||
const sum = summaries.find((s) => s.locationid === resolvedLocationId);
|
||||
const status = matchedLoc ? fstr(matchedLoc.status) || 'Active' : 'Active';
|
||||
return {
|
||||
locationid: resolvedLocationId,
|
||||
@@ -154,7 +166,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] mb-1">My Account</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mb-6">Your profile and the store you’re assigned to.</p>
|
||||
|
||||
<div className="bg-white border border-slate-200/70 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="bg-white border border-slate-200/70 shadow-sm overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-[#662582] via-purple-800 to-purple-950 p-6 text-white flex items-center gap-4">
|
||||
<span className="w-14 h-14 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-lg font-bold tracking-wide">
|
||||
{initials}
|
||||
@@ -200,10 +212,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
|
||||
// Distinguish a real load failure from "no store assigned" — otherwise an
|
||||
// API outage would wrongly tell the user to contact their admin.
|
||||
if (locationsQ.isError || locSummaryQ.isError) {
|
||||
if (locationsQ.isError || orderSummaryQ.isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="bg-white border border-slate-200/70 p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
@@ -214,7 +226,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
<button
|
||||
onClick={() => {
|
||||
locationsQ.refetch();
|
||||
locSummaryQ.refetch();
|
||||
orderSummaryQ.refetch();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
|
||||
>
|
||||
@@ -228,7 +240,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
if (!resolvedLocationId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="bg-white border border-slate-200/70 p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
@@ -250,10 +262,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||
// gating below — only "My Store Inventory" uses the resolved location id).
|
||||
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />;
|
||||
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} isSidebarOpen={sidebarOpen} />;
|
||||
|
||||
// The store console needs a resolved store, so gate it on the load state.
|
||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||
if (locationsQ.isLoading || orderSummaryQ.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
||||
<div className="w-7 h-7 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
|
||||
@@ -293,6 +305,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
? 'POS Terminal'
|
||||
: activeSection === 'account'
|
||||
? 'My Account'
|
||||
: activeSection === 'dispatch'
|
||||
? 'Console'
|
||||
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
|
||||
icon: activeSection === 'console'
|
||||
? LayoutDashboard
|
||||
@@ -324,7 +338,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
>
|
||||
{isInactive && (
|
||||
<div className="absolute inset-0 z-50 bg-slate-100/60 backdrop-blur-[2px] flex items-center justify-center">
|
||||
<div className="bg-white p-8 rounded-xl shadow-xl text-center w-full min-w-[320px] max-w-sm mx-4 border border-red-100 pointer-events-auto">
|
||||
<div className="bg-white p-8 shadow-xl text-center w-full min-w-[320px] max-w-sm mx-4 border border-red-100 pointer-events-auto">
|
||||
<div className="w-16 h-16 bg-red-100 text-red-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Store size={32} />
|
||||
</div>
|
||||
|
||||
@@ -52,11 +52,11 @@ export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSi
|
||||
to={`/store/${item.id}`}
|
||||
title={item.label}
|
||||
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
||||
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
||||
}`}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
|
||||
@@ -452,7 +452,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowAddUserModal(false); }}
|
||||
>
|
||||
<div className="bg-white border border-slate-200/80 rounded-2xl w-full max-w-[30rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
<div className="bg-white border border-slate-200/80 w-full max-w-[30rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
|
||||
@@ -663,7 +663,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }}
|
||||
>
|
||||
<div className="bg-white border border-slate-200/80 rounded-2xl w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
<div className="bg-white border border-slate-200/80 w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
|
||||
|
||||
@@ -396,11 +396,11 @@ export function SlideDrawer({
|
||||
{/* Pure, Clean Drawer Panel */}
|
||||
<div className="absolute right-0 top-0 bottom-0 z-50 w-[520px] max-w-[100vw] bg-white shadow-[-10px_0_40px_rgba(0,0,0,0.08)] flex flex-col animate-in slide-in-from-right duration-300 ease-out">
|
||||
{/* Minimalist Header */}
|
||||
<div className="flex items-center justify-between px-7 py-5 border-b border-slate-100 z-10 shrink-0">
|
||||
<h2 className="text-lg font-bold text-slate-800 tracking-tight">{title}</h2>
|
||||
<div className="flex items-center justify-between px-7 py-5 bg-[#662582] border-b border-purple-900 z-10 shrink-0">
|
||||
<h2 className="text-lg font-bold text-white tracking-tight">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
65
src/hooks/useCatalogueImport.ts
Normal file
65
src/hooks/useCatalogueImport.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getCatalogueProducts,
|
||||
getImportedCatalogueRefs,
|
||||
importCatalogueProducts,
|
||||
getBrands,
|
||||
getProductSubcategories,
|
||||
removeFromStoreCatalogue,
|
||||
ImportCatalogueProductRequest
|
||||
} from '../services/catalogueApi';
|
||||
|
||||
export function useCatalogueProducts(brand?: string, keyword?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['catalogue', 'products', brand ?? 'all', keyword ?? ''],
|
||||
queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 100 }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['catalogue', 'imported', tenantid, brand ?? 'all'],
|
||||
queryFn: () => getImportedCatalogueRefs(tenantid, brand),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCatalogueBrands() {
|
||||
return useQuery({
|
||||
queryKey: ['catalogue', 'brands'],
|
||||
queryFn: () => getBrands(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductSubcategories(tenantid: number, categoryid?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['catalogue', 'subcategories', tenantid, categoryid ?? 'all'],
|
||||
queryFn: () => getProductSubcategories(tenantid, categoryid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportCatalogueProduct(tenantid: number, locationid: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (items: ImportCatalogueProductRequest[]) => importCatalogueProducts(items),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogue', 'imported', tenantid] });
|
||||
// Invalidate store catalogue (adjust queryKey if it differs from what fiestaQueries uses for live store stock)
|
||||
queryClient.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fiesta', 'productStocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveFromStoreCatalogue(tenantid: number, locationid: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (productid: number) => removeFromStoreCatalogue(tenantid, locationid, productid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogue', 'imported', tenantid] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fiesta', 'productStocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -82,3 +82,25 @@
|
||||
/* orange */
|
||||
--color-orange-850: color-mix(in oklab, #9a3412, #7c2d12);
|
||||
}
|
||||
|
||||
/* Custom scrollbar utility */
|
||||
@layer utilities {
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-slate-350, #cbd5e1);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-slate-450, #94a3b8);
|
||||
}
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-slate-350, #cbd5e1) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
117
src/services/catalogueApi.ts
Normal file
117
src/services/catalogueApi.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_FIESTA_URL || "https://fiesta.nearle.app/live/api/v1";
|
||||
|
||||
export interface CatalogueProduct {
|
||||
id: number;
|
||||
brand: string;
|
||||
product_name: string;
|
||||
category?: string;
|
||||
images?: string[];
|
||||
size?: string;
|
||||
product_sku?: string;
|
||||
price_range?: string; // display only — never an exact price
|
||||
}
|
||||
|
||||
export interface ImportedRef {
|
||||
brand: string;
|
||||
catalogueid: number;
|
||||
}
|
||||
|
||||
export interface ImportCatalogueProductRequest {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
brand: string; // bridge key part 1
|
||||
catalogueid: number; // bridge key part 2 — the catalogue row's `id`
|
||||
categoryid: number; // this tenant's own category
|
||||
subcategoryid: number; // this tenant's own subcategory
|
||||
quantity: number;
|
||||
stocktype: "in" | "out";
|
||||
status: string;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
taxpercent: number;
|
||||
}
|
||||
|
||||
async function apiGet<T>(url: URL): Promise<T[]> {
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Catalogue API failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (Array.isArray(json)) return json;
|
||||
if (json && typeof json === 'object' && 'details' in json) {
|
||||
return json.details || [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// brand omitted → the entire catalogue, all brands merged.
|
||||
export async function getCatalogueProducts(opts: {
|
||||
brand?: string; keyword?: string; pageno?: number; pagesize?: number;
|
||||
} = {}) {
|
||||
const { brand, keyword, pageno = 1, pagesize = 50 } = opts;
|
||||
const url = new URL(`${API_BASE}/catalogue/getproducts`);
|
||||
if (brand) url.searchParams.set("brand", brand);
|
||||
if (keyword) url.searchParams.set("keyword", keyword);
|
||||
url.searchParams.set("pageno", String(pageno));
|
||||
url.searchParams.set("pagesize", String(pagesize));
|
||||
|
||||
const products = await apiGet<CatalogueProduct>(url);
|
||||
return { products, total: products.length };
|
||||
}
|
||||
|
||||
// brand omitted → imported refs across every brand.
|
||||
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
|
||||
const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`);
|
||||
url.searchParams.set("tenantid", String(tenantid));
|
||||
if (brand) url.searchParams.set("brand", brand);
|
||||
|
||||
const refs = await apiGet<ImportedRef>(url);
|
||||
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
|
||||
}
|
||||
|
||||
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
||||
const res = await fetch(`${API_BASE}/products/importcatalogueproduct`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify(items),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to import products");
|
||||
const json = await res.json();
|
||||
if (json && typeof json === 'object' && 'status' in json && !json.status) {
|
||||
throw new Error(json.message || "Failed to import products");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
|
||||
const res = await fetch(`${API_BASE}/products/deleteproductlocation`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify({ tenantid, locationid, productid }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove product from store");
|
||||
const json = await res.json();
|
||||
if (json && typeof json === 'object' && 'status' in json && !json.status) {
|
||||
throw new Error(json.message || "Failed to remove product from store");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function getBrands() {
|
||||
const url = new URL(`${API_BASE}/catalogue/getbrands`);
|
||||
const brands = await apiGet<{ brand: string; count: number }>(url);
|
||||
return brands;
|
||||
}
|
||||
|
||||
export async function getProductSubcategories(tenantid: number, categoryid?: number) {
|
||||
const url = new URL(`${API_BASE}/products/getproductsubcategories`);
|
||||
url.searchParams.set("tenantid", String(tenantid));
|
||||
if (categoryid) url.searchParams.set("categoryid", String(categoryid));
|
||||
const subcategories = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url);
|
||||
return subcategories;
|
||||
}
|
||||
@@ -20,11 +20,11 @@
|
||||
const FIESTA_BASE = import.meta.env.VITE_FIESTA_URL || 'https://fiesta.nearle.app/live/api/v1/web';
|
||||
const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'https://fiesta.nearle.app/live/api/v1/mob';
|
||||
|
||||
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
|
||||
export const FIESTA_TENANT_ID = 1087;
|
||||
/** Tenant / location scope shared by the merchant console. */
|
||||
export const FIESTA_TENANT_ID = 1135;
|
||||
export const FIESTA_APPLOCATION_ID = 1;
|
||||
/** Primary outlet for this tenant — the one carrying live orders/stock. */
|
||||
export const FIESTA_PRIMARY_LOCATION_ID = 1097;
|
||||
export const FIESTA_PRIMARY_LOCATION_ID = 1170;
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
type QueryParams = Record<string, string | number | undefined | null>;
|
||||
@@ -167,7 +167,13 @@ export async function getOrderSummary(
|
||||
todate: string,
|
||||
locationid?: number,
|
||||
): Promise<FiestaOrderSummary | null> {
|
||||
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, locationid, fromdate, todate }));
|
||||
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', {
|
||||
tenantid,
|
||||
locationid,
|
||||
applocationid: locationid, // send both to bypass backend bugs
|
||||
fromdate,
|
||||
todate
|
||||
}));
|
||||
if (!row) return null;
|
||||
return {
|
||||
total: num(row.total),
|
||||
@@ -192,9 +198,9 @@ export interface FiestaLocationSummary {
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
/** /orders/getlocationsummary?tenantid= — per-outlet order rollup. */
|
||||
export async function getLocationSummary(tenantid: number): Promise<FiestaLocationSummary[]> {
|
||||
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid })).map((r) => ({
|
||||
/** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */
|
||||
export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise<FiestaLocationSummary[]> {
|
||||
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({
|
||||
locationid: num(r.locationid),
|
||||
locationname: str(r.locationname),
|
||||
total: num(r.total),
|
||||
@@ -622,9 +628,11 @@ export function cleanTenantLocations(rows: Row[]): Row[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant (test rows stripped). */
|
||||
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
||||
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
|
||||
/** /tenants/gettenantlocations?tenantid=&userid= — outlet locations for a tenant (test rows stripped). */
|
||||
export async function getTenantLocations(tenantid: number, userid?: number): Promise<Row[]> {
|
||||
const params: Record<string, any> = { tenantid };
|
||||
if (userid) params.userid = userid;
|
||||
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', params)));
|
||||
}
|
||||
|
||||
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
|
||||
@@ -737,7 +745,7 @@ export async function getProductStocks(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
/** /products/getproductlocations?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
||||
/** /products/getlocationproducts?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
||||
* geofenced per-outlet inventory. */
|
||||
export async function getProductLocations(opts: {
|
||||
tenantid: number;
|
||||
@@ -747,7 +755,7 @@ export async function getProductLocations(opts: {
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('products/getproductlocations', {
|
||||
await fiestaGet('products/getlocationproducts', {
|
||||
tenantid: opts.tenantid,
|
||||
locationid: opts.locationid,
|
||||
subcategoryid: opts.subcategoryid,
|
||||
@@ -783,13 +791,35 @@ export interface CreateProductLocationInput {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
qty: number;
|
||||
quantity?: number; // User prompt specified quantity
|
||||
qty?: number; // Keep for backwards compatibility if needed
|
||||
stocktype?: string;
|
||||
status?: string;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. */
|
||||
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
|
||||
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
|
||||
return fiestaSend<Row>('products/createproductlocation', 'POST', input);
|
||||
const payload = {
|
||||
tenantid: input.tenantid,
|
||||
locationid: input.locationid,
|
||||
productid: input.productid,
|
||||
quantity: input.quantity ?? input.qty ?? 0,
|
||||
stocktype: input.stocktype || 'in',
|
||||
status: input.status || 'Active',
|
||||
};
|
||||
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
|
||||
}
|
||||
|
||||
export interface DeleteProductLocationInput {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
productid: number;
|
||||
}
|
||||
|
||||
/** DELETE /products/deleteproductlocation — Remove a product from a store catalogue. */
|
||||
export async function deleteProductLocation(input: DeleteProductLocationInput): Promise<Row> {
|
||||
return fiestaSend<Row>('products/deleteproductlocation', 'DELETE', input);
|
||||
}
|
||||
|
||||
export interface StockRequestInput {
|
||||
@@ -1211,3 +1241,40 @@ export async function getSalesSummary(opts: {
|
||||
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
||||
return res.details;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GLOBAL CATALOGUE
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** GET /v1/web/catalogue/getbrands — List all brands + product count per brand */
|
||||
export async function getGlobalBrands(): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('catalogue/getbrands'));
|
||||
}
|
||||
|
||||
/** GET /v1/web/catalogue/getcategories — List categories available for that brand */
|
||||
export async function getGlobalCategories(opts: { brand: string }): Promise<string[]> {
|
||||
const res = await fiestaGet<{ details: string[] }>('catalogue/getcategories', { brand: opts.brand });
|
||||
return Array.isArray(res.details) ? res.details : [];
|
||||
}
|
||||
|
||||
/** GET /v1/web/catalogue/getproducts — Product list */
|
||||
export async function getGlobalProducts(opts: {
|
||||
brand: string;
|
||||
category?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('catalogue/getproducts', {
|
||||
brand: opts.brand,
|
||||
category: opts.category,
|
||||
keyword: opts.keyword,
|
||||
pageno: opts.pageno,
|
||||
pagesize: opts.pagesize,
|
||||
}));
|
||||
}
|
||||
|
||||
/** GET /v1/web/catalogue/getproduct — Single product lookup by SKU */
|
||||
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
|
||||
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
|
||||
}
|
||||
|
||||
@@ -166,3 +166,34 @@ export function orderRowToOrder(row: Row): CustomerOrder {
|
||||
locationid: num(row.locationid),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Global Catalogue product row (from /catalogue/getproducts) to ProductMatrixItem.
|
||||
*/
|
||||
export function globalRowToProduct(row: Row): ProductMatrixItem {
|
||||
// Extract base price from price_range if possible, else 0
|
||||
const priceRange = str(row.price_range) || '';
|
||||
const priceMatch = priceRange.match(/\d+/);
|
||||
const basePrice = priceMatch ? parseInt(priceMatch[0], 10) : 0;
|
||||
|
||||
return {
|
||||
id: str(row.id),
|
||||
name: str(row.product_name) || str(row.title) || 'Unnamed product',
|
||||
sku: str(row.product_sku) || str(row.variant_key) || `SKU-${str(row.id)}`,
|
||||
unitsSold: 0,
|
||||
revenue: basePrice,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'flat',
|
||||
image: (Array.isArray(row.images) && row.images.length > 0) ? str(row.images[0]) : str(row.image) || str(row.image_url) || (str(row.image_id) ? `https://fiesta.nearle.app/images/${str(row.image_id)}.jpg` : PLACEHOLDER_IMG),
|
||||
category: str(row.category) || 'Uncategorized',
|
||||
exposure: str(row.size) || '1 unit',
|
||||
verified: true,
|
||||
description: str(row.description) || undefined,
|
||||
fssaiLicense: str(row.fssai_license) || undefined,
|
||||
highlights: Array.isArray(row.highlights) ? row.highlights : [],
|
||||
nutrients: Array.isArray(row.nutrients) ? row.nutrients : [],
|
||||
providers: Array.isArray(row.providers) ? row.providers : [],
|
||||
brand: str(row.brand) || undefined,
|
||||
priceRange: str(row.price_range) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@
|
||||
* Swap `read`/`write` for those calls when the API is ready; the hook API stays.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createProductLocation, FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
||||
import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
||||
|
||||
export interface StoreCatalogueItem {
|
||||
productid: string;
|
||||
@@ -30,71 +30,85 @@ export interface StoreCatalogueItem {
|
||||
unit: string;
|
||||
/** Quantity the admin intends to stock for this product. */
|
||||
qty: number;
|
||||
}
|
||||
|
||||
const KEY = 'nearledaily.storeCatalogue';
|
||||
const EVENT = 'nearledaily:storeCatalogue';
|
||||
|
||||
function read(): StoreCatalogueItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as StoreCatalogueItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: StoreCatalogueItem[]): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(items));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
// Notify listeners in this tab (storage event only fires in OTHER tabs).
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live view of the store catalogue + curation helpers. Re-renders whenever the
|
||||
* catalogue changes (this tab or another).
|
||||
* catalogue changes via React Query invalidation.
|
||||
*/
|
||||
export function useStoreCatalogue() {
|
||||
const [items, setItems] = useState<StoreCatalogueItem[]>(read);
|
||||
const q = useFiestaProductLocations({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
pagesize: 500,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setItems(read());
|
||||
window.addEventListener(EVENT, sync);
|
||||
window.addEventListener('storage', sync);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, sync);
|
||||
window.removeEventListener('storage', sync);
|
||||
};
|
||||
}, []);
|
||||
const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => r.status !== 'Inactive').map((r: any) => ({
|
||||
productid: String(r.productid),
|
||||
name: String(r.name || r.productname || ''),
|
||||
image: String(r.image || r.productimage || ''),
|
||||
category: String(r.category || r.categoryname || 'General'),
|
||||
sku: String(r.sku || ''),
|
||||
price: Number(r.price || 0),
|
||||
unit: String(r.unit || ''),
|
||||
qty: Number(r.quantity ?? r.qty ?? 0),
|
||||
status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(),
|
||||
}));
|
||||
|
||||
const mutation = useFiestaCreateProductLocation();
|
||||
const deleteMutation = useFiestaDeleteProductLocation();
|
||||
|
||||
const has = (id: string) => items.some((i) => i.productid === id);
|
||||
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
|
||||
|
||||
const add = (item: StoreCatalogueItem) => {
|
||||
write([...read().filter((i) => i.productid !== item.productid), item]);
|
||||
createProductLocation({
|
||||
mutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(item.productid),
|
||||
qty: item.qty,
|
||||
status: 'Active'
|
||||
}).catch(e => console.error('API createProductLocation failed:', e));
|
||||
price: item.price,
|
||||
status: item.status || 'Active'
|
||||
});
|
||||
};
|
||||
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
|
||||
|
||||
const remove = (id: string) => {
|
||||
const item = items.find(i => i.productid === id);
|
||||
if (!item) return;
|
||||
deleteMutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(id)
|
||||
});
|
||||
};
|
||||
|
||||
const setQty = (id: string, qty: number) => {
|
||||
const item = items.find(i => i.productid === id);
|
||||
if (!item) return;
|
||||
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||
write(read().map((i) => (i.productid === id ? { ...i, qty: safeQty } : i)));
|
||||
createProductLocation({
|
||||
mutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(id),
|
||||
qty: safeQty,
|
||||
status: 'Active'
|
||||
}).catch(e => console.error('API updateProductLocation failed:', e));
|
||||
price: item.price,
|
||||
status: item.status || 'Active'
|
||||
});
|
||||
};
|
||||
|
||||
const setPrice = (id: string, price: number) => {
|
||||
const item = items.find(i => i.productid === id);
|
||||
if (!item) return;
|
||||
mutation.mutate({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||
productid: Number(id),
|
||||
qty: item.qty || 0,
|
||||
price: price,
|
||||
status: 'Active' // Setting a price activates it
|
||||
});
|
||||
};
|
||||
|
||||
return { items, has, getQty, add, remove, setQty };
|
||||
return { items, has, getQty, add, remove, setQty, setPrice, isLoading: q.isLoading };
|
||||
}
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch';
|
||||
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch' | 'catalogue';
|
||||
|
||||
export interface KPICardData {
|
||||
title: string;
|
||||
@@ -48,7 +48,15 @@ export interface ProductMatrixItem {
|
||||
exposure: string;
|
||||
verified: boolean;
|
||||
isNew?: boolean;
|
||||
price?: number;
|
||||
isSample?: boolean;
|
||||
description?: string;
|
||||
fssaiLicense?: string;
|
||||
highlights?: string[];
|
||||
nutrients?: string[];
|
||||
providers?: string[];
|
||||
brand?: string;
|
||||
priceRange?: string;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
|
||||
37
test-api.js
Normal file
37
test-api.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
async function test() {
|
||||
const res = await fetch('https://queue.workolik.com/live/api/v1/web/orders/getlocationsummary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantid: 1135, fromdate: '2026-07-13', todate: '2026-07-13' })
|
||||
});
|
||||
const text = await res.text();
|
||||
console.log('Location Summary:', text);
|
||||
|
||||
const res2 = await fetch('https://queue.workolik.com/live/api/v1/web/orders/getordersummary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantid: 1135, locationid: 1170, fromdate: '2026-07-13', todate: '2026-07-13' })
|
||||
});
|
||||
const text2 = await res2.text();
|
||||
console.log('Order Summary Peelamedu (1170):', text2);
|
||||
|
||||
const res3 = await fetch('https://queue.workolik.com/live/api/v1/web/orders/getordersummary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantid: 1135, applocationid: 1170, fromdate: '2026-07-13', todate: '2026-07-13' })
|
||||
});
|
||||
const text3 = await res3.text();
|
||||
console.log('Order Summary Peelamedu (1170) applocationid:', text3);
|
||||
|
||||
const res4 = await fetch('https://queue.workolik.com/live/api/v1/web/orders/getordersummary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantid: 1135, locationid: 1097, fromdate: '2026-07-13', todate: '2026-07-13' })
|
||||
});
|
||||
const text4 = await res4.text();
|
||||
console.log('Order Summary RS Puram (1097):', text4);
|
||||
}
|
||||
|
||||
test();
|
||||
@@ -1,4 +1,15 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
console.log("Checking API structure...");
|
||||
async function test() {
|
||||
const res = await fetch('https://fiesta.nearle.app/live/api/v1/web/users/getallusers?tenantid=1135');
|
||||
const json = await res.json();
|
||||
const users = json.details || json;
|
||||
|
||||
const rsPuramUser = users.find(u => u.userid === 1409 || (u.email && u.email.includes('rspuram')));
|
||||
console.log('RS Puram User:', JSON.stringify(rsPuramUser, null, 2));
|
||||
|
||||
const gandhipuramUser = users.find(u => u.userid === 1406 || (u.email && u.email.includes('gandhipuram')));
|
||||
console.log('Gandhipuram User:', JSON.stringify(gandhipuramUser, null, 2));
|
||||
}
|
||||
|
||||
test();
|
||||
|
||||
Reference in New Issue
Block a user