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",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"claude": "^0.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -2039,6 +2040,12 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"claude": "^0.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"leaflet": "^1.9.4",
|
"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,
|
Store,
|
||||||
Settings,
|
Settings,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users
|
Users,
|
||||||
|
Box
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { MainSection } from './types';
|
import { MainSection } from './types';
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
useFiestaCreateLocation,
|
useFiestaCreateLocation,
|
||||||
useFiestaOrderSummary,
|
useFiestaOrderSummary,
|
||||||
} from './services/fiestaQueries';
|
} 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 Sidebar from './components/Sidebar';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import DashboardView from './components/DashboardView';
|
import DashboardView from './components/DashboardView';
|
||||||
@@ -55,6 +56,7 @@ import LoginView from './components/LoginView';
|
|||||||
import UserStorePage from './components/UserStorePage';
|
import UserStorePage from './components/UserStorePage';
|
||||||
import AwaitingApi from './components/AwaitingApi';
|
import AwaitingApi from './components/AwaitingApi';
|
||||||
import ComparisonModal from './components/ComparisonModal';
|
import ComparisonModal from './components/ComparisonModal';
|
||||||
|
import CatalogueBrowser from './components/CatalogueBrowser';
|
||||||
import type { AuthUser } from './services/auth';
|
import type { AuthUser } from './services/auth';
|
||||||
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
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')
|
? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console')
|
||||||
: currentSection === 'inventory'
|
: currentSection === 'inventory'
|
||||||
? 'Products'
|
? 'Products'
|
||||||
: currentSection === 'dispatch'
|
: currentSection === 'catalogue'
|
||||||
|
? 'Global Catalogue'
|
||||||
|
: currentSection === 'dispatch'
|
||||||
? 'Console'
|
? 'Console'
|
||||||
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
|
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
|
||||||
icon: currentSection === 'dashboard'
|
icon: currentSection === 'dashboard'
|
||||||
? LayoutDashboard
|
? LayoutDashboard
|
||||||
: currentSection === 'inventory'
|
: currentSection === 'inventory'
|
||||||
? Layers
|
? Layers
|
||||||
: currentSection === 'stores'
|
: currentSection === 'catalogue'
|
||||||
|
? Box
|
||||||
|
: currentSection === 'stores'
|
||||||
? Store
|
? Store
|
||||||
: currentSection === 'reports'
|
: currentSection === 'reports'
|
||||||
? TrendingUp
|
? TrendingUp
|
||||||
@@ -614,7 +620,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Main core pages payload area */}
|
{/* 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)]'}`}>
|
<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>
|
<Routes>
|
||||||
<Route index element={<Navigate to="dashboard" replace />} />
|
<Route index element={<Navigate to="dashboard" replace />} />
|
||||||
<Route path="dashboard" element={
|
<Route path="dashboard" element={
|
||||||
@@ -635,6 +641,14 @@ export default function App() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
tenantId={tenantId}
|
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 React, { useMemo, useState } from 'react';
|
||||||
import { useFiestaCustomerOrders } from '../services/fiestaQueries';
|
import { useFiestaCustomerOrders } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
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 OrderDetailsModal from './OrderDetailsModal';
|
||||||
import './CustomerDetailPanel.css';
|
import './CustomerDetailPanel.css';
|
||||||
|
|
||||||
@@ -108,6 +108,17 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cdp-contact-inline">
|
<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 && (
|
{phone && (
|
||||||
<a href={`tel:${phone}`} className="cdp-contact-chip">
|
<a href={`tel:${phone}`} className="cdp-contact-chip">
|
||||||
<span className="cdp-contact-icon-bg"><Phone size={12} /></span>
|
<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 monthlyRevenue = insight ? Number(insight.grossrevenue || insight.overallrevenue || insight.revenue || 0) : null;
|
||||||
const monthlyProfit = insight ? Number(insight.profit || insight.netrevenue || insight.margin || 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 ?? [];
|
const summaries = locSummaryQ.data ?? [];
|
||||||
|
|
||||||
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
// 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 (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-500 relative">
|
<div className="space-y-lg animate-in fade-in duration-500 relative">
|
||||||
{/* ── Immersive Executive Banner (cover image + slate→purple gradient overlay) ── */}
|
{/* ── 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 */}
|
{/* 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
|
<img
|
||||||
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
|
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
|
||||||
alt="Executive operations dashboard"
|
alt="Executive operations dashboard"
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{/* Table */}
|
{/* 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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full" style={{ minWidth: 1240 }}>
|
<table className="w-full" style={{ minWidth: 1240 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -268,7 +268,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
|
|||||||
// in the view tree is transformed/blurred (otherwise the panel collapses).
|
// in the view tree is transformed/blurred (otherwise the panel collapses).
|
||||||
return createPortal(
|
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="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 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 }}>
|
<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>
|
<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; }) {
|
function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenantId: number; locationid?: number; fromdate: string; todate: string; }) {
|
||||||
const [metric, setMetric] = useState<'revenue' | 'orders'>('revenue');
|
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 ordersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
||||||
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate, todate });
|
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()]}`;
|
return `${d.getDate()} ${months[d.getMonth()]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalRevenue = revenueQ.data?.grossrevenue ?? 0;
|
const totalRevenue = revenueQ.data?.overallrevenue ?? 0;
|
||||||
const locationRevenueMap = useMemo(() => {
|
const locationRevenueMap = useMemo(() => {
|
||||||
const map = new Map<number, number>();
|
const map = new Map<number, number>();
|
||||||
for (const r of (ordersQ.data ?? [])) {
|
for (const r of (ordersQ.data ?? [])) {
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
setFocusedId(null);
|
setFocusedId(null);
|
||||||
}, [viewMode]);
|
}, [viewMode]);
|
||||||
|
|
||||||
|
const [customerStoreFilter, setCustomerStoreFilter] = useState<string>('all');
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||||
const [animateNonce, setAnimateNonce] = useState(0);
|
const [animateNonce, setAnimateNonce] = useState(0);
|
||||||
@@ -167,6 +168,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
|
|
||||||
if (viewMode === 'stores' && locationsQ.data) {
|
if (viewMode === 'stores' && locationsQ.data) {
|
||||||
for (const loc of locationsQ.data) {
|
for (const loc of locationsQ.data) {
|
||||||
|
if (locationid && fnum(loc.locationid) !== locationid) continue;
|
||||||
const id = String(fnum(loc.locationid)).toLowerCase();
|
const id = String(fnum(loc.locationid)).toLowerCase();
|
||||||
const name = fstr(loc.locationname) || `Store ${id}`;
|
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: {} });
|
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) {
|
if (viewMode === 'customers' && customersQ.data) {
|
||||||
for (const cust of 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 id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
|
||||||
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`;
|
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 });
|
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));
|
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]);
|
}, [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 focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||||
const groupedByRider = viewMode !== 'riders';
|
const groupedByRider = viewMode !== 'riders';
|
||||||
|
|
||||||
@@ -361,7 +373,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
groupedByRider={groupedByRider}
|
groupedByRider={groupedByRider}
|
||||||
tripSort={tripSort}
|
tripSort={tripSort}
|
||||||
setTripSort={setTripSort}
|
setTripSort={setTripSort}
|
||||||
onBack={() => setFocusedId(null)}
|
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
|
||||||
fmtTime={fmtTime}
|
fmtTime={fmtTime}
|
||||||
riderLogs={riderLogsQ.data}
|
riderLogs={riderLogsQ.data}
|
||||||
riderLogsLoading={riderLogsQ.isLoading}
|
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 === 'customers' ? 'No customers found' : 'No deliveries for this day'}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="ph">
|
<div className="ph relative flex items-center justify-between w-full h-8">
|
||||||
{viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})
|
<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>
|
</div>
|
||||||
{groups.map((g) => {
|
{groups.map((g) => {
|
||||||
const isSelected = focusedId === g.id;
|
const isSelected = focusedId === g.id;
|
||||||
@@ -580,16 +615,18 @@ function FocusedDetail({
|
|||||||
groupedByRider: boolean;
|
groupedByRider: boolean;
|
||||||
tripSort: 'planned' | 'time';
|
tripSort: 'planned' | 'time';
|
||||||
setTripSort: (v: 'planned' | 'time') => void;
|
setTripSort: (v: 'planned' | 'time') => void;
|
||||||
onBack: () => void;
|
onBack?: () => void;
|
||||||
fmtTime: (raw: unknown) => string;
|
fmtTime: (raw: unknown) => string;
|
||||||
riderLogs?: Row[];
|
riderLogs?: Row[];
|
||||||
riderLogsLoading?: boolean;
|
riderLogsLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
{onBack && (
|
||||||
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
||||||
</button>
|
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{riderLogs && riderLogs.length > 0 && (
|
{riderLogs && riderLogs.length > 0 && (
|
||||||
<RiderTelemetryPanel
|
<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) => (
|
{tripBlocks.map((blk, bi) => (
|
||||||
<div className="trip-block" key={bi}>
|
<div className="trip-block" key={bi}>
|
||||||
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
|
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ interface Props {
|
|||||||
productId: string;
|
productId: string;
|
||||||
category: string;
|
category: string;
|
||||||
productName: 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);
|
const details = generateFMCGDetails(productId, category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,21 +33,36 @@ export default function FMCGHoverOverlay({ productId, category, productName }: P
|
|||||||
|
|
||||||
{/* 2. Ingredients & Legal (Back Panel) */}
|
{/* 2. Ingredients & Legal (Back Panel) */}
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
<div>
|
{product?.description && (
|
||||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
<div>
|
||||||
<Beaker size={10} /> Ingredients
|
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||||
</h5>
|
<Info size={10} /> Description
|
||||||
<p className="text-[10px] text-slate-700 leading-relaxed font-medium">{details.ingredients}</p>
|
</h5>
|
||||||
<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">
|
<p className="text-[10px] text-slate-700 leading-relaxed font-medium line-clamp-4">{product.description}</p>
|
||||||
{details.allergens}
|
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||||
<ShieldCheck size={10} /> FSSAI / Storage
|
<ShieldCheck size={10} /> FSSAI / Storage
|
||||||
</h5>
|
</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>
|
<p className="text-[9px] text-slate-500 mt-0.5">{details.storage}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function Header({
|
|||||||
.toUpperCase() || 'NA';
|
.toUpperCase() || 'NA';
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Brand & Desktop Navigation Tabs */}
|
||||||
<div className="flex items-center gap-md md:pl-0 pl-1">
|
<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 */}
|
{/* 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 */}
|
{/* 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
|
<img
|
||||||
src={isSidebarOpen ? '/logo.png' : '/favicon.png'}
|
src={isSidebarOpen ? '/logo.png' : '/favicon.png'}
|
||||||
alt="nearledaily logo"
|
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 React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { 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 { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import { shortTime } from '../services/fiestaMappers';
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +105,8 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
|||||||
setShowSelected(false);
|
setShowSelected(false);
|
||||||
}, [fromdate, todate, status, branch, pageno, locationid]);
|
}, [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
|
// Scope to the user's store when a locationid is supplied (server-side per the
|
||||||
// backend's getordersummary/getorders locationid param); tenant-wide otherwise.
|
// backend's getordersummary/getorders locationid param); tenant-wide otherwise.
|
||||||
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate, locationid);
|
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.
|
// simply won't appear — the intended guard.
|
||||||
const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]);
|
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 orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]);
|
||||||
|
|
||||||
const ridersQ = useFiestaRiders({
|
const ridersQ = useFiestaRiders({
|
||||||
tenantid: tenantId,
|
tenantid: tenantId,
|
||||||
applocationid: orderApplocationId || undefined,
|
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(
|
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) => ({
|
.map((r) => ({
|
||||||
id: fnum(r.userid),
|
id: fnum(r.userid),
|
||||||
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
||||||
}))
|
}))
|
||||||
.filter((o) => o.id > 0 && o.label),
|
.filter((o) => o.id > 0 && o.label);
|
||||||
[ridersQ.data],
|
},
|
||||||
|
[ridersQ.data, internalRidersQ.data, riderSource, orderPartnerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Branches (app-locations) present in the data — drives the branch filter so the
|
// 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 }}>
|
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
|
||||||
<UserCheck size={15} /> {selected.size} selected
|
<UserCheck size={15} /> {selected.size} selected
|
||||||
</span>
|
</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
|
<select
|
||||||
value={assignRiderId}
|
value={assignRiderId}
|
||||||
onChange={(e) => setAssignRiderId(Number(e.target.value))}
|
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"
|
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 }}
|
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>)}
|
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
@@ -371,7 +414,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* 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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full" style={{ minWidth: 960 }}>
|
<table className="w-full" style={{ minWidth: 960 }}>
|
||||||
<thead>
|
<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>
|
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
|
||||||
</div>
|
</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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full" style={{ minWidth: 720 }}>
|
<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>
|
<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(
|
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)' }}
|
<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(); }}>
|
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 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 }}>
|
<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>
|
<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));
|
return coimbatoreZones.some(zone => name.toLowerCase().includes(zone));
|
||||||
};
|
};
|
||||||
|
|
||||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
const locSummaryQ = useFiestaLocationSummary(tenantId, ymd(yearStart), todate);
|
||||||
|
|
||||||
const regionLocations = useMemo(() => {
|
const regionLocations = useMemo(() => {
|
||||||
const rawLocations = [...(locSummaryQ.data ?? [])];
|
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">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
|
||||||
|
|
||||||
{/* Revenue Heatmap table - 8 Cols */}
|
{/* 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">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard nodes bar list - 4 Cols */}
|
{/* 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">
|
<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">
|
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||||
Top Performing Nodes
|
Top Performing Nodes
|
||||||
@@ -888,7 +888,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Performance Matrix table */}
|
{/* 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 */}
|
{/* Table header with filters control */}
|
||||||
|
|||||||
@@ -255,29 +255,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
}, [rolesQ.data]);
|
}, [rolesQ.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 font-sans text-slate-700">
|
<div className="font-sans text-slate-700">
|
||||||
{/* Header */}
|
{/* Header Removed */}
|
||||||
<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="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
|
||||||
{/* Tab rail & Merchant Card */}
|
{/* 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 */}
|
{/* 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">
|
<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 */}
|
{/* Background design accents */}
|
||||||
@@ -329,7 +312,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div className="lg:col-span-3 space-y-gutter text-sm pb-24">
|
<div className="lg:col-span-3 space-y-gutter text-sm pb-24">
|
||||||
{activeTab === 'profile' && (
|
{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>
|
<div>
|
||||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Store Profile</span>
|
<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>
|
<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' && (
|
{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 className="flex justify-between items-center pb-4 border-b border-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">
|
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Users,
|
Users,
|
||||||
Truck
|
Truck,
|
||||||
|
Box
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
@@ -37,6 +38,7 @@ export default function Sidebar({
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
{ 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: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||||
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
|
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||||
@@ -53,7 +55,7 @@ export default function Sidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<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'
|
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}`}
|
to={`/admin/${item.id}`}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
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
|
isActive
|
||||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ 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 { 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 { 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 { 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 { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||||
@@ -35,6 +34,7 @@ interface StoreCatalogViewProps {
|
|||||||
locationid?: number;
|
locationid?: number;
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
|
isSidebarOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stockStatus(closing: number): { label: string; color: string } {
|
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';
|
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 tenantid = tenantId;
|
||||||
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
||||||
|
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]);
|
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 storeCat = useStoreCatalogue();
|
||||||
const products = useMemo(
|
const products = useMemo(
|
||||||
() =>
|
() =>
|
||||||
storeCat.items.map((it) => ({
|
storeCat.items
|
||||||
id: it.productid,
|
.filter((it) => it.status === 'Active')
|
||||||
name: it.name,
|
.map((it) => ({
|
||||||
sku: it.sku || `SKU-${it.productid}`,
|
id: it.productid,
|
||||||
image: it.image || PLACEHOLDER,
|
name: it.name,
|
||||||
category: it.category || 'General',
|
sku: it.sku || `SKU-${it.productid}`,
|
||||||
price: it.price,
|
image: it.image || PLACEHOLDER,
|
||||||
unit: it.unit,
|
category: it.category || 'General',
|
||||||
adminQty: it.qty,
|
price: it.price,
|
||||||
})),
|
unit: it.unit,
|
||||||
|
adminQty: it.qty,
|
||||||
|
})),
|
||||||
[storeCat.items],
|
[storeCat.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,6 +163,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [stockQ.data, picks]);
|
}, [stockQ.data, picks]);
|
||||||
|
|
||||||
const inventory = useMemo(
|
const inventory = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const baseInventory = (stockQ.data ?? []).map((r: Row) => {
|
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',
|
name: fstr(r.productname) || 'Unnamed product',
|
||||||
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
|
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
|
||||||
image: fstr(r.productimage) || PLACEHOLDER,
|
image: fstr(r.productimage) || PLACEHOLDER,
|
||||||
category: categoryName(fnum(r.categoryid)),
|
category: fstr(r.categoryname) || 'General',
|
||||||
closing,
|
closing,
|
||||||
...stockStatus(closing),
|
...stockStatus(closing),
|
||||||
price: Math.floor(Math.random() * 50) + 10, // mock price
|
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">
|
<div className="animate-in fade-in duration-300 font-sans pb-28">
|
||||||
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs and Controls */}
|
||||||
<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">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
|
||||||
<button
|
<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">
|
||||||
onClick={() => setView('catalogue')}
|
<button
|
||||||
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 ${
|
onClick={() => setView('catalogue')}
|
||||||
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
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>
|
<Boxes size={14} /> Browse Catalogue ({products.length})
|
||||||
<button
|
</button>
|
||||||
onClick={() => setView('inventory')}
|
<button
|
||||||
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 ${
|
onClick={() => setView('inventory')}
|
||||||
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
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>
|
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||||
<button
|
</button>
|
||||||
onClick={() => setView('requests')}
|
<button
|
||||||
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 ${
|
onClick={() => setView('requests')}
|
||||||
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
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>
|
<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>
|
||||||
|
|
||||||
<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 */}
|
{/* Sticky Sidebar Filter */}
|
||||||
{view !== 'requests' && (
|
{view !== 'requests' && isLocalSidebarOpen && (
|
||||||
<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 className="w-full xl:w-64 shrink-0 flex flex-col gap-5 pr-2 pb-2">
|
||||||
<div>
|
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0">
|
||||||
<Search size={14} className="text-[#662582]" /> Search
|
<h3 className="font-bold text-slate-800 flex items-center gap-2">
|
||||||
</h3>
|
<Layers size={16} className="text-[#662582]" /> Filter Product
|
||||||
<div className="relative">
|
</h3>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="w-full h-px bg-slate-100" />
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
<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
|
<Layers size={14} className="text-[#662582]" /> Categories
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<label key={c} className="flex items-center gap-2.5 cursor-pointer group">
|
<label key={c} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
||||||
<input
|
<div className="relative flex items-center justify-center">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={selectedCategories.includes(c)}
|
type="checkbox"
|
||||||
onChange={() => toggleCategory(c)}
|
checked={selectedCategories.includes(c)}
|
||||||
className="sr-only"
|
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"
|
||||||
<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} />}
|
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||||
</div>
|
</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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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
|
<Activity size={14} className="text-[#662582]" /> Stock Health
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
||||||
<label key={h} className="flex items-center gap-2.5 cursor-pointer group">
|
<label key={h} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
||||||
<input
|
<div className="relative flex items-center justify-center">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={stockHealthFilter.includes(h)}
|
type="checkbox"
|
||||||
onChange={() => toggleStockHealth(h)}
|
checked={stockHealthFilter.includes(h)}
|
||||||
className="sr-only"
|
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"
|
||||||
<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} />}
|
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||||
</div>
|
</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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -363,32 +372,17 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
Clear All Filters
|
Clear All Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Product Grid Area */}
|
{/* Product Grid Area */}
|
||||||
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
<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">
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Results Summary & Toolbar */}
|
{/* 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>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-slate-900">
|
<h2 className="text-lg font-bold text-slate-900">
|
||||||
{view === 'catalogue' ? 'Store Catalogue' : view === 'inventory' ? 'My Inventory' : 'My Requests'}
|
{view === 'catalogue' ? 'Store Catalogue' : view === 'inventory' ? 'My Inventory' : 'My Requests'}
|
||||||
@@ -399,7 +393,31 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Browse Catalogue ── */}
|
{/* ── 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."
|
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) => {
|
{filtered.map((p) => {
|
||||||
const stocked = inStore.has(p.id);
|
const stocked = inStore.has(p.id);
|
||||||
const pick = picks[p.id];
|
const pick = picks[p.id];
|
||||||
@@ -516,19 +534,6 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
{/* ── My Requests ── */}
|
{/* ── My Requests ── */}
|
||||||
{view === 'requests' && (
|
{view === 'requests' && (
|
||||||
<div className="flex flex-col gap-4">
|
<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) ? (
|
{(!stockRequestsQ.data || stockRequestsQ.data.length === 0) ? (
|
||||||
<CenterState
|
<CenterState
|
||||||
icon={<Inbox size={34} />}
|
icon={<Inbox size={34} />}
|
||||||
@@ -655,13 +660,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
title="No stock matches your filters"
|
title="No stock matches your filters"
|
||||||
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
|
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
|
||||||
action={
|
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
|
<X size={13} /> Clear filters
|
||||||
</button>
|
</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) => (
|
{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">
|
<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 */}
|
{/* Image Bento Block */}
|
||||||
@@ -944,14 +949,14 @@ function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; titl
|
|||||||
return (
|
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">
|
<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 */}
|
{/* 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 -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-indigo-200/30 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">
|
<div className="relative flex flex-col items-center">
|
||||||
{/* Icon with halo */}
|
{/* Icon with halo */}
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-5">
|
||||||
<span className="absolute inset-0 -m-3 rounded-full bg-purple-300/25 blur-xl" />
|
<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-gradient-to-br from-[#662582] to-indigo-500 text-white shadow-lg shadow-purple-500/20 ring-8 ring-white">
|
<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}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
|
|
||||||
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
||||||
{showHero && (
|
{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 */}
|
{/* Cover Image Background */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function StoreQRView({
|
|||||||
if (!locationid) {
|
if (!locationid) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl mx-auto animate-in fade-in duration-300">
|
<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">
|
<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} />
|
<AlertTriangle size={30} />
|
||||||
</div>
|
</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' }}>
|
<div className="mx-auto flex flex-col items-center" style={{ width: '100%', maxWidth: '380px', minWidth: '280px' }}>
|
||||||
|
|
||||||
{/* Premium Table Tent Mockup Card */}
|
{/* 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 */}
|
{/* 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" />
|
<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';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFiestaTenantLocations,
|
useFiestaTenantLocations,
|
||||||
useFiestaLocationSummary,
|
useFiestaOrderSummary,
|
||||||
FIESTA_TENANT_ID,
|
FIESTA_TENANT_ID,
|
||||||
} from '../services/fiestaQueries';
|
} 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 type { AuthUser } from '../services/auth';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import StoreDetailView from './StoreDetailView';
|
import StoreDetailView from './StoreDetailView';
|
||||||
@@ -51,7 +51,7 @@ const NAV_ITEMS: UserNavItem[] = [
|
|||||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||||
{ id: 'inventory', label: 'Products', icon: Layers },
|
{ id: 'inventory', label: 'Products', icon: Layers },
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'dispatch', label: 'Dispatch', icon: RouteIcon },
|
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
|
||||||
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
{ 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.
|
// only a fallback for legacy sessions whose record predates tenantid capture.
|
||||||
const tenantId = user.tenantid || FIESTA_TENANT_ID;
|
const tenantId = user.tenantid || FIESTA_TENANT_ID;
|
||||||
|
|
||||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
const todayStr = ymd(new Date());
|
||||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
const locationsQ = useFiestaTenantLocations(tenantId, user.userid);
|
||||||
|
|
||||||
const locations = locationsQ.data ?? [];
|
const locations = locationsQ.data ?? [];
|
||||||
const summaries = locSummaryQ.data ?? [];
|
|
||||||
|
|
||||||
// Resolve the user's store. Most tenants have exactly ONE store, so when the
|
// 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
|
// 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.
|
// (accepting a row whose locationid equals it too), then by locationid.
|
||||||
const apploc = user.applocationid;
|
const apploc = user.applocationid;
|
||||||
const locid = user.locationid;
|
const locid = user.locationid;
|
||||||
|
const userEmail = user.email?.toLowerCase() || '';
|
||||||
|
|
||||||
const matchedLoc =
|
const matchedLoc =
|
||||||
locations.length === 1
|
locations.length === 1
|
||||||
? locations[0]
|
? 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) => apploc != null && apploc > 0 && fnum(l.locationid) === apploc) ??
|
||||||
locations.find((l) => locid != null && locid > 0 && fnum(l.locationid) === locid) ??
|
locations.find((l) => apploc != null && apploc > 0 && fnum(l.applocationid) === apploc) ??
|
||||||
locations.find((l) => locid != null && locid > 0 && fnum(l.applocationid) === locid) ??
|
|
||||||
null);
|
null);
|
||||||
|
|
||||||
// Resolve the locationid the store console queries by. Prefer the matched
|
// Resolve the locationid the store console queries by. Prefer the matched
|
||||||
@@ -104,6 +114,9 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
const resolvedLocationId =
|
const resolvedLocationId =
|
||||||
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
||||||
|
|
||||||
|
const orderSummaryQ = useFiestaOrderSummary(tenantId, todayStr, todayStr, resolvedLocationId || undefined);
|
||||||
|
const sum = orderSummaryQ.data;
|
||||||
|
|
||||||
const storeName =
|
const storeName =
|
||||||
(matchedLoc && fstr(matchedLoc.locationname)) ||
|
(matchedLoc && fstr(matchedLoc.locationname)) ||
|
||||||
user.applocation ||
|
user.applocation ||
|
||||||
@@ -131,7 +144,6 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
// Build the store shape StoreDetailView expects, mirroring the App registry
|
// Build the store shape StoreDetailView expects, mirroring the App registry
|
||||||
// mapping so the console renders identically to the admin's store view.
|
// mapping so the console renders identically to the admin's store view.
|
||||||
const buildStore = (): StoreShape => {
|
const buildStore = (): StoreShape => {
|
||||||
const sum = summaries.find((s) => s.locationid === resolvedLocationId);
|
|
||||||
const status = matchedLoc ? fstr(matchedLoc.status) || 'Active' : 'Active';
|
const status = matchedLoc ? fstr(matchedLoc.status) || 'Active' : 'Active';
|
||||||
return {
|
return {
|
||||||
locationid: resolvedLocationId,
|
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>
|
<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>
|
<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">
|
<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">
|
<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}
|
{initials}
|
||||||
@@ -200,10 +212,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
|
|
||||||
// Distinguish a real load failure from "no store assigned" — otherwise an
|
// Distinguish a real load failure from "no store assigned" — otherwise an
|
||||||
// API outage would wrongly tell the user to contact their admin.
|
// API outage would wrongly tell the user to contact their admin.
|
||||||
if (locationsQ.isError || locSummaryQ.isError) {
|
if (locationsQ.isError || orderSummaryQ.isError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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">
|
<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} />
|
<AlertTriangle size={30} />
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +226,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
locationsQ.refetch();
|
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"
|
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) {
|
if (!resolvedLocationId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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">
|
<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} />
|
<AlertTriangle size={30} />
|
||||||
</div>
|
</div>
|
||||||
@@ -250,10 +262,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
// 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
|
// 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).
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
<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" />
|
<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'
|
? 'POS Terminal'
|
||||||
: activeSection === 'account'
|
: activeSection === 'account'
|
||||||
? 'My Account'
|
? 'My Account'
|
||||||
|
: activeSection === 'dispatch'
|
||||||
|
? 'Console'
|
||||||
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
|
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
|
||||||
icon: activeSection === 'console'
|
icon: activeSection === 'console'
|
||||||
? LayoutDashboard
|
? LayoutDashboard
|
||||||
@@ -324,7 +338,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
>
|
>
|
||||||
{isInactive && (
|
{isInactive && (
|
||||||
<div className="absolute inset-0 z-50 bg-slate-100/60 backdrop-blur-[2px] flex items-center justify-center">
|
<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">
|
<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} />
|
<Store size={32} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSi
|
|||||||
to={`/store/${item.id}`}
|
to={`/store/${item.id}`}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
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
|
isActive
|
||||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ 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"
|
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); }}
|
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 */}
|
{/* Modal Header */}
|
||||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
|
<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"
|
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); }}
|
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">
|
<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">
|
<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">
|
<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 */}
|
{/* 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">
|
<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 */}
|
{/* Minimalist Header */}
|
||||||
<div className="flex items-center justify-between px-7 py-5 border-b border-slate-100 z-10 shrink-0">
|
<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-slate-800 tracking-tight">{title}</h2>
|
<h2 className="text-lg font-bold text-white tracking-tight">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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} />
|
<X size={20} strokeWidth={2} />
|
||||||
</button>
|
</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 */
|
/* orange */
|
||||||
--color-orange-850: color-mix(in oklab, #9a3412, #7c2d12);
|
--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_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';
|
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). */
|
/** Tenant / location scope shared by the merchant console. */
|
||||||
export const FIESTA_TENANT_ID = 1087;
|
export const FIESTA_TENANT_ID = 1135;
|
||||||
export const FIESTA_APPLOCATION_ID = 1;
|
export const FIESTA_APPLOCATION_ID = 1;
|
||||||
/** Primary outlet for this tenant — the one carrying live orders/stock. */
|
/** 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>;
|
export type Row = Record<string, unknown>;
|
||||||
type QueryParams = Record<string, string | number | undefined | null>;
|
type QueryParams = Record<string, string | number | undefined | null>;
|
||||||
@@ -167,7 +167,13 @@ export async function getOrderSummary(
|
|||||||
todate: string,
|
todate: string,
|
||||||
locationid?: number,
|
locationid?: number,
|
||||||
): Promise<FiestaOrderSummary | null> {
|
): 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;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
total: num(row.total),
|
total: num(row.total),
|
||||||
@@ -192,9 +198,9 @@ export interface FiestaLocationSummary {
|
|||||||
cancelled: number;
|
cancelled: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /orders/getlocationsummary?tenantid= — per-outlet order rollup. */
|
/** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */
|
||||||
export async function getLocationSummary(tenantid: number): Promise<FiestaLocationSummary[]> {
|
export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise<FiestaLocationSummary[]> {
|
||||||
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid })).map((r) => ({
|
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({
|
||||||
locationid: num(r.locationid),
|
locationid: num(r.locationid),
|
||||||
locationname: str(r.locationname),
|
locationname: str(r.locationname),
|
||||||
total: num(r.total),
|
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). */
|
/** /tenants/gettenantlocations?tenantid=&userid= — outlet locations for a tenant (test rows stripped). */
|
||||||
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
export async function getTenantLocations(tenantid: number, userid?: number): Promise<Row[]> {
|
||||||
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
|
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. */
|
/** /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. */
|
* geofenced per-outlet inventory. */
|
||||||
export async function getProductLocations(opts: {
|
export async function getProductLocations(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
@@ -747,7 +755,7 @@ export async function getProductLocations(opts: {
|
|||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}): Promise<Row[]> {
|
}): Promise<Row[]> {
|
||||||
return toRows(
|
return toRows(
|
||||||
await fiestaGet('products/getproductlocations', {
|
await fiestaGet('products/getlocationproducts', {
|
||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
locationid: opts.locationid,
|
locationid: opts.locationid,
|
||||||
subcategoryid: opts.subcategoryid,
|
subcategoryid: opts.subcategoryid,
|
||||||
@@ -783,13 +791,35 @@ export interface CreateProductLocationInput {
|
|||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
productid: number;
|
productid: number;
|
||||||
qty: number;
|
quantity?: number; // User prompt specified quantity
|
||||||
|
qty?: number; // Keep for backwards compatibility if needed
|
||||||
|
stocktype?: string;
|
||||||
status?: 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> {
|
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 {
|
export interface StockRequestInput {
|
||||||
@@ -1211,3 +1241,40 @@ export async function getSalesSummary(opts: {
|
|||||||
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
||||||
return res.details;
|
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),
|
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.
|
* Swap `read`/`write` for those calls when the API is ready; the hook API stays.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
|
||||||
import { createProductLocation, FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
||||||
|
|
||||||
export interface StoreCatalogueItem {
|
export interface StoreCatalogueItem {
|
||||||
productid: string;
|
productid: string;
|
||||||
@@ -30,71 +30,85 @@ export interface StoreCatalogueItem {
|
|||||||
unit: string;
|
unit: string;
|
||||||
/** Quantity the admin intends to stock for this product. */
|
/** Quantity the admin intends to stock for this product. */
|
||||||
qty: number;
|
qty: number;
|
||||||
}
|
status: string;
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live view of the store catalogue + curation helpers. Re-renders whenever the
|
* Live view of the store catalogue + curation helpers. Re-renders whenever the
|
||||||
* catalogue changes (this tab or another).
|
* catalogue changes via React Query invalidation.
|
||||||
*/
|
*/
|
||||||
export function useStoreCatalogue() {
|
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 items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => r.status !== 'Inactive').map((r: any) => ({
|
||||||
const sync = () => setItems(read());
|
productid: String(r.productid),
|
||||||
window.addEventListener(EVENT, sync);
|
name: String(r.name || r.productname || ''),
|
||||||
window.addEventListener('storage', sync);
|
image: String(r.image || r.productimage || ''),
|
||||||
return () => {
|
category: String(r.category || r.categoryname || 'General'),
|
||||||
window.removeEventListener(EVENT, sync);
|
sku: String(r.sku || ''),
|
||||||
window.removeEventListener('storage', sync);
|
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 has = (id: string) => items.some((i) => i.productid === id);
|
||||||
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
|
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
|
||||||
|
|
||||||
const add = (item: StoreCatalogueItem) => {
|
const add = (item: StoreCatalogueItem) => {
|
||||||
write([...read().filter((i) => i.productid !== item.productid), item]);
|
mutation.mutate({
|
||||||
createProductLocation({
|
|
||||||
tenantid: FIESTA_TENANT_ID,
|
tenantid: FIESTA_TENANT_ID,
|
||||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||||
productid: Number(item.productid),
|
productid: Number(item.productid),
|
||||||
qty: item.qty,
|
qty: item.qty,
|
||||||
status: 'Active'
|
price: item.price,
|
||||||
}).catch(e => console.error('API createProductLocation failed:', e));
|
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 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);
|
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||||
write(read().map((i) => (i.productid === id ? { ...i, qty: safeQty } : i)));
|
mutation.mutate({
|
||||||
createProductLocation({
|
|
||||||
tenantid: FIESTA_TENANT_ID,
|
tenantid: FIESTA_TENANT_ID,
|
||||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||||
productid: Number(id),
|
productid: Number(id),
|
||||||
qty: safeQty,
|
qty: safeQty,
|
||||||
status: 'Active'
|
price: item.price,
|
||||||
}).catch(e => console.error('API updateProductLocation failed:', e));
|
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
|
* 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 {
|
export interface KPICardData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,7 +48,15 @@ export interface ProductMatrixItem {
|
|||||||
exposure: string;
|
exposure: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
price?: number;
|
||||||
isSample?: boolean;
|
isSample?: boolean;
|
||||||
|
description?: string;
|
||||||
|
fssaiLicense?: string;
|
||||||
|
highlights?: string[];
|
||||||
|
nutrients?: string[];
|
||||||
|
providers?: string[];
|
||||||
|
brand?: string;
|
||||||
|
priceRange?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryItem {
|
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 fetch from 'node-fetch';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
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