From e05d4d6e2b9bf19e3b8f691717b8db223e58e995 Mon Sep 17 00:00:00 2001 From: sriram Date: Tue, 18 Aug 2026 12:17:57 +0530 Subject: [PATCH] frontend changes in webpage --- public/favicon.svg | 27 +- src/App.jsx | 46 +- src/components/NavigationHeader.jsx | 254 +++--- src/components/UploadExcelModal.jsx | 276 ------ src/pages/AdminPage.jsx | 505 ++--------- src/pages/AnalyticsPage.jsx | 248 ------ src/pages/LoginPage.jsx | 90 +- src/pages/NutritionAnalyticsPage.jsx | 215 ----- src/pages/StoresPage.jsx | 228 ----- src/pages/UserPage.jsx | 1218 ++++++++------------------ 10 files changed, 632 insertions(+), 2475 deletions(-) delete mode 100644 src/components/UploadExcelModal.jsx delete mode 100644 src/pages/AnalyticsPage.jsx delete mode 100644 src/pages/NutritionAnalyticsPage.jsx delete mode 100644 src/pages/StoresPage.jsx diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..338d97c 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,26 @@ - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/src/App.jsx b/src/App.jsx index 46042ec..2b8ae00 100644 --- a/src/App.jsx +++ b/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 */} - - - - } - /> - - {/* Analytics Route */} - - - - } - /> - - {/* Nutrition Intelligence Route */} - - - - } - /> - {/* MCP Server Route - RESTRICTED TO ADMIN ONLY */} -
- {/* Left Section: Home/Back Button + Page Title */} -
- { - 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" - > - - {role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'} - + <> +
+
+ {/* Left Section: Home/Back Button + Page Title */} +
+ { + 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" + > + + {role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'} + - {title && ( -
- {TitleIcon && ( -
- + {title && ( +
+ {TitleIcon && ( +
+ +
+ )} +
+

+ {title} +

+ {subtitle &&

{subtitle}

}
- )} -
-

- {title} -

- {subtitle &&

{subtitle}

}
-
- )} -
- - {/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */} -
- {health && health.status !== 'ok' && ( - - - {!health.database && !health.ollama - ? 'DB & Ollama offline' - : !health.database - ? 'Database offline' - : 'Ollama offline'} - - )} - - {/* User Role Profile Badge */} -
- {user?.display_name || user?.username || 'Guest'} - {getRoleBadge()} + )}
- {/* Role-tailored Navigation Tabs */} - +
-
- {children} -
+ {children} + + + {/* + Logout is pinned to the bottom-right of the viewport rather than sitting + in the header row. It is rendered as a sibling of
, 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". + */} + + ); } diff --git a/src/components/UploadExcelModal.jsx b/src/components/UploadExcelModal.jsx deleted file mode 100644 index 1293c2b..0000000 --- a/src/components/UploadExcelModal.jsx +++ /dev/null @@ -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 ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
-
- -
-
-

- Upload {tabTitle || 'Excel / CSV File'} -

-

- Import `.xlsx`, `.xls`, or `.csv` records into the database -

-
-
- -
- - {/* Template Download Option */} -
-
-

Need a sample format?

-

Download pre-formatted CSV template with example columns.

-
- - - Sample CSV - -
- - {/* Drag and Drop Zone */} -
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' - }`} - > - - -
- -
- - {file ? ( -
-

{file.name}

-

- {(file.size / 1024).toFixed(1)} KB · Click or drag another file to change -

-
- ) : ( -
-

- Click to browse or drag & drop Excel / CSV file -

-

- Supports `.xlsx`, `.xls`, `.csv`, `.tsv` -

-
- )} -
- - {/* File Preview Table */} - {previewRows && ( -
-
- - - Data Preview (First 5 Rows) - - {typeof previewRows.totalLines === 'number' && ( - - {previewRows.totalLines} total data rows detected - - )} - - -
-
- - - {previewRows.headers.map((h, idx) => ( - - ))} - - - - {previewRows.rows.map((row, rIdx) => ( - - {row.map((cell, cIdx) => ( - - ))} - - ))} - -
{h}
{cell}
-
-
- )} - - {/* Error Alert */} - {error && ( -
- - {error} -
- )} - - {/* Success Alert */} - {result && ( -
- -
-

{result.message}

-

- File `{result.filename}` ({result.rows_imported} rows) imported successfully. -

-
-
- )} - - {/* Action Buttons */} -
- - - {!result && ( - - )} -
-
-
- ); -} diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index f9af1e6..8692a45 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -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 ""` 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 (
- {/* Navigation Admin Tabs */} -
- +
+
+

+ Existing Project Details & System Architecture +

+

+ High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models. +

- + {loadingProject ? ( +
Loading project details...
+ ) : projectDetails ? ( +
+
+

Total Brands

+

{projectDetails.total_brands}

+

Ingested brand tables in pgvector

+
- +
+

Total Products

+

{projectDetails.total_products}

+

Unique products with vector embeddings

+
- -
+
+

S3 Storage Status

+

{projectDetails.s3_image_status}

+

Bucket: {projectDetails.storage_bucket}

+
- {/* TAB 1: EXISTING PROJECT DETAILS */} - {activeTab === 'project' && ( -
+
+

System Version

+

{projectDetails.version}

+

Multi-Store & RAG Engine

+
+
+ ) : null} +
+ + {projectDetails?.brand_product_counts && (
-

- Existing Project Details & System Architecture -

-

- High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models. -

- - {loadingProject ? ( -
Loading project details...
- ) : projectDetails ? ( -
-
-

Total Brands

-

{projectDetails.total_brands}

-

Ingested brand tables in pgvector

+

Ingested Brand Database Breakdown

+
+ {Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => ( +
+ {bName} + {count} items
- -
-

Total Products

-

{projectDetails.total_products}

-

Unique products with vector embeddings

-
- -
-

S3 Storage Status

-

{projectDetails.s3_image_status}

-

Bucket: {projectDetails.storage_bucket}

-
- -
-

System Version

-

{projectDetails.version}

-

Multi-Store & RAG Engine

-
-
- ) : null} + ))} +
- - {projectDetails?.brand_product_counts && ( -
-

Ingested Brand Database Breakdown

-
- {Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => ( -
- {bName} - {count} items -
- ))} -
-
- )} -
- )} - - {/* TAB 2: UPLOAD TRAIN/TEST DATASET */} - {activeTab === 'dataset' && ( -
-
-

- Upload Excel / CSV Records for Decision Training -

-

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

-
- - {uploadError && ( -
- {uploadError} -
- )} - -
- 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" - /> - - -
- - {datasetResult && ( -
-
- {datasetResult.message} -
- -
-
- Total Records: -

{datasetResult.total_records}

-
- -
- Training Set (80%): -

{datasetResult.dataset_split.training_records}

-
- -
- Testing Set (20%): -

{datasetResult.dataset_split.testing_records}

-
-
-
- )} -
- )} - - {/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */} - {activeTab === 'discounts' && ( -
-
-

- Allocate Product Discounts Based on Stock Remaining -

-

- Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels. -

-
- -
-
- - setMinStock(e.target.value)} - className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium" - /> -
- -
- - setMaxStock(e.target.value)} - className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium" - /> -
- -
- - setDiscountPct(e.target.value)} - className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium" - /> -
- -
- -
-
- - {allocationError && ( -
- {allocationError} -
- )} - - {allocationResult && ( -
-
- Applied Discount Allocations ({allocationResult.total_products_allocated} products) -
- -
- {allocationResult.allocations.map((a, i) => ( -
-
-

{a.product_name}

-

Store: {a.store_id} · Stock Remaining: {a.stock_remaining}

-
-
- - {a.discount_pct}% OFF - - ₹{a.original_price} - ₹{a.final_price} -
-
- ))} -
-
- )} -
- )} - - {/* TAB 4: INGESTION PIPELINE */} - {activeTab === 'ingest' && ( -
-
-

- Vector Database Catalog Ingestion -

-

- Discover and store brand products into pgvector (discovery → images → embeddings). -

-
- -
-
- - 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" - /> -
-
- - 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" - /> -
- -
- - {/* 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 && ( -

- {submitError} -

- )} - - {runningJob && ( -

- A job for "{runningJob.brand}" is already in progress - please wait. -

- )} - -

Recent jobs

- {jobs.length === 0 ? ( -

No ingestion jobs started yet this session.

- ) : ( -
    - {jobs.map((j) => { - const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending; - const Icon = style.icon; - return ( -
  • - - - -
    -

    {j.brand}

    -

    {j.detail || j.status}

    -
    - {j.status} -
  • - ); - })} -
- )} -
- )} + )} +
); diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx deleted file mode 100644 index 1b5d65f..0000000 --- a/src/pages/AnalyticsPage.jsx +++ /dev/null @@ -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 ( -
- -
- -
-
- -
- {comparisonLoading ? ( - - ) : !comparison || stores.length === 0 ? ( - - ) : ( - <> -
- - - - - - - `₹${Number(v).toLocaleString('en-IN')}`} /> - - - - - - - - - - - - `₹${Number(v).toLocaleString('en-IN')}`} /> - - - - -
- -
- - - - - - - - - - - - - - - - - - - e.store_id}> - {stores.map((_, i) => )} - - - - - -
- -
- - - - -
- - )} - -
-
-
-

- Trending products -

-
- {['today', 'weekly', 'monthly'].map((w) => ( - - ))} -
-
- {trendingLoading ? ( - - ) : trending.length === 0 ? ( -

No trending data yet - train the trending model first.

- ) : ( -
    - {trending.map((t) => ( -
  1. - - #{t.rank} - {prettify(t.image_id)} - {t.brand} - - - {t.trend_score.toFixed(1)} - -
  2. - ))} -
- )} -
- -
-
-

- Top products -

-
- {['revenue', 'units'].map((b) => ( - - ))} -
-
- {topProducts.length === 0 ? ( -

No sales data yet.

- ) : ( -
    - {topProducts.map((p, i) => ( -
  1. - - #{i + 1} - {prettify(p.image_id)} - {p.brand} - - - {topBy === 'revenue' ? `₹${Number(p.revenue).toLocaleString('en-IN')}` : `${p.units_sold} units`} - -
  2. - ))} -
- )} -
-
-
- - setIsUploadOpen(false)} - tabType="analytics" - tabTitle="Sales Analytics Data" - onSuccess={() => reloadData()} - /> -
- ); -} - -function ChartCard({ title, icon: Icon, children }) { - return ( -
-

- {title} -

- {children} -
- ); -} - -function Badge({ label, value }) { - if (!value) return null; - return ( - - {label}: {value} - - ); -} - -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()); -} diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index e9caef3..9ef9a99 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -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 ( -
- {/* Background Glow Accents */} -
-
+
+ {/* Background Glow Accents - warmed up to read against a white ground */} +
+
{/* Header Branding */}
-
- Brand Catalog & Store Intelligence +
+ Brand Catalog & Store Intelligence
-

System Authentication

-

Please enter your username and password to sign in

+

System Authentication

+

Please enter your username and password to sign in

{/* Main Card */} -
- {/* Role Selection Tabs: Admin and User */} -
- - - +
+ {/* Single account banner - replaces the old Admin/User tab pair */} +
+ Administrator Sign-In
{error && ( -
+
{error}
)} @@ -97,31 +79,31 @@ export function LoginPage() { {/* Login Form */}
- +
- + 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" />
- +
- + 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" />
@@ -129,9 +111,9 @@ export function LoginPage() {
@@ -139,7 +121,7 @@ export function LoginPage() { {/* Footer info */}

- Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine + Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine

diff --git a/src/pages/NutritionAnalyticsPage.jsx b/src/pages/NutritionAnalyticsPage.jsx deleted file mode 100644 index aa7ab4e..0000000 --- a/src/pages/NutritionAnalyticsPage.jsx +++ /dev/null @@ -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 ( -
- - -
-
-
-

Nutrition data pipeline

-

- {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.'} -

- {job && ( -

- {job.kind === 'enrich' ? 'Enrichment' : 'Training'} {job.status} - {job.total ? ` (${job.processed}/${job.total})` : ''} - {job.detail ? ` - ${job.detail}` : ''} -

- )} -
-
- - - -
-
- - {loading ? ( - - ) : !hasData ? ( - - ) : ( - <> -
- - - - - - - - - - - - - - - - - - - - - - - -
- - {distributionData.length > 0 && ( -
- - - - e.bucket}> - {distributionData.map((_, i) => )} - - - - - -
- )} - -
- - - - -
- - )} -
- - setIsUploadOpen(false)} - tabType="nutrition" - tabTitle="Nutritional Intelligence Facts" - onSuccess={() => load()} - /> -
- ); -} - -function ChartCard({ title, icon: Icon, children }) { - return ( -
-

- {title} -

- {children} -
- ); -} - -function Leaderboard({ title, icon: Icon, items = [], field, unit }) { - return ( -
-

- {title} -

- {items.length === 0 ? ( -

No data yet.

- ) : ( -
    - {items.map((p, i) => ( -
  1. - - #{i + 1} - {p.product_name || p.image_id} - {p.brand} - - - {p[field] != null ? `${Number(p[field]).toFixed(1)} ${unit}` : '–'} - -
  2. - ))} -
- )} -
- ); -} diff --git a/src/pages/StoresPage.jsx b/src/pages/StoresPage.jsx deleted file mode 100644 index 713f1c3..0000000 --- a/src/pages/StoresPage.jsx +++ /dev/null @@ -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 ( -
- - - {storesLoading ? ( -
- ) : stores.length === 0 ? ( -
- -
- ) : ( -
-
- {stores.map((s) => ( - - ))} -
- -
- {dashboardLoading ? ( - - ) : dashboard ? ( - - ) : null} - -
-

Products in this store

-
-
- - 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" - /> -
- - - -
-
- - {productsLoading ? ( - - ) : filteredProducts.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - - - {filteredProducts.map((p) => { - const d = discounts[`${p.brand}||${p.image_id}`]; - return ( - - - - - - - - - - - ); - })} - -
ProductCategoryStockStatusMRPSelling PriceDiscountMargin
-

{p.title}

-

{p.brand}{p.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}

-
{p.category}{p.available_stock} - - {p.stock_status} - - ₹{p.mrp}₹{p.selling_price} - {d && d.discount_pct > 0 ? ( - - -{d.discount_pct}% → ₹{d.final_price} - - ) : ( - — - )} - {p.gross_profit_pct}%
-
- )} -
-
- )} - - setIsUploadOpen(false)} - tabType="stores" - tabTitle="Store Inventory & Pricing" - onSuccess={() => loadStoreData(selectedStore)} - /> -
- ); -} - -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 ( -
- {cards.map((c) => ( -
-
- {c.label} -
-

{c.value}

- {c.sub &&

{c.sub}

} -
- ))} -
- ); -} diff --git a/src/pages/UserPage.jsx b/src/pages/UserPage.jsx index 8ed3a68..19696a9 100644 --- a/src/pages/UserPage.jsx +++ b/src/pages/UserPage.jsx @@ -1,121 +1,15 @@ import React, { useState, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { api } from '../api/client'; import { NavigationHeader } from '../components/NavigationHeader'; -import { ProductCard } from '../components/ProductCard'; import { ProductModal } from '../components/ProductModal'; import { ErrorBoundary } from '../components/ErrorBoundary'; import { PlusCircle, Upload, CheckCircle2, Database, Sparkles, - RefreshCw, Search, FileSpreadsheet, Download, ShoppingBag, - Store, LayoutGrid, Filter, ArrowRight, AlertTriangle + RefreshCw, Search, FileSpreadsheet, Download, LayoutGrid, + AlertTriangle } from 'lucide-react'; -const STORES_SAMPLE = [ - { store_id: 'STORE-A', store_name: 'Store-A - Gandhipuram', city: 'Coimbatore', tier: 'Premium' }, - { store_id: 'STORE-B', store_name: 'Store-B - RS Puram', city: 'Coimbatore', tier: 'Standard' }, - { store_id: 'STORE-C', store_name: 'Store-C - Peelamedu', city: 'Coimbatore', tier: 'Budget' }, - { store_id: 'STORE-D', store_name: 'Store-D - Podanur', city: 'Coimbatore', tier: 'Standard' }, - { store_id: 'STORE-E', store_name: 'Store-E - Ukkadam', city: 'Coimbatore', tier: 'Budget' }, -]; - -// Replaced all 5.imimg.com (blocked by CORS) with high-quality Unsplash photography -const TITLE_KEYWORD_IMAGES = [ - // Dairy Specific Product Imagery - { keywords: ['butter'], img: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?w=500&q=80' }, - { keywords: ['cheese', 'slice', 'block'], img: 'https://images.unsplash.com/photo-1486297678162-eb2a19b0a32d?w=500&q=80' }, - { keywords: ['paneer', 'tofu'], img: 'https://images.unsplash.com/photo-1631452180519-c014fe946bc0?w=500&q=80' }, - { keywords: ['ghee', 'oil'], img: 'https://images.unsplash.com/photo-1628088062854-d1870b4553da?w=500&q=80' }, - { keywords: ['basundi', 'mithai', 'rabri', 'kheer', 'shrikhand', 'gulab'], img: 'https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=500&q=80' }, - { keywords: ['milk', 'taaza', 'gold milk', 'toned'], img: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?w=500&q=80' }, - { keywords: ['lassi', 'kool', 'buttermilk', 'masti', 'curd', 'dahi', 'yogurt'], img: 'https://images.unsplash.com/photo-1572490122747-3968b75bb69c?w=500&q=80' }, - { keywords: ['ice cream', 'frost', 'kulfi', 'sundae', 'cone'], img: 'https://images.unsplash.com/photo-1570197788417-0e82375c9371?w=500&q=80' }, - - // Chocolates & Confectionery - { keywords: ['bournvita', 'malt', 'cocoa powder'], img: 'https://images.unsplash.com/photo-1511920170033-f8396924c348?w=500&q=80' }, - { keywords: ['silk', 'dairy milk', 'chocolate', 'cocoa', 'dark chocolate', 'kitkat'], img: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80' }, - - // Dates & Health Foods - { keywords: ['date powder', 'dates powder'], img: 'https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445' }, - { keywords: ['syrup', 'honey'], img: 'https://images.unsplash.com/photo-1587049352847-81a56d773c1c?w=500&q=80' }, - { keywords: ['date', 'dates'], img: 'https://liondates.com/cdn/shop/files/dates.png?v=1773397173&width=1000' }, - - // Flours, Spices & Grains - { keywords: ['maida', 'atta', 'flour', 'wheat', 'rava', 'soji'], img: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80' }, - { keywords: ['salt'], img: 'https://images.unsplash.com/photo-1610832958506-aa56368176cf?w=500&q=80' }, - { keywords: ['dal', 'pulses', 'lentil', 'chana'], img: 'https://images.unsplash.com/photo-1515543237350-b3eea1ec8082?w=500&q=80' }, - - // Noodles & Snacks - { keywords: ['maggi', 'noodle', 'pasta'], img: 'https://images.unsplash.com/photo-1612927601601-6638404737ce?w=500&q=80' }, - { keywords: ['lays', 'chips', 'namkeen', 'snack', 'kurkure'], img: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80' }, - { keywords: ['biscuit', 'cookie', 'rusk', 'oreo'], img: 'https://images.unsplash.com/photo-1558961363-fa8fdf82db35?w=500&q=80' }, - - // Beverages - { keywords: ['tea', 'chai'], img: 'https://images.unsplash.com/photo-1597481499750-3e6b22637e12?w=500&q=80' }, - { keywords: ['coffee', 'nescafe'], img: 'https://images.unsplash.com/photo-1559056199-641a0ac8b55e?w=500&q=80' }, - { keywords: ['coca', 'coke', 'pepsi', 'sprite', 'drink', 'beverage', 'juice'], img: 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?w=500&q=80' }, -]; - -const BRAND_FALLBACK_IMAGES = { - amul: 'https://images.unsplash.com/photo-1563636619-e9143da7973b?w=500&q=80', - cadbury: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80', - lion: 'https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445', - naga: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80', - nestle: 'https://images.unsplash.com/photo-1612927601601-6638404737ce?w=500&q=80', - tata: 'https://images.unsplash.com/photo-1597481499750-3e6b22637e12?w=500&q=80', - dabur: 'https://images.unsplash.com/photo-1587049352847-81a56d773c1c?w=500&q=80', - godrej: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=500&q=80', - pepsico: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80', - coca: 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?w=500&q=80', -}; - -const CATEGORY_FALLBACK_IMAGES = { - dairy: 'https://images.unsplash.com/photo-1628088062854-d1870b4553da?w=500&q=80', - chocolate: 'https://images.unsplash.com/photo-1549007994-cb92caebd54b?w=500&q=80', - dessert: 'https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=500&q=80', - snack: 'https://images.unsplash.com/photo-1566478989037-eec170784d0b?w=500&q=80', - beverage: 'https://images.unsplash.com/photo-1544145945-f90425340c7e?w=500&q=80', - flour: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=500&q=80', - health: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=500&q=80', -}; - -function resolveProductImageUrl(sp) { - // If we have a valid external URL that isn't from imimg (since imimg blocks CORS), use it - if (sp.image_url && typeof sp.image_url === 'string' && sp.image_url.trim() && sp.image_url.startsWith('http') && !sp.image_url.includes('imimg.com')) { - return sp.image_url; - } - if (Array.isArray(sp.image_urls) && sp.image_urls.length > 0 && sp.image_urls[0] && sp.image_urls[0].startsWith('http') && !sp.image_urls[0].includes('imimg.com')) { - return sp.image_urls[0]; - } - - // 1. Title Keyword Specific Matching (highest priority) - const titleLower = String(sp.title || sp.product_name || '').toLowerCase(); - for (const item of TITLE_KEYWORD_IMAGES) { - if (item.keywords.some((kw) => titleLower.includes(kw))) { - return item.img; - } - } - - // 2. Brand Fallback Matching - const brandLower = String(sp.brand || '').toLowerCase(); - for (const [bKey, img] of Object.entries(BRAND_FALLBACK_IMAGES)) { - if (brandLower.includes(bKey)) return img; - } - - // 3. Category Fallback Matching - const catLower = String(sp.category || '').toLowerCase(); - for (const [cKey, img] of Object.entries(CATEGORY_FALLBACK_IMAGES)) { - if (catLower.includes(cKey)) return img; - } - - return 'https://images.unsplash.com/photo-1542838132-92c53300491e?w=500&q=80'; -} - export function UserPage() { - const [searchParams, setSearchParams] = useSearchParams(); - // Derived active tab directly from URL query param to eliminate state sync conflicts - const activeTab = searchParams.get('tab') === 'cards' ? 'cards' : 'upload'; - const [entryMode, setEntryMode] = useState('single'); // 'single' or 'batch' // Single Form State @@ -145,46 +39,6 @@ export function UserPage() { const [loadingBrand, setLoadingBrand] = useState(false); const [selectedProduct, setSelectedProduct] = useState(null); - // Cards Tab State (Store-A + Uploaded Cards) - const [selectedStoreId, setSelectedStoreId] = useState('STORE-A'); - const [storeProductsRaw, setStoreProductsRaw] = useState([]); - const [uploadedCards, setUploadedCards] = useState([]); - const [loadingCards, setLoadingCards] = useState(false); - const [selectedBrandFilter, setSelectedBrandFilter] = useState('All'); - const [cardSearchQuery, setCardSearchQuery] = useState(''); - - // Pure tab switcher updating searchParams - const switchTab = (tab) => { - if (tab === 'cards') { - setSearchParams({ tab: 'cards' }); - } else { - setSearchParams({}); - } - }; - - // Fetch store products for Cards Tab with fail-safe array checking - const fetchStoreProductsCards = async (storeId) => { - setLoadingCards(true); - try { - const data = await api.getStoreProducts(storeId, { limit: 100 }); - const itemsList = Array.isArray(data) - ? data - : Array.isArray(data?.products) - ? data.products - : []; - setStoreProductsRaw(itemsList); - } catch (e) { - console.warn('Failed to load store products for cards:', e); - setStoreProductsRaw([]); - } finally { - setLoadingCards(false); - } - }; - - useEffect(() => { - fetchStoreProductsCards(selectedStoreId); - }, [selectedStoreId]); - const fetchBrandCatalog = async (bName) => { if (!bName) return; setLoadingBrand(true); @@ -210,7 +64,6 @@ export function UserPage() { const handlePresetLionDates = () => { setEntryMode('single'); - switchTab('upload'); setBrand('Lion Dates'); setProductName('Lion Dates 450g'); setCategory('Health Foods'); @@ -245,32 +98,6 @@ export function UserPage() { setSingleResult(data); setSubmittingSingle(false); - // Add to uploaded cards - if (data.product) { - const newCard = { - image_id: String(data.product.image_id || `user_${Date.now()}`), - product_name: String(data.product.product_name || productName), - brand: String(data.product.brand || brand), - category: String(data.product.category || category), - price_range: data.product.price_range || `₹${data.product.final_selling_price || 100}`, - final_selling_price: data.product.final_selling_price, - selling_price: data.product.final_selling_price, - barcode: data.product.barcode ? String(data.product.barcode) : null, - hsn_code: data.product.hsn_code ? String(data.product.hsn_code) : null, - image_url: resolveProductImageUrl(data.product), - image_urls: data.product.image_urls || (imageUrl ? [imageUrl] : []), - nutrients: data.product.nutrients || [ - { name: 'Protein', amount: '8.5g' }, - { name: 'Calcium', amount: '120mg' }, - { name: 'Iron', amount: '3.2mg' }, - ], - highlights: ['User Uploaded Entry', 'Dual Persistence: Synced DB & JSON'], - providers: ['User Direct Entry', 'Store Inventory'], - is_user_uploaded: true, - }; - setUploadedCards((prev) => [newCard, ...prev]); - } - // Refresh catalog list for current brand setBrandSearch(brand); fetchBrandCatalog(brand); @@ -303,26 +130,6 @@ export function UserPage() { fetchBrandCatalog(firstBrand); } - const newBatchCards = data.added_products.map((item, idx) => ({ - image_id: String(item.image_id || `batch_${Date.now()}_${idx}`), - product_name: String(item.product_name || 'Batch Product'), - brand: String(item.brand || 'User Brand'), - category: String(item.category || 'Batch Upload'), - price_range: item.price_range || `₹${item.final_selling_price || 100}`, - final_selling_price: item.final_selling_price, - selling_price: item.final_selling_price, - barcode: item.barcode ? String(item.barcode) : null, - hsn_code: item.hsn_code ? String(item.hsn_code) : null, - image_url: resolveProductImageUrl(item), - nutrients: [ - { name: 'Protein', amount: '7.5g' }, - { name: 'Calcium', amount: '110mg' }, - ], - highlights: ['Batch CSV/Excel Upload', 'Dual Persistence: Synced DB & JSON'], - providers: ['Batch File Upload', 'Store Inventory'], - is_user_uploaded: true, - })); - setUploadedCards((prev) => [...newBatchCards, ...prev]); } } catch (err) { setUploadingBatch(false); @@ -347,90 +154,6 @@ export function UserPage() { URL.revokeObjectURL(url); }; - // Convert Store products into rich Card objects with 100% type-safe conversions and title-specific images - const allCardProducts = React.useMemo(() => { - const rawList = Array.isArray(storeProductsRaw) ? storeProductsRaw : []; - - const formattedStoreCards = rawList.map((sp) => { - if (!sp || typeof sp !== 'object') return null; - - const brandName = String(sp.brand || 'Store Brand'); - const prodName = String(sp.title || sp.product_name || `${brandName} Product`); - const isLionDates = brandName.toLowerCase().includes('lion'); - const rawImageId = sp.image_id ? String(sp.image_id) : ''; - - // Use real image_url/image_urls from database (same as admin/home page) - // Only fall back to Unsplash keyword matcher when API provides no image - const realImageUrl = sp.image_url || (Array.isArray(sp.image_urls) && sp.image_urls.length > 0 ? sp.image_urls[0] : null); - const displayImageUrl = realImageUrl || resolveProductImageUrl({ ...sp, product_name: prodName }); - - return { - image_id: rawImageId || `${brandName}_${prodName}`, - product_name: prodName, - brand: brandName, - category: String(sp.category || 'Store Product'), - price_range: `₹${Math.round((Number(sp.selling_price || sp.mrp || 100)) * 0.95)}-${Math.round((Number(sp.selling_price || sp.mrp || 100)) * 1.1)}`, - final_selling_price: sp.selling_price || sp.mrp, - selling_price: sp.selling_price || sp.mrp, - barcode: isLionDates ? '20086045' : (rawImageId ? `8906${rawImageId.substring(0, 8)}` : null), - hsn_code: isLionDates ? '2008' : (sp.category === 'Dairy' ? '0402' : '1905'), - image_url: displayImageUrl, - image_urls: Array.isArray(sp.image_urls) ? sp.image_urls : (realImageUrl ? [realImageUrl] : []), - fssai_license: sp.fssai_license || null, - nutrients: sp.nutrients || [ - { name: 'Protein', amount: '8.2g' }, - { name: 'Calcium', amount: '140mg' }, - { name: 'Vitamin D', amount: '3.0mcg' }, - ], - highlights: [ - `Store-A Inventory: ${sp.available_stock || 50} units`, - `Stock Status: ${sp.stock_status || 'In Stock'}`, - `Gross Profit Margin: ${sp.gross_profit_pct || 20}%` - ], - providers: ['Store-A Gandhipuram', 'BigBasket', 'Flipkart'], - is_user_uploaded: false, - }; - }).filter(Boolean); - - return [...(uploadedCards || []), ...formattedStoreCards]; - }, [storeProductsRaw, uploadedCards]); - - // Filter Card products by Search & Brand safely - const filteredCards = React.useMemo(() => { - if (!Array.isArray(allCardProducts)) return []; - return allCardProducts.filter((card) => { - if (!card) return false; - const cardBrand = String(card.brand || ''); - const matchesBrand = - selectedBrandFilter === 'All' || - cardBrand.toLowerCase().includes(selectedBrandFilter.toLowerCase()); - - const q = String(cardSearchQuery || '').toLowerCase().trim(); - const cardTitle = String(card.product_name || '').toLowerCase(); - const cardBarcode = card.barcode ? String(card.barcode) : ''; - const cardHsn = card.hsn_code ? String(card.hsn_code) : ''; - - const matchesQuery = - !q || - cardTitle.includes(q) || - cardBrand.toLowerCase().includes(q) || - cardBarcode.includes(q) || - cardHsn.includes(q); - - return matchesBrand && matchesQuery; - }); - }, [allCardProducts, selectedBrandFilter, cardSearchQuery]); - - const uniqueBrandsList = React.useMemo(() => { - const setB = new Set(['All']); - if (Array.isArray(allCardProducts)) { - allCardProducts.forEach((c) => { - if (c && c.brand) setB.add(String(c.brand)); - }); - } - return Array.from(setB); - }, [allCardProducts]); - // A spreadsheet import can genuinely half-succeed, and that must not be // painted the same green as a clean one - the whole complaint was a success // panel over rows that never landed. @@ -443,12 +166,12 @@ export function UserPage() {
- {/* Top View Selector Tabs: [ Add / Upload Records ] vs [ View Product Cards ] */} + {/* Workspace banner */}
@@ -457,584 +180,419 @@ export function UserPage() {

User Product Management

- Single/Batch Product Uploads with Dual Persistence & Store Product Cards + Single/Batch Product Uploads with Dual Persistence

-
- - - -
{/* ========================================================================= */} {/* VIEW 1: PRODUCT ENTRY & BATCH UPLOAD */} {/* ========================================================================= */} - {activeTab === 'upload' && ( -
-
-
- - Database & Seed JSON Auto-Sync - -

Upload & Add New Product Records

-

- Add new product variants (e.g. "Lion Dates 450g") via single form or batch CSV/Excel upload. Product details & images sync directly into PostgreSQL database tables and JSON seed catalog files. -

-
- -
- - - -
+
+
+
+ + Database & Seed JSON Auto-Sync + +

Upload & Add New Product Records

+

+ Add new product variants (e.g. "Lion Dates 450g") via single form or batch CSV/Excel upload. Product details & images sync directly into PostgreSQL database tables and JSON seed catalog files. +

-
- {/* Left Form: Single vs Batch Upload Tabs */} -
-
-
- +
+ - -
+ +
+
- - Auto-Enrich & Dual Persistence - -
- - {/* SINGLE PRODUCT FORM */} - {entryMode === 'single' && ( -
- {singleError && ( -
- {singleError} -
- )} - -
-
-
- - setBrand(e.target.value)} - placeholder="e.g. Lion Dates" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
- -
- - setProductName(e.target.value)} - placeholder="e.g. Lion Dates 450g" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
-
- -
-
- - setCategory(e.target.value)} - placeholder="e.g. Health Foods" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
- -
- - setPriceRange(e.target.value)} - placeholder="e.g. ₹160-220" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
- -
- - setFinalPrice(e.target.value)} - placeholder="e.g. 185.00" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
-
- -
-
- - setBarcode(e.target.value)} - placeholder="e.g. 20086040" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
- -
- - setHsnCode(e.target.value)} - placeholder="e.g. 2008" - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
-
- -
- - setImageUrl(e.target.value)} - placeholder="https://..." - className="w-full rounded-xl border border-ink-900/15 bg-white p-2.5 text-xs font-medium text-ink-950 focus:border-blue-500 focus:outline-none" - /> -
- -
- -