import product catalogue

This commit is contained in:
2026-07-16 17:05:06 +05:30
parent 8a1527cef8
commit 11baf732b7
35 changed files with 2722 additions and 1513 deletions

7
package-lock.json generated
View File

@@ -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",

View File

@@ -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
View 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();

View File

@@ -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}
/> />
} /> } />

View 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>
);
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 ?? [])) {

View File

@@ -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` }}>

View File

@@ -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>

View File

@@ -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"

View 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

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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">

View File

@@ -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 }) => (

View File

@@ -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>

View File

@@ -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

View File

@@ -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" />

View File

@@ -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 youre assigned to.</p> <p className="text-zinc-500 font-sans text-xs mb-6">Your profile and the store youre 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>

View File

@@ -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 }) => (

View File

@@ -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">

View File

@@ -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>

View 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'] });
},
});
}

View File

@@ -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;
}
}

View 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;
}

View File

@@ -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 }));
}

View File

@@ -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

View File

@@ -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 };
} }

View File

@@ -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
View 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();

View File

@@ -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();