frontend changes in webpage
This commit is contained in:
46
src/App.jsx
46
src/App.jsx
@@ -8,10 +8,11 @@ import { LoginPage } from './pages/LoginPage';
|
||||
/*
|
||||
* Every page except the login screen is loaded on demand.
|
||||
*
|
||||
* Imported eagerly, the whole app landed in one ~790kB bundle - the charting
|
||||
* library alone, pulled in by the two analytics pages, was most of it, and
|
||||
* every visitor downloaded it before they could reach the login form. Splitting
|
||||
* per route means each page's code arrives when it is first opened.
|
||||
* Imported eagerly, the whole app landed in one ~790kB bundle, and every
|
||||
* visitor downloaded all of it before they could reach the login form.
|
||||
* Splitting per route means each page's code arrives when it is first opened.
|
||||
* (The charting library that dominated that bundle went away with the
|
||||
* analytics pages, but the split is still worth keeping.)
|
||||
*
|
||||
* LoginPage stays eager: it is the first thing an unauthenticated visitor
|
||||
* sees, so deferring it would only add a round trip before the form appears.
|
||||
@@ -19,13 +20,6 @@ import { LoginPage } from './pages/LoginPage';
|
||||
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })));
|
||||
const UserPage = lazy(() => import('./pages/UserPage').then((m) => ({ default: m.UserPage })));
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
const StoresPage = lazy(() => import('./pages/StoresPage').then((m) => ({ default: m.StoresPage })));
|
||||
const AnalyticsPage = lazy(() =>
|
||||
import('./pages/AnalyticsPage').then((m) => ({ default: m.AnalyticsPage }))
|
||||
);
|
||||
const NutritionAnalyticsPage = lazy(() =>
|
||||
import('./pages/NutritionAnalyticsPage').then((m) => ({ default: m.NutritionAnalyticsPage }))
|
||||
);
|
||||
const McpPage = lazy(() => import('./pages/McpPage').then((m) => ({ default: m.McpPage })));
|
||||
|
||||
function PageFallback({ label }) {
|
||||
@@ -104,36 +98,6 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Store Intelligence Route */}
|
||||
<Route
|
||||
path="/stores"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<StoresPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Analytics Route */}
|
||||
<Route
|
||||
path="/analytics"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<AnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Nutrition Intelligence Route */}
|
||||
<Route
|
||||
path="/nutrition-analytics"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
|
||||
<NutritionAnalyticsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* MCP Server Route - RESTRICTED TO ADMIN ONLY */}
|
||||
<Route
|
||||
path="/mcp"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid, Plug } from 'lucide-react';
|
||||
import { Home, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, Plug } from 'lucide-react';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
|
||||
export function NavigationHeader({
|
||||
@@ -13,7 +13,6 @@ export function NavigationHeader({
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const path = location.pathname;
|
||||
const search = location.search;
|
||||
const navigate = useNavigate();
|
||||
const { user, role, logout } = useAuth();
|
||||
|
||||
@@ -38,163 +37,134 @@ export function NavigationHeader({
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/95 px-6 py-3.5 backdrop-blur-md transition-all shadow-xs">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Left Section: Home/Back Button + Page Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={role === 'admin' ? '/' : '/user'}
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'}
|
||||
className="group flex items-center gap-2 rounded-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white px-4 py-2 text-xs font-semibold shadow-sm transition-all duration-200 active:scale-95 hover:shadow-md"
|
||||
>
|
||||
<Home className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||
<span>{role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'}</span>
|
||||
</Link>
|
||||
<>
|
||||
<header className="sticky top-0 z-30 flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/95 px-6 py-3.5 backdrop-blur-md transition-all shadow-xs">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Left Section: Home/Back Button + Page Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={role === 'admin' ? '/' : '/user'}
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'}
|
||||
className="group flex items-center gap-2 rounded-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white px-4 py-2 text-xs font-semibold shadow-sm transition-all duration-200 active:scale-95 hover:shadow-md"
|
||||
>
|
||||
<Home className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||
<span>{role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'}</span>
|
||||
</Link>
|
||||
|
||||
{title && (
|
||||
<div className="flex items-center gap-2.5 border-l border-ink-900/15 pl-4 py-0.5">
|
||||
{TitleIcon && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-ink-900/5 text-ink-900">
|
||||
<TitleIcon className="h-4 w-4" />
|
||||
{title && (
|
||||
<div className="flex items-center gap-2.5 border-l border-ink-900/15 pl-4 py-0.5">
|
||||
{TitleIcon && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-ink-900/5 text-ink-900">
|
||||
<TitleIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="font-display text-sm font-bold text-ink-950 flex items-center gap-1.5">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-[11px] text-slate-400 font-normal leading-tight">{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="font-display text-sm font-bold text-ink-950 flex items-center gap-1.5">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-[11px] text-slate-400 font-normal leading-tight">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{health && health.status !== 'ok' && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{!health.database && !health.ollama
|
||||
? 'DB & Ollama offline'
|
||||
: !health.database
|
||||
? 'Database offline'
|
||||
: 'Ollama offline'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* User Role Profile Badge */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-2xs">
|
||||
<span className="font-bold text-slate-800">{user?.display_name || user?.username || 'Guest'}</span>
|
||||
{getRoleBadge()}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role-tailored Navigation Tabs */}
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
|
||||
{/* Catalog tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
|
||||
</Link>
|
||||
{/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{health && health.status !== 'ok' && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{!health.database && !health.ollama
|
||||
? 'DB & Ollama offline'
|
||||
: !health.database
|
||||
? 'Database offline'
|
||||
: 'Ollama offline'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* User Upload tab */}
|
||||
<Link
|
||||
to="/user"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/user' && !search.includes('tab=cards') ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<UserCheck className="h-3.5 w-3.5 text-blue-400" /> User Upload
|
||||
</Link>
|
||||
{/* User Role Profile Badge */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-2xs">
|
||||
<span className="font-bold text-slate-800">{user?.display_name || user?.username || 'Guest'}</span>
|
||||
{getRoleBadge()}
|
||||
</div>
|
||||
|
||||
{/* Product Cards tab for User role */}
|
||||
<Link
|
||||
to="/user?tab=cards"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/user' && search.includes('tab=cards') ? 'bg-amber-500 text-slate-950 font-bold shadow-sm' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5 text-amber-600" /> Product Cards
|
||||
</Link>
|
||||
{/* Role-tailored Navigation Tabs */}
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
|
||||
{/* Catalog tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Stores tab */}
|
||||
<Link
|
||||
to="/stores"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/stores' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Store className="h-3.5 w-3.5 text-emerald-400" /> Stores
|
||||
</Link>
|
||||
|
||||
{/* Analytics tab */}
|
||||
<Link
|
||||
to="/analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/analytics' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Analytics
|
||||
</Link>
|
||||
|
||||
{/* Nutrition tab */}
|
||||
<Link
|
||||
to="/nutrition-analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/nutrition-analytics' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
|
||||
</Link>
|
||||
|
||||
{/* MCP tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
{/* User Upload tab */}
|
||||
<Link
|
||||
to="/mcp"
|
||||
to="/user"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/mcp' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
path === '/user' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Plug className="h-3.5 w-3.5" /> MCP
|
||||
<UserCheck className="h-3.5 w-3.5 text-blue-400" /> User Upload
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Admin Panel tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 text-amber-400" /> Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
{/* MCP tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/mcp"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/mcp' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Plug className="h-3.5 w-3.5" /> MCP
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Top Right Logout Button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Logout to Login Page"
|
||||
className="flex items-center gap-1.5 rounded-full bg-rose-600 hover:bg-rose-700 text-white px-3.5 py-1.5 text-xs font-semibold shadow-xs transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
{/* Admin Panel tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 text-amber-400" /> Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</header>
|
||||
{children}
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Logout is pinned to the bottom-right of the viewport rather than sitting
|
||||
in the header row. It is rendered as a sibling of <header>, not inside
|
||||
it: the header creates a stacking/blur context via `sticky` and
|
||||
`backdrop-blur`, and a fixed child of that context is positioned
|
||||
against the header rather than the viewport.
|
||||
|
||||
Icon-only, so the accessible name comes from aria-label - without it the
|
||||
button would be announced as just "button".
|
||||
*/}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Logout"
|
||||
aria-label="Logout"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center justify-center rounded-full bg-rose-600 hover:bg-rose-700 text-white p-4 shadow-lg transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { X, UploadCloud, Download, FileSpreadsheet, CheckCircle2, AlertCircle, Loader2, Table } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function UploadExcelModal({ isOpen, onClose, tabType, tabTitle, onSuccess }) {
|
||||
const [file, setFile] = useState(null);
|
||||
const [previewRows, setPreviewRows] = useState(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const parsePreview = (selectedFile) => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setFile(selectedFile);
|
||||
|
||||
// Generate quick client-side text preview for CSV/TSV/Text
|
||||
if (selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.tsv') || selectedFile.name.endsWith('.txt')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result || '';
|
||||
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
||||
if (lines.length > 0) {
|
||||
const headers = lines[0].split(/,|\t/).map((h) => h.trim().replace(/^["']|["']$/g, ''));
|
||||
const rows = lines.slice(1, 6).map((line) =>
|
||||
line.split(/,|\t/).map((c) => c.trim().replace(/^["']|["']$/g, ''))
|
||||
);
|
||||
setPreviewRows({ headers, rows, totalLines: lines.length - 1 });
|
||||
}
|
||||
};
|
||||
reader.readAsText(selectedFile.slice(0, 1024 * 10)); // read first 10KB
|
||||
} else {
|
||||
// Excel binary preview placeholder
|
||||
setPreviewRows({ headers: ['Excel File Data Detected'], rows: [[selectedFile.name, `${(selectedFile.size / 1024).toFixed(1)} KB`]], totalLines: 'Excel' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
parsePreview(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
parsePreview(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const res = await api.uploadFile(tabType, file);
|
||||
setResult(res);
|
||||
if (onSuccess) onSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to import file. Please check file format.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFile(null);
|
||||
setPreviewRows(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/60 p-4 backdrop-blur-xs" onClick={handleClose}>
|
||||
<div
|
||||
className="relative max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-2xl bg-paper-50 p-6 shadow-2xl transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-ink-900/10 pb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950">
|
||||
Upload {tabTitle || 'Excel / CSV File'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Import `.xlsx`, `.xls`, or `.csv` records into the database
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-full p-1.5 text-slate-400 hover:bg-ink-100 hover:text-ink-900 transition"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Template Download Option */}
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl bg-amber-500/5 p-3.5 border border-amber-500/20">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-amber-900">Need a sample format?</p>
|
||||
<p className="text-amber-700/80">Download pre-formatted CSV template with example columns.</p>
|
||||
</div>
|
||||
<a
|
||||
href={api.getTemplateUrl(tabType)}
|
||||
download
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-amber-700 transition"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span>Sample CSV</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Drag and Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-6 text-center transition-all ${
|
||||
isDragging
|
||||
? 'border-amber-500 bg-amber-500/10 scale-[1.01]'
|
||||
: file
|
||||
? 'border-leaf-500/60 bg-leaf-500/5'
|
||||
: 'border-ink-900/15 bg-white hover:border-amber-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv, .xlsx, .xls, .tsv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 text-amber-600 mb-2">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
{file ? (
|
||||
<div>
|
||||
<p className="font-semibold text-ink-950 text-sm">{file.name}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-mono">
|
||||
{(file.size / 1024).toFixed(1)} KB · Click or drag another file to change
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="font-semibold text-ink-900 text-sm">
|
||||
Click to browse or drag & drop Excel / CSV file
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Supports `.xlsx`, `.xls`, `.csv`, `.tsv`
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Preview Table */}
|
||||
{previewRows && (
|
||||
<div className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3 shadow-xs">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-slate-100">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-ink-900">
|
||||
<Table className="h-3.5 w-3.5 text-amber-600" />
|
||||
Data Preview (First 5 Rows)
|
||||
</span>
|
||||
{typeof previewRows.totalLines === 'number' && (
|
||||
<span className="text-[11px] font-mono text-slate-400">
|
||||
{previewRows.totalLines} total data rows detected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto max-h-40 no-scrollbar">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-100/70 text-slate-600 font-semibold">
|
||||
{previewRows.headers.map((h, idx) => (
|
||||
<th key={idx} className="p-1.5 whitespace-nowrap font-mono">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 font-sans text-ink-800">
|
||||
{previewRows.rows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-slate-50">
|
||||
{row.map((cell, cIdx) => (
|
||||
<td key={cIdx} className="p-1.5 whitespace-nowrap">{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-xl bg-maroon-50 p-3.5 text-xs text-maroon-700 border border-maroon-200">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-maroon-600 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Alert */}
|
||||
{result && (
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-xl bg-leaf-50 p-3.5 text-xs text-leaf-700 border border-leaf-200">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-leaf-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-leaf-900">{result.message}</p>
|
||||
<p className="text-[11px] text-leaf-700/80 mt-0.5">
|
||||
File `{result.filename}` ({result.rows_imported} rows) imported successfully.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex items-center justify-end gap-3 pt-3 border-t border-ink-900/10">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl border border-ink-900/15 bg-white px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-slate-50 transition"
|
||||
>
|
||||
{result ? 'Done' : 'Cancel'}
|
||||
</button>
|
||||
|
||||
{!result && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || loading}
|
||||
className={`flex items-center gap-2 rounded-xl px-5 py-2 text-xs font-semibold text-white shadow-sm transition ${
|
||||
!file || loading
|
||||
? 'bg-slate-300 cursor-not-allowed'
|
||||
: 'bg-amber-600 hover:bg-amber-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Importing Data...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="h-4 w-4" />
|
||||
<span>Import & Process File</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,25 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, Upload, Server } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Wrench, Database } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
pending: { icon: Clock, className: 'text-slate-500 bg-ink-100' },
|
||||
running: { icon: Loader2, className: 'text-amber-600 bg-amber-100', spin: true },
|
||||
done: { icon: CheckCircle2, className: 'text-leaf-600 bg-leaf-100' },
|
||||
failed: { icon: XCircle, className: 'text-maroon-600 bg-maroon-100' },
|
||||
};
|
||||
|
||||
/*
|
||||
* The admin panel is the project overview and nothing else.
|
||||
*
|
||||
* It used to carry three more sub-tabs behind a local `activeTab` switch -
|
||||
* train/test dataset upload, a stock-remaining discount allocator, and a
|
||||
* trigger for the vector ingestion pipeline. They were retired from the UI;
|
||||
* with one view left there is nothing to switch between, so the tab bar and
|
||||
* its state went too.
|
||||
*
|
||||
* Only the UI was removed. The endpoints behind those panels are still
|
||||
* registered and still work - ingestion in particular is reachable from
|
||||
* `python cli/ingest_brand.py "<brand>"` and from POST /api/catalog/generate.
|
||||
*/
|
||||
export function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest'
|
||||
|
||||
// Project details state
|
||||
const [projectDetails, setProjectDetails] = useState(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
|
||||
// Ingestion state
|
||||
const [brand, setBrand] = useState('');
|
||||
const [maxProducts, setMaxProducts] = useState(50);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
// Dataset upload state
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadingDataset, setUploadingDataset] = useState(false);
|
||||
const [datasetResult, setDatasetResult] = useState(null);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
|
||||
// Discount allocation state
|
||||
const [minStock, setMinStock] = useState(0);
|
||||
const [maxStock, setMaxStock] = useState(20);
|
||||
const [discountPct, setDiscountPct] = useState(25);
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocationResult, setAllocationResult] = useState(null);
|
||||
const [allocationError, setAllocationError] = useState(null);
|
||||
|
||||
const fetchProjectDetails = async () => {
|
||||
try {
|
||||
setProjectDetails(await api.getProjectDetails());
|
||||
@@ -49,431 +30,75 @@ export function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// This effect also drove a 3-second poll of ingestion job status; that went
|
||||
// with the ingest panel, leaving a single fetch on mount.
|
||||
useEffect(() => {
|
||||
fetchProjectDetails();
|
||||
|
||||
pollRef.current = setInterval(() => {
|
||||
setJobs((current) => {
|
||||
current
|
||||
.filter((j) => j.status === 'pending' || j.status === 'running')
|
||||
.forEach((j) => {
|
||||
api.getJobStatus(j.job_id).then((updated) => {
|
||||
setJobs((prev) => prev.map((p) => (p.job_id === updated.job_id ? updated : p)));
|
||||
}).catch(() => {});
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, []);
|
||||
|
||||
const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running');
|
||||
|
||||
async function handleSubmitIngestion(e) {
|
||||
e.preventDefault();
|
||||
if (!brand.trim() || runningJob) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const job = await api.generateCatalog({ brand: brand.trim(), max_products: Number(maxProducts) || 50 });
|
||||
setJobs((prev) => [job, ...prev]);
|
||||
setBrand('');
|
||||
} catch (err) {
|
||||
setSubmitError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatasetUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!uploadFile) return;
|
||||
setUploadingDataset(true);
|
||||
setUploadError(null);
|
||||
setDatasetResult(null);
|
||||
|
||||
try {
|
||||
setDatasetResult(await api.uploadTrainingDataset(uploadFile));
|
||||
} catch (err) {
|
||||
setUploadError(err.message);
|
||||
} finally {
|
||||
setUploadingDataset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllocateDiscounts = async (e) => {
|
||||
e.preventDefault();
|
||||
setAllocating(true);
|
||||
setAllocationError(null);
|
||||
setAllocationResult(null);
|
||||
try {
|
||||
const rules = [
|
||||
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
|
||||
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
|
||||
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
|
||||
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
|
||||
];
|
||||
setAllocationResult(await api.allocateDiscounts({ rules }));
|
||||
} catch (err) {
|
||||
// Previously this only acted on res.ok, so a 401 or 403 left the panel
|
||||
// looking idle and gave no clue that nothing had been allocated.
|
||||
setAllocationError(err.message);
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
|
||||
<NavigationHeader
|
||||
title="Admin Intelligence Panel"
|
||||
subtitle="Existing project details, ML train/test dataset uploads & stock discount allocation"
|
||||
subtitle="Brand catalogs, vector database tables & S3 storage overview"
|
||||
icon={Wrench}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-6 py-8 space-y-6">
|
||||
{/* Navigation Admin Tabs */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-900/10 pb-3 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setActiveTab('project')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'project'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Database className="h-4 w-4" /> Project Overview Details
|
||||
</button>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
||||
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
|
||||
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('dataset')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'dataset'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" /> Train & Test Dataset Upload
|
||||
</button>
|
||||
{loadingProject ? (
|
||||
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
|
||||
) : projectDetails ? (
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('discounts')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'discounts'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Percent className="h-4 w-4" /> Stock Remaining Discount Allocator
|
||||
</button>
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('ingest')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'ingest'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Server className="h-4 w-4" /> Vector Ingestion Pipeline
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: EXISTING PROJECT DETAILS */}
|
||||
{activeTab === 'project' && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{projectDetails?.brand_product_counts && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
||||
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
|
||||
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
|
||||
</p>
|
||||
|
||||
{loadingProject ? (
|
||||
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
|
||||
) : projectDetails ? (
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
|
||||
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
|
||||
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
|
||||
<span className="text-xs font-bold text-ink-950">{bName}</span>
|
||||
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projectDetails?.brand_product_counts && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
|
||||
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
|
||||
<span className="text-xs font-bold text-ink-950">{bName}</span>
|
||||
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: UPLOAD TRAIN/TEST DATASET */}
|
||||
{activeTab === 'dataset' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<FileSpreadsheet className="h-5 w-5 text-amber-500" /> Upload Excel / CSV Records for Decision Training
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Upload custom product dataset files (.xlsx, .csv) to train and test decision-making models. Data is automatically split into an 80/20 train/test split for optimization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDatasetUpload} className="flex flex-col sm:flex-row items-center gap-4 border-2 border-dashed border-slate-300 rounded-2xl p-6 bg-slate-50/50">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||
className="text-xs text-slate-600 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!uploadFile || uploadingDataset}
|
||||
className="flex items-center gap-2 rounded-xl bg-ink-900 hover:bg-ink-950 text-white px-5 py-2.5 text-xs font-bold transition disabled:opacity-40 shrink-0"
|
||||
>
|
||||
{uploadingDataset ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Train Decision Model
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{datasetResult && (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-5 space-y-4">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-bold text-sm">
|
||||
<CheckCircle2 className="h-5 w-5" /> {datasetResult.message}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Total Records:</span>
|
||||
<p className="font-mono text-lg font-bold text-ink-950">{datasetResult.total_records}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Training Set (80%):</span>
|
||||
<p className="font-mono text-lg font-bold text-amber-600">{datasetResult.dataset_split.training_records}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Testing Set (20%):</span>
|
||||
<p className="font-mono text-lg font-bold text-blue-600">{datasetResult.dataset_split.testing_records}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */}
|
||||
{activeTab === 'discounts' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<Percent className="h-5 w-5 text-amber-500" /> Allocate Product Discounts Based on Stock Remaining
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAllocateDiscounts} className="grid grid-cols-1 sm:grid-cols-4 gap-4 bg-slate-50 p-4 rounded-xl border border-ink-900/10">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Min Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minStock}
|
||||
onChange={(e) => setMinStock(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Max Stock Remaining</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxStock}
|
||||
onChange={(e) => setMaxStock(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Discount %</label>
|
||||
<input
|
||||
type="number"
|
||||
value={discountPct}
|
||||
onChange={(e) => setDiscountPct(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={allocating}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 py-2.5 text-xs font-bold shadow-sm transition"
|
||||
>
|
||||
{allocating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Percent className="h-4 w-4" />}
|
||||
Allocate Discounts
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{allocationError && (
|
||||
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||||
{allocationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allocationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<span className="font-bold text-ink-950">Applied Discount Allocations ({allocationResult.total_products_allocated} products)</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto border border-ink-900/10 rounded-xl bg-white divide-y divide-slate-100">
|
||||
{allocationResult.allocations.map((a, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 text-xs">
|
||||
<div>
|
||||
<p className="font-bold text-ink-950">{a.product_name}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono">Store: {a.store_id} · Stock Remaining: {a.stock_remaining}</p>
|
||||
</div>
|
||||
<div className="text-right font-mono">
|
||||
<span className="inline-block bg-emerald-100 text-emerald-800 font-bold px-2 py-0.5 rounded text-[11px] mr-2">
|
||||
{a.discount_pct}% OFF
|
||||
</span>
|
||||
<span className="text-slate-400 line-through mr-1">₹{a.original_price}</span>
|
||||
<span className="font-bold text-leaf-600 text-sm">₹{a.final_price}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: INGESTION PIPELINE */}
|
||||
{activeTab === 'ingest' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<Server className="h-5 w-5 text-amber-500" /> Vector Database Catalog Ingestion
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Discover and store brand products into pgvector (discovery → images → embeddings).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmitIngestion} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-800">Brand name</label>
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="e.g. Britannia"
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-800">Max products</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={maxProducts}
|
||||
onChange={(e) => setMaxProducts(e.target.value)}
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !brand.trim() || !!runningJob}
|
||||
className="flex items-center justify-center gap-1.5 rounded-xl bg-ink-900 px-5 py-2.5 text-xs font-bold text-white transition hover:bg-ink-950 disabled:opacity-40"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" /> Start Ingestion
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* handleSubmitIngestion has always captured this, but nothing
|
||||
rendered it, so a rejected ingestion looked like nothing had
|
||||
happened. It matters more now that the endpoint can answer 401
|
||||
(session expired) or 403 (not an admin). */}
|
||||
{submitError && (
|
||||
<p className="rounded-xl border border-maroon-600/30 bg-maroon-100/60 p-2.5 text-xs font-medium text-maroon-600">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{runningJob && (
|
||||
<p className="text-xs text-amber-600">
|
||||
A job for "{runningJob.brand}" is already in progress - please wait.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 className="font-display text-xs font-semibold uppercase tracking-wide text-slate-500 mt-6">Recent jobs</h3>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No ingestion jobs started yet this session.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{jobs.map((j) => {
|
||||
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<li key={j.job_id} className="flex items-center gap-3 rounded-xl border border-ink-900/10 bg-white px-4 py-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
|
||||
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-bold text-ink-950">{j.brand}</p>
|
||||
<p className="text-[11px] text-slate-500">{j.detail || j.status}</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis,
|
||||
Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
import { Flame, Trophy, TrendingUp, Award, BarChart3 as BarChartIcon, UploadCloud } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [comparison, setComparison] = useState(null);
|
||||
const [comparisonLoading, setComparisonLoading] = useState(true);
|
||||
|
||||
const [trending, setTrending] = useState([]);
|
||||
const [trendingLoading, setTrendingLoading] = useState(true);
|
||||
const [window_, setWindow] = useState('weekly');
|
||||
|
||||
const [topProducts, setTopProducts] = useState([]);
|
||||
const [topBy, setTopBy] = useState('revenue');
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
|
||||
const reloadData = useCallback(() => {
|
||||
setComparisonLoading(true);
|
||||
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
|
||||
setTrendingLoading(true);
|
||||
api.getTrending({ window: window_, scope: 'overall', top_k: 10 }).then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
|
||||
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
|
||||
}, [window_, topBy]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTrendingLoading(true);
|
||||
api.getTrending({ window: window_, scope: 'overall', top_k: 10 })
|
||||
.then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
|
||||
}, [window_]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
|
||||
}, [topBy]);
|
||||
|
||||
const stores = comparison?.stores || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<NavigationHeader title="Chain Analytics" subtitle="Store comparison, trending products & top sellers" icon={BarChartIcon}>
|
||||
<div className="flex items-center justify-end px-6 pb-1 -mt-2">
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Sales Analytics (Excel / CSV)</span>
|
||||
</button>
|
||||
</div>
|
||||
</NavigationHeader>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
{comparisonLoading ? (
|
||||
<Spinner label="Loading chain analytics…" />
|
||||
) : !comparison || stores.length === 0 ? (
|
||||
<EmptyState icon={TrendingUp} title="No analytics yet" subtitle="Seed and train the store intelligence models first (see /stores page)." />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Revenue by store" icon={Trophy}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={stores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(v) => `₹${Number(v).toLocaleString('en-IN')}`} />
|
||||
<Bar dataKey="revenue" fill="#e2a33d" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Profit by store" icon={Award}>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={stores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(v) => `₹${Number(v).toLocaleString('en-IN')}`} />
|
||||
<Bar dataKey="profit" fill="#3f8556" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Orders vs. average order value" icon={TrendingUp}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={stores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="order_count" name="Orders" stroke="#2c3e63" strokeWidth={2} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="avg_order_value" name="Avg order value (₹)" stroke="#c2872a" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Simulated footfall share" icon={Flame}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={stores} dataKey="footfall_simulated" nameKey="store_id" outerRadius={90} label={(e) => e.store_id}>
|
||||
{stores.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<Badge label="Best performing" value={comparison.best_performing} />
|
||||
<Badge label="Highest revenue" value={comparison.highest_revenue} />
|
||||
<Badge label="Highest profit" value={comparison.highest_profit} />
|
||||
<Badge label="Lowest performing" value={comparison.lowest_performing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Flame className="h-4 w-4 text-amber-500" /> Trending products
|
||||
</h3>
|
||||
<div className="flex gap-1 rounded-full bg-ink-100 p-0.5 text-xs">
|
||||
{['today', 'weekly', 'monthly'].map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setWindow(w)}
|
||||
className={`rounded-full px-2.5 py-1 capitalize transition ${window_ === w ? 'bg-paper-50 font-medium text-ink-950 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{trendingLoading ? (
|
||||
<Spinner label="Scoring products…" />
|
||||
) : trending.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">No trending data yet - train the trending model first.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{trending.map((t) => (
|
||||
<li key={`${t.brand}-${t.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{t.rank}</span>
|
||||
<span className="truncate text-ink-900">{prettify(t.image_id)}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{t.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
|
||||
{t.trend_score.toFixed(1)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Trophy className="h-4 w-4 text-leaf-600" /> Top products
|
||||
</h3>
|
||||
<div className="flex gap-1 rounded-full bg-ink-100 p-0.5 text-xs">
|
||||
{['revenue', 'units'].map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setTopBy(b)}
|
||||
className={`rounded-full px-2.5 py-1 capitalize transition ${topBy === b ? 'bg-paper-50 font-medium text-ink-950 shadow-sm' : 'text-slate-500'}`}
|
||||
>
|
||||
by {b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{topProducts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">No sales data yet.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{topProducts.map((p, i) => (
|
||||
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-leaf-100/40">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
|
||||
<span className="truncate text-ink-900">{prettify(p.image_id)}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-700">
|
||||
{topBy === 'revenue' ? `₹${Number(p.revenue).toLocaleString('en-IN')}` : `${p.units_sold} units`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="analytics"
|
||||
tabTitle="Sales Analytics Data"
|
||||
onSuccess={() => reloadData()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-amber-500" /> {title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ label, value }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<span className="rounded-full border border-ink-900/10 bg-white px-3 py-1">
|
||||
<span className="text-slate-400">{label}:</span> <span className="font-medium text-ink-900">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function prettify(imageId) {
|
||||
if (!imageId) return '';
|
||||
const parts = imageId.split('_').filter((p, i, arr) => !(i > 0 && arr[i - 1] === p));
|
||||
return parts.join(' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { ShieldCheck, UserCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { ShieldCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
|
||||
|
||||
/*
|
||||
* There is exactly one interactive account: `admin`.
|
||||
*
|
||||
* This screen used to offer an Admin/User tab pair, which was always more
|
||||
* suggestion than control - the server assigns the role from the account the
|
||||
* password belongs to (see AuthContext.login), so the tab never affected the
|
||||
* outcome. With the `user` account switched off in the backend config, the
|
||||
* tabs would have offered a sign-in that could only ever fail, so they are gone
|
||||
* and this is a single admin form.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState('admin');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRoleSelect = (role) => {
|
||||
setSelectedRole(role);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
@@ -31,8 +35,8 @@ export function LoginPage() {
|
||||
try {
|
||||
const user = await login(username, password);
|
||||
setLoading(false);
|
||||
// Route by the role the SERVER assigned, not by the tab that was
|
||||
// selected. The tab only labels which account you are signing in to.
|
||||
// Route by the role the SERVER assigned. Only `admin` can sign in now,
|
||||
// but a token minted for any other role still lands somewhere valid.
|
||||
if (user.role === 'admin') navigate('/admin');
|
||||
else navigate('/user');
|
||||
} catch (err) {
|
||||
@@ -44,52 +48,30 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-slate-950 text-slate-100 flex items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||
{/* Background Glow Accents */}
|
||||
<div className="absolute -top-40 -left-40 h-96 w-96 rounded-full bg-amber-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="min-h-screen w-full bg-white text-ink-950 flex items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||
{/* Background Glow Accents - warmed up to read against a white ground */}
|
||||
<div className="absolute -top-40 -left-40 h-96 w-96 rounded-full bg-amber-400/25 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-leaf-500/15 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
{/* Header Branding */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 text-xs font-semibold mb-3">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Brand Catalog & Store Intelligence
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-amber-500/15 border border-amber-500/40 text-amber-600 text-xs font-semibold mb-3">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Brand Catalog & Store Intelligence
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white font-display">System Authentication</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">Please enter your username and password to sign in</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-ink-950 font-display">System Authentication</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Please enter your username and password to sign in</p>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/80 p-6 backdrop-blur-xl shadow-2xl">
|
||||
{/* Role Selection Tabs: Admin and User */}
|
||||
<div className="grid grid-cols-2 gap-2 p-1.5 rounded-xl bg-slate-950/80 border border-slate-800/80 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleSelect('admin')}
|
||||
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
|
||||
selectedRole === 'admin'
|
||||
? 'bg-amber-500 text-slate-950 shadow-md scale-[1.02]'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" /> Admin Account
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleSelect('user')}
|
||||
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
|
||||
selectedRole === 'user'
|
||||
? 'bg-blue-500 text-white shadow-md scale-[1.02]'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<UserCheck className="h-4 w-4" /> User Account
|
||||
</button>
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-lg">
|
||||
{/* Single account banner - replaces the old Admin/User tab pair */}
|
||||
<div className="flex items-center justify-center gap-2 py-3 px-4 mb-6 rounded-xl bg-amber-500/10 border border-amber-500/30 text-xs font-bold text-amber-700">
|
||||
<ShieldCheck className="h-4 w-4" /> Administrator Sign-In
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400">
|
||||
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -97,31 +79,31 @@ export function LoginPage() {
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1.5">Username</label>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1.5">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={`Enter ${selectedRole} username`}
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||
placeholder="Enter admin username"
|
||||
className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-4 text-sm text-ink-950 placeholder-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1.5">Password</label>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||
className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-4 text-sm text-ink-950 placeholder-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,9 +111,9 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-slate-950 py-3 text-sm font-bold shadow-lg shadow-amber-500/20 transition-all duration-200 active:scale-98 disabled:opacity-50"
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white py-3 text-sm font-bold shadow-md shadow-amber-500/25 transition-all duration-200 active:scale-98 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{loading ? 'Authenticating...' : `Sign In as ${selectedRole.toUpperCase()}`}
|
||||
{loading ? 'Authenticating...' : 'Sign In as ADMIN'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
@@ -139,7 +121,7 @@ export function LoginPage() {
|
||||
|
||||
{/* Footer info */}
|
||||
<p className="text-center text-xs text-slate-500 mt-6">
|
||||
Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine
|
||||
Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis,
|
||||
Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
|
||||
|
||||
export function NutritionAnalyticsPage() {
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [job, setJob] = useState(null);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api.getNutritionAnalyticsDashboard(8).then(setDashboard).catch(() => setDashboard(null)).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || job.status === 'done' || job.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (job?.status === 'done') load();
|
||||
return;
|
||||
}
|
||||
pollRef.current = setInterval(() => {
|
||||
api.getNutritionIntelligenceJob(job.job_id).then(setJob).catch(() => {});
|
||||
}, 2000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [job]);
|
||||
|
||||
const runEnrich = () => api.enrichNutrition({}).then(setJob);
|
||||
const runTrain = () => api.trainNutritionModels().then(setJob);
|
||||
|
||||
const distribution = dashboard?.distribution;
|
||||
const enrichmentCounts = distribution?.enrichment_status || {};
|
||||
const totalTracked = Object.values(enrichmentCounts).reduce((a, b) => a + b, 0);
|
||||
const hasData = totalTracked > 0;
|
||||
|
||||
const distributionData = distribution?.health_score_distribution
|
||||
? Object.entries(distribution.health_score_distribution).map(([bucket, count]) => ({ bucket, count }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<NavigationHeader
|
||||
title="Nutrition Intelligence"
|
||||
subtitle="Verified nutrition data, health scores & leaderboards across the catalog"
|
||||
icon={HeartPulse}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div>
|
||||
<h3 className="font-display text-sm font-semibold text-ink-950">Nutrition data pipeline</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{hasData
|
||||
? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products`
|
||||
: 'No products enriched yet - run enrichment or upload Excel/CSV data.'}
|
||||
</p>
|
||||
{job && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
{job.kind === 'enrich' ? 'Enrichment' : 'Training'} {job.status}
|
||||
{job.total ? ` (${job.processed}/${job.total})` : ''}
|
||||
{job.detail ? ` - ${job.detail}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Nutrition (Excel / CSV)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={runEnrich}
|
||||
disabled={job && job.status === 'running'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-3 py-1.5 text-xs font-semibold text-paper-100 transition hover:bg-ink-800 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${job?.kind === 'enrich' && job.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
Auto Enrich OFF
|
||||
</button>
|
||||
<button
|
||||
onClick={runTrain}
|
||||
disabled={job && job.status === 'running'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-leaf-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-leaf-700 disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Train Similarity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner label="Loading nutrition analytics…" />
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={HeartPulse}
|
||||
title="No nutrition analytics yet"
|
||||
subtitle="Run enrichment above (or `python scripts/enrich_nutrition.py`), then training, to populate this dashboard."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Healthiest brands" icon={Award}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dashboard.rankings.healthiest_brands}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="brand" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg_health_score" fill="#3f8556" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Healthiest categories" icon={Apple}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dashboard.rankings.healthiest_categories}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="category" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg_health_score" fill="#e2a33d" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{distributionData.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ChartCard title="Health score distribution" icon={HeartPulse}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={distributionData} dataKey="count" nameKey="bucket" outerRadius={85} label={(e) => e.bucket}>
|
||||
{distributionData.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<Leaderboard title="Highest protein" icon={Beef} items={dashboard.leaderboards.highest_protein} field="protein_g" unit="g" />
|
||||
<Leaderboard title="Highest fiber" icon={Wheat} items={dashboard.leaderboards.highest_fiber} field="dietary_fiber_g" unit="g" />
|
||||
<Leaderboard title="Lowest sugar" icon={Droplet} items={dashboard.leaderboards.lowest_sugar} field="total_sugar_g" unit="g" />
|
||||
<Leaderboard title="Lowest sodium" icon={Flame} items={dashboard.leaderboards.lowest_sodium} field="sodium_mg" unit="mg" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="nutrition"
|
||||
tabTitle="Nutritional Intelligence Facts"
|
||||
onSuccess={() => load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-amber-500" /> {title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Leaderboard({ title, icon: Icon, items = [], field, unit }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-3 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-leaf-600" /> {title}
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-slate-400">No data yet.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{items.map((p, i) => (
|
||||
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
|
||||
<span className="truncate text-ink-900">{p.product_name || p.image_id}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
|
||||
{p[field] != null ? `${Number(p[field]).toFixed(1)} ${unit}` : '–'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
|
||||
PackageSearch, BarChart3, Search, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const STOCK_STATUS_STYLES = {
|
||||
'In Stock': 'bg-leaf-100 text-leaf-600',
|
||||
'Low Stock': 'bg-amber-100 text-amber-600',
|
||||
'Out of Stock': 'bg-maroon-100 text-maroon-600',
|
||||
'Overstocked': 'bg-ink-100 text-ink-700',
|
||||
};
|
||||
|
||||
export function StoresPage() {
|
||||
const [stores, setStores] = useState([]);
|
||||
const [storesLoading, setStoresLoading] = useState(true);
|
||||
const [selectedStore, setSelectedStore] = useState(null);
|
||||
|
||||
const [products, setProducts] = useState([]);
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [dashboardLoading, setDashboardLoading] = useState(false);
|
||||
const [discounts, setDiscounts] = useState({}); // keyed by brand||image_id
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [inStockOnly, setInStockOnly] = useState(false);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStores()
|
||||
.then((res) => {
|
||||
setStores(res);
|
||||
if (res.length > 0) setSelectedStore(res[0].store_id);
|
||||
})
|
||||
.catch(() => setStores([]))
|
||||
.finally(() => setStoresLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadStoreData = useCallback((storeId) => {
|
||||
if (!storeId) return;
|
||||
setProductsLoading(true);
|
||||
setDashboardLoading(true);
|
||||
api.getStoreProducts(storeId, { in_stock_only: inStockOnly })
|
||||
.then(setProducts)
|
||||
.catch(() => setProducts([]))
|
||||
.finally(() => setProductsLoading(false));
|
||||
api.getStoreAnalytics(storeId)
|
||||
.then(setDashboard)
|
||||
.catch(() => setDashboard(null))
|
||||
.finally(() => setDashboardLoading(false));
|
||||
api.getStoreDiscounts(storeId)
|
||||
.then((rows) => {
|
||||
const map = {};
|
||||
rows.forEach((r) => { map[`${r.brand}||${r.image_id}`] = r; });
|
||||
setDiscounts(map);
|
||||
})
|
||||
.catch(() => setDiscounts({}));
|
||||
}, [inStockOnly]);
|
||||
|
||||
useEffect(() => { loadStoreData(selectedStore); }, [selectedStore, loadStoreData]);
|
||||
|
||||
const filteredProducts = products.filter((p) =>
|
||||
!search || (p.title || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-paper-100">
|
||||
<NavigationHeader title="Store Intelligence" subtitle="Multi-store inventory, pricing & ML-based discounts" icon={StoreIcon} />
|
||||
|
||||
{storesLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center"><Spinner label="Loading stores…" /></div>
|
||||
) : stores.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<EmptyState
|
||||
icon={StoreIcon}
|
||||
title="No stores set up yet"
|
||||
subtitle="Run scripts/seed_store_intelligence.py (or POST /api/admin/store-intelligence/seed) to provision the 5 stores and simulate order history, then scripts/train_ml_models.py to train the discount/trending/recommendation models."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex gap-2 overflow-x-auto border-b border-ink-900/10 bg-paper-50 px-6 py-3">
|
||||
{stores.map((s) => (
|
||||
<button
|
||||
key={s.store_id}
|
||||
onClick={() => setSelectedStore(s.store_id)}
|
||||
className={`flex shrink-0 items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||
selectedStore === s.store_id ? 'bg-ink-900 text-paper-100' : 'bg-ink-100 text-ink-700 hover:bg-ink-100/70'
|
||||
}`}
|
||||
>
|
||||
<StoreIcon className="h-3.5 w-3.5" />
|
||||
{s.store_name}
|
||||
<span className="rounded-full bg-black/10 px-1.5 py-0.5 text-[10px] uppercase">{s.tier}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{dashboardLoading ? (
|
||||
<Spinner label="Loading store analytics…" className="mb-4" />
|
||||
) : dashboard ? (
|
||||
<KpiRow dashboard={dashboard} />
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-950">Products in this store</h2>
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter products…"
|
||||
className="rounded-full border border-ink-900/10 bg-white py-1.5 pl-8 pr-3 text-sm focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-500 mr-2">
|
||||
<input type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} />
|
||||
In stock only
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Excel / CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productsLoading ? (
|
||||
<Spinner label="Loading products…" className="mt-4" />
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<EmptyState icon={PackageSearch} title="No products found" subtitle="Try clearing filters." />
|
||||
) : (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-ink-900/10 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-ink-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">Product</th>
|
||||
<th className="px-4 py-2.5">Category</th>
|
||||
<th className="px-4 py-2.5">Stock</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">MRP</th>
|
||||
<th className="px-4 py-2.5">Selling Price</th>
|
||||
<th className="px-4 py-2.5">Discount</th>
|
||||
<th className="px-4 py-2.5">Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-900/5">
|
||||
{filteredProducts.map((p) => {
|
||||
const d = discounts[`${p.brand}||${p.image_id}`];
|
||||
return (
|
||||
<tr key={`${p.brand}-${p.image_id}`} className="hover:bg-amber-50/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-ink-900">{p.title}</p>
|
||||
<p className="text-xs text-slate-400">{p.brand}{p.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-500">{p.category}</td>
|
||||
<td className="px-4 py-2.5 font-mono">{p.available_stock}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STOCK_STATUS_STYLES[p.stock_status] || 'bg-ink-100 text-ink-700'}`}>
|
||||
{p.stock_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-slate-500 line-through decoration-slate-300">₹{p.mrp}</td>
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-ink-950">₹{p.selling_price}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{d && d.discount_pct > 0 ? (
|
||||
<span className="rounded-full bg-maroon-100 px-2 py-0.5 text-[11px] font-medium text-maroon-600">
|
||||
-{d.discount_pct}% → ₹{d.final_price}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-leaf-600">{p.gross_profit_pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="stores"
|
||||
tabTitle="Store Inventory & Pricing"
|
||||
onSuccess={() => loadStoreData(selectedStore)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiRow({ dashboard }) {
|
||||
const { sales, profit, inventory } = dashboard;
|
||||
const TONE_TEXT = { ink: 'text-ink-600', leaf: 'text-leaf-600', maroon: 'text-maroon-600' };
|
||||
const cards = [
|
||||
{ icon: IndianRupee, label: 'Revenue', value: `₹${Number(sales.revenue).toLocaleString('en-IN')}`, tone: 'ink' },
|
||||
{ icon: TrendingUp, label: 'Profit', value: `₹${Number(profit.total_profit).toLocaleString('en-IN')}`, sub: `${profit.gross_profit_pct}% GP`, tone: 'leaf' },
|
||||
{ icon: BarChart3, label: 'Orders', value: sales.total_sales.toLocaleString('en-IN'), sub: `₹${sales.average_basket_value} avg basket`, tone: 'ink' },
|
||||
{ icon: PackageCheck, label: 'In Stock', value: inventory.in_stock, sub: `${inventory.total_products} products total`, tone: 'leaf' },
|
||||
{ icon: PackageX, label: 'Out of Stock', value: inventory.out_of_stock, sub: `${inventory.low_stock} running low`, tone: 'maroon' },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
<c.icon className="h-3.5 w-3.5" /> {c.label}
|
||||
</div>
|
||||
<p className={`mt-1.5 font-display text-xl font-bold ${TONE_TEXT[c.tone]}`}>{c.value}</p>
|
||||
{c.sub && <p className="text-xs text-slate-400">{c.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user