Compare commits

..

27 Commits

Author SHA1 Message Date
sriram
3c4d9a7c8a frontend changes on UI 2026-10-06 12:02:47 +05:30
sriram
b58b291a0c Frontend Updates on Brand Discovery 2026-09-29 16:44:03 +05:30
sriram
5315f20ea9 Unnecessary Checkbox removal 2026-09-23 11:22:51 +05:30
sriram
7f601cc919 image generation updates 2026-09-11 15:43:48 +05:30
sriram
a24113f1e4 Frontend Changes 2026-09-07 17:54:24 +05:30
sriram
d4845c27f7 Dagster Orchestration File updates 2026-09-07 12:37:25 +05:30
sriram
2ff8fd41ea Frontend health score updation 2026-09-04 12:05:24 +05:30
sriram
2108e402b0 Frontend Feature Changes 2026-09-02 12:27:10 +05:30
sriram
b677ddf9e6 Frontend checkbox fixes 2026-09-01 11:27:08 +05:30
sriram
21642c172b updates in envs 2026-08-31 16:45:48 +05:30
sriram
dd5c70fd54 Frontend Feature Removal of single file manual 2026-08-31 16:33:32 +05:30
sriram
d5420f824e Frontend Updates Orchestration 2026-08-31 15:33:54 +05:30
sriram
097a4a5bfe Frontend Automate Orchestration 2026-08-31 15:00:56 +05:30
sriram
80daf30a42 Frontend Feature add on Dagster 2026-08-29 14:51:10 +05:30
sriram
8efff41226 catalog-header-removal 2026-08-29 12:02:54 +05:30
sriram
8f8f370566 File uploads external source 2026-08-28 08:54:00 +05:30
sriram
e073d68d60 Feature insertion- File importing 2026-08-28 07:47:44 +05:30
sriram
e1247ce1b2 Login page Authenticate and input icon 2026-08-25 11:06:54 +05:30
sriram
814347e2d2 new changes 2026-08-24 11:25:09 +05:30
sriram
f5d3d80f42 updated search suggestions functionality 2026-08-20 12:59:32 +05:30
sriram
c84f0a9529 Updates on md file-frontend 2026-08-19 14:37:21 +05:30
sriram
8bb53d19dd frontend changes with drop elements 2026-08-18 18:39:20 +05:30
sriram
39ae51e291 frontend enrichment pipeline 2026-08-18 16:55:31 +05:30
sriram
e05d4d6e2b frontend changes in webpage 2026-08-18 12:17:57 +05:30
sriram
8e219ca277 updates in frontend based on product DB 2026-08-17 15:33:17 +05:30
Suriyakumarvijayanayagam
2d1f8e4156 Remove the empty VITE_API_BASE_URL build arg - it overrode .env.production
The deployed bundle shipped with no API base URL, so every request fell back to
a relative /api/*, which the frontend's nginx forwards to a backend:8000 service
this deployment does not have.

The cause was this Dockerfile. Vite gives a real environment variable precedence
over its .env files, so `ARG VITE_API_BASE_URL=""` followed by `ENV
VITE_API_BASE_URL=$VITE_API_BASE_URL` did not leave the value unset - it set it
to an empty string and overrode .env.production. Nothing about the build says
so; it just quietly produces a bundle with no API host.

Reproduced directly: building with VITE_API_BASE_URL="" in the environment
yields a bundle with no API URL, and building with the variable unset bakes in
https://mcp.nearle.ai.in from .env.production.

The value now comes from .env.production alone. Point a build elsewhere by
editing that file or adding .env.local; both are read by Vite and neither can be
silently empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:00:37 +05:30
Suriyakumarvijayanayagam
0096008db5 Ship the API URL as .env.production - Dokploy was overwriting .env
The deployed bundle had no API base URL baked in at all, so the app fell back to
relative /api/* requests, which the frontend's nginx forwards to a backend:8000
service that does not exist in this deployment.

Same cause as the backend: Dokploy writes its own .env into the build context
from the service's Environment tab after cloning, and that tab is empty, so the
committed file was replaced by a zero-byte one. The checkout showed .env at 0
bytes, and grepping the running container's bundle for the API host returned
nothing.

Vite loads .env.production for production builds and it takes precedence over
.env, so the config now travels under that name and survives. No Dockerfile
change is needed - Vite resolves it natively - and the --build-arg stays
unnecessary.

Verified by writing an empty .env over the checkout, exactly as Dokploy does,
and building: https://mcp.nearle.ai.in is still inlined into the bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:34:51 +05:30
31 changed files with 3513 additions and 2686 deletions

View File

@@ -1,5 +1,9 @@
# Committed on purpose - see .gitignore.
#
# Named .env.production, not .env: Dokploy overwrites .env in the build context
# from the service's Environment tab (empty when that tab is blank). Vite loads
# .env.production for production builds and it wins over .env, so this survives.
#
# Nothing here is a secret. VITE_API_BASE_URL is inlined into the public JS
# bundle at build time, so it is readable by anyone who opens the site; keeping
# it out of git protected nothing and only meant the value had to be remembered

22
.env.unified Normal file
View File

@@ -0,0 +1,22 @@
# Build profile for a bundle that is served by the FastAPI backend itself.
#
# Use it with: npm run build:unified
#
# Why this file exists: `npm run build` loads .env.production, which pins
# VITE_API_BASE_URL to https://mcp.nearle.ai.in. That is correct for the
# deployed site (served from catalogue.nearle.ai.in, calling the API on its
# own subdomain) but wrong for every local build, because backend/app/main.py
# serves frontend/dist automatically whenever it exists. Opening
# http://localhost:8000 then loads a bundle that sends /api/auth/login to the
# remote host instead of the backend sitting on the same port - so logging in
# with the local admin credentials fails with "Could not reach the API",
# which reads like the backend being down when it is running fine.
#
# Blank means requests stay relative ("/api/..."), i.e. same-origin, so they
# land on whichever server delivered the page. That is exactly what the
# single-port unified deployment needs, and it needs no CORS entry at all.
# Single line comment to avoid a trailing newline, which Vite interprets as a literal value and appends to the URL, breaking all API calls.
# Same value and same reasoning as .env.development; separate file because
# Vite only loads .env.development in dev mode, never during a build.
VITE_API_BASE_URL=

15
.gitignore vendored
View File

@@ -12,15 +12,22 @@ dist
dist-ssr
*.local
# .env is committed here deliberately. It holds only VITE_API_BASE_URL, which
# Vite inlines into the public bundle anyway - it is visible to anyone who opens
# the site, so ignoring it protected nothing. Committing it means the deploy
# .env.production is committed deliberately. It holds only VITE_API_BASE_URL,
# which Vite inlines into the public bundle anyway - visible to anyone who opens
# the site - so ignoring it protected nothing, and committing it means the deploy
# does not depend on remembering a --build-arg.
#
# The name matters. Dokploy writes its own .env into the build context from the
# service's Environment tab after cloning, so a committed .env is silently
# replaced - with an empty file when that tab is blank, which is exactly what
# left the deployed bundle with no API URL at all. Vite loads .env.production
# for production builds and it takes precedence over .env, so this survives.
#
# Anything genuinely secret must NOT go in a VITE_-prefixed variable: it would
# be published in the bundle. Use .env.local (still ignored) for local
# overrides.
!.env
!.env.production
.env
.env.local
# Editor directories and files

View File

@@ -7,21 +7,18 @@ RUN npm ci
COPY . .
# Which origin the built app sends its API calls to.
# Which origin the built app calls comes from .env.production, committed next to
# this file, and Vite inlines it at build time.
#
# Vite inlines env vars at build time, so this MUST be a build arg - setting it
# on the running container has no effect, the value is already baked into the
# JS bundle.
# There is deliberately no ARG/ENV VITE_API_BASE_URL here. Vite gives a real
# environment variable precedence over its .env files, so declaring one with an
# empty default did not "leave it unset" - it actively set the value to an empty
# string and overrode .env.production. The build succeeded, the bundle shipped
# with no API base URL, and every request fell back to a relative /api/* that
# nginx forwards to a backend service this deployment does not have.
#
# docker build --build-arg VITE_API_BASE_URL=https://mcp.nearle.ai.in .
#
# Left empty by default, which keeps requests relative and same-origin so the
# nginx /api/* proxy below serves them. Set it when the API is on its own
# domain - and add this app's origin to the backend's API_CORS_ORIGINS, or the
# browser will block the responses.
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
# To point a build somewhere else, edit .env.production, or add .env.local
# (ignored by git) - both are read by Vite and neither is silently empty.
RUN npm run build
# ---- Serve stage ----

View File

@@ -1,4 +1,4 @@
# Frontend - Kirana AI (React + Vite)
# Frontend - Global Catalogue (React + Vite)
React 19 + Vite + Tailwind CSS v4 single-page app for browsing the catalog,
running semantic search, chatting with the RAG assistant, and (on
@@ -9,7 +9,7 @@ is a fast local reference.
## Quick start
### One command (from the project root)
### One command (from the Project root)
```bash
python run_project.py
@@ -42,10 +42,26 @@ If your backend runs somewhere else, set `VITE_API_BASE_URL` in a local
## Production build
```bash
npm run build # outputs to dist/
npm run build # outputs to dist/ - for the DEPLOYED site only
npm run preview # serve the production build locally to sanity-check it
```
`npm run build` loads `.env.production`, which pins `VITE_API_BASE_URL` to
`https://mcp.nearle.ai.in`. That is right for the deployed site and wrong for
anything you run locally, because the backend serves `frontend/dist` itself
whenever that directory exists - so `http://localhost:8000` would hand you a
page that sends `/api/auth/login` to the remote host and fails with "Could not
reach the API" even though the local backend is up. For a build you intend to
run locally, use:
```bash
npm run build:unified # same optimised build, blank VITE_API_BASE_URL
```
That leaves API calls relative, so they go back to whichever server delivered
the page - port 8000 for the backend's single-port mode, or `npm run preview`,
which proxies `/api/*` to `127.0.0.1:8000` the same way the dev server does.
## Pages
- `/` - Browse & Search (sidebar brand/category filters, product grid,
@@ -68,5 +84,5 @@ src/
│ └── AdminPage.jsx
├── App.jsx # routes
├── main.jsx
└── index.css # Tailwind v4 theme tokens ("Kirana Ledger" palette)
└── index.css # Tailwind v4 theme tokens ("Global Catalogue Ledger" palette)
```

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:unified": "vite build --mode unified",
"lint": "oxlint",
"preview": "vite preview"
},

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -8,24 +8,17 @@ 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.
*/
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 }) {
@@ -54,10 +47,11 @@ function ProtectedRoute({ children, allowedRoles }) {
const effectiveRole = role || (user.role === 'store' ? 'user' : user.role);
if (allowedRoles && !allowedRoles.includes(effectiveRole)) {
if (window.location.pathname === '/user') {
return children;
}
return <Navigate to="/user" replace />;
// Every remaining route is admin-only, so there is nowhere else in the app
// to send a non-admin. This can still be reached by a token minted before
// the `user` account was disabled - those stay valid for their 12h TTL -
// so it has to terminate somewhere public rather than redirect in a loop.
return <Navigate to="/login" replace />;
}
return children;
@@ -84,16 +78,6 @@ export default function App() {
}
/>
{/* User Workspace Route */}
<Route
path="/user"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<UserPage />
</ProtectedRoute>
}
/>
{/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */}
<Route
path="/admin"
@@ -104,36 +88,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"

View File

@@ -131,6 +131,52 @@ async function upload(path, file, { fieldName = 'file' } = {}) {
return res.json();
}
/**
* Post several files under one field name.
*
* Separate from `upload` rather than a flag on it: `upload` is used by six
* call sites that send exactly one file, and widening its signature would put
* an array check in front of all of them for the benefit of one.
*
* FastAPI reads `files: List[UploadFile]` from repeated parts sharing a name,
* which is why every file is appended under the same key. As with `upload`,
* Content-Type is deliberately left unset so the browser writes the multipart
* boundary itself.
*/
async function uploadMany(path, files, { fieldName = 'files' } = {}) {
const formData = new FormData();
Array.from(files || []).forEach((file) => formData.append(fieldName, file));
let res;
try {
res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
} catch {
throw new ApiError(
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
`and reachable?`,
0
);
}
if (!res.ok) {
let detail = `Upload failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {
/* ignore parse errors, keep generic message */
}
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
throw new ApiError(detail, res.status);
}
return res.json();
}
function qs(params = {}) {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
@@ -179,8 +225,15 @@ export const api = {
getProductDetail: (brand, imageId) =>
request(`/api/brands/${encodeURIComponent(brand)}/products/${encodeURIComponent(imageId)}`),
search: (q, { brand, category, top_k = 50 } = {}) =>
request(`/api/search${qs({ q, brand, category, top_k })}`),
// Same reasoning as SHOW_ALL_PRODUCTS_LIMIT above: searching a brand name must
// return that brand's whole catalog, not a page of it. The old default of 50
// was in any case ignored - the backend clamped every search to 15.
search: (q, { brand, category, top_k = SHOW_ALL_PRODUCTS_LIMIT, offset = 0 } = {}) =>
request(`/api/search${qs({ q, brand, category, top_k, offset })}`),
// Autocomplete for the catalog search box. Answers from in-process caches,
// so it is safe to call per keystroke (debounced in useSuggestions).
suggest: (q, { limit = 8 } = {}) => request(`/api/suggest${qs({ q, limit })}`),
chat: ({ query, brand, category, top_k, history }) =>
request('/api/chat', {
@@ -284,6 +337,107 @@ export const api = {
body: JSON.stringify({ store_id, rules }),
}),
// --- Admin: batch catalog ingestion (many spreadsheets -> one batch) ---
// Excel -> the 11-stage pipeline -> brand tables. See
// app/api/routers/batch_catalog.py. `preview` parses only, so the operator
// can check the column mapping before paying for a full scrape.
//
// NOTE the flag defaults here are OFF. A single file with image search on is
// a considered trade; twenty files is thousands of outbound requests and a
// Playwright subprocess per row, on a single-vCPU container that is also
// serving the API. A batch opts in. (There used to be a single-file sibling,
// `ingestStoreCatalog`, which defaulted both ON for exactly that reason; it
// was removed with the Store Catalog Ingestion admin tab.)
previewCatalogBatch: (files) =>
uploadMany('/api/admin/catalog-batch/preview', files),
ingestCatalogBatch: (files, { use_llm = true, fetch_images = false } = {}) =>
uploadMany(`/api/admin/catalog-batch/ingest${qs({ use_llm, fetch_images })}`, files),
getCatalogBatch: (batchId) =>
request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}`),
listCatalogBatches: (limit = 20) =>
request(`/api/admin/catalog-batch/batches${qs({ limit })}`),
resumeCatalogBatch: (batchId) =>
request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}/resume`, {
method: 'POST',
}),
cancelCatalogBatch: (batchId) =>
request(`/api/admin/catalog-batch/batches/${encodeURIComponent(batchId)}/cancel`, {
method: 'POST',
}),
// --- Admin: brand discovery (a brand NAME -> the same 11-stage pipeline) ---
// See app/api/routers/brand_discovery.py. `preview` discovers and writes
// nothing; `ingest` takes back the rows the operator kept and stages them as
// an ordinary catalog batch, so the response is a BatchOut and the Batch tab
// can adopt it.
//
// NOTE the flag defaults here are ON, unlike the batch uploader above. The
// reasoning is not inconsistent: a batch is up to twenty spreadsheets of
// unknown size, while this is one brand the operator has just reviewed
// row by row - and rows ingested without images stay imageless, because
// nothing downstream goes back to fill them in.
previewBrandDiscovery: (payload) =>
request('/api/admin/brand-discovery/preview', {
method: 'POST',
body: JSON.stringify(payload),
}),
// Web & retail listings: a background search (minutes, not seconds) that
// the panel starts, polls, and then reads through the preview's `use_web`.
startWebDiscovery: (brand, { refresh = false } = {}) =>
request('/api/admin/brand-discovery/web-jobs', {
method: 'POST',
body: JSON.stringify({ brand, refresh }),
}),
getWebDiscoveryJob: (jobId) =>
request(`/api/admin/brand-discovery/web-jobs/${encodeURIComponent(jobId)}`),
ingestBrandDiscovery: (
brand,
products,
{ use_llm = true, fetch_images = true, acknowledge_inactive = false } = {},
) =>
request('/api/admin/brand-discovery/ingest', {
method: 'POST',
body: JSON.stringify({
brand,
products,
use_llm,
fetch_images,
acknowledge_inactive,
}),
}),
// --- Admin: the review inbox (a colleague dropped files; you decide) ---
// The uploader half is POST /api/uploads/catalog, which the browser never
// calls - a colleague hits it with an X-API-Key from a script.
listInbox: () => request('/api/admin/catalog-batch/inbox'),
// `runner` picks the executor: 'inprocess' (this container's worker thread,
// the default and the only one that exists in production) or 'dagster'
// (staged and left for the orchestrator to claim). See InboxStartRequest in
// app/api/routers/batch_catalog.py.
startBatchFromInbox: (
fileIds,
{ use_llm = true, fetch_images = false, runner = 'inprocess' } = {},
) =>
request('/api/admin/catalog-batch/from-inbox', {
method: 'POST',
body: JSON.stringify({ file_ids: fileIds, use_llm, fetch_images, runner }),
}),
dismissInboxFiles: (fileIds) =>
request('/api/admin/catalog-batch/inbox/dismiss', {
method: 'POST',
body: JSON.stringify({ file_ids: fileIds }),
}),
// --- User workspace: single + batch product entry ---
// Guarded by `add_product` / `upload_batch_products` - see
// app/api/routers/user_products.py.

View File

@@ -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 { 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,112 @@ 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>
{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" />
<>
<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: Page Title */}
<div className="flex items-center gap-3">
{title && (
<div className="flex items-center gap-2.5 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>
{/* 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>
)}
{/* 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' && (
<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>
)}
{/* 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>
{/* 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>
</>
);
}

View File

@@ -186,7 +186,7 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
{description && <p className="mt-3 text-sm leading-relaxed text-ink-800">{description}</p>}
<NutritionPanel brand={brand} imageId={product.image_id} productName={product_name} />
<NutritionPanel brand={brand} imageId={product.image_id} productName={product_name} category={category} />
{size_variants.length > 0 && (
<Section icon={Tag} label="Available sizes">

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react';
import { Tag, LayoutGrid } from 'lucide-react';
/**
* The search box's autocomplete listbox.
*
* Presentational only - the parent owns `activeIndex` and selection, so the
* input can drive it from the keyboard while keeping focus.
*/
export function SearchSuggestions({ suggestions, activeIndex, onHover, onSelect, listboxId }) {
const listRef = useRef(null);
// Keep the keyboard-highlighted option visible when arrowing past the fold.
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const option = listRef.current.querySelector(`[data-index="${activeIndex}"]`);
if (option) option.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
if (!suggestions.length) return null;
return (
<ul
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Search suggestions"
className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-72 overflow-y-auto rounded-xl border border-ink-900/10 bg-white py-1 shadow-lg"
>
{suggestions.map((s, i) => {
const Icon = s.type === 'category' ? LayoutGrid : Tag;
const active = i === activeIndex;
return (
<li
key={`${s.type}:${s.value}`}
id={`${listboxId}-opt-${i}`}
data-index={i}
role="option"
aria-selected={active}
onMouseEnter={() => onHover(i)}
// mousedown, not click: the input's blur would otherwise close the
// list before the click could land on it.
onMouseDown={(e) => {
e.preventDefault();
onSelect(s);
}}
className={`flex cursor-pointer items-center gap-2.5 px-3.5 py-2 text-xs transition ${
active ? 'bg-amber-500/15 text-ink-950' : 'text-ink-900 hover:bg-ink-100/60'
}`}
>
<Icon className={`h-3.5 w-3.5 shrink-0 ${active ? 'text-amber-600' : 'text-slate-400'}`} />
<span className="flex-1 truncate font-medium">{s.label}</span>
{s.sublabel && (
<span className="shrink-0 text-[11px] text-slate-400">{s.sublabel}</span>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -1,7 +1,67 @@
import { useEffect, useId, useRef, useState } from 'react';
import { Search, MessageCircle, LayoutGrid } from 'lucide-react';
import { NavigationHeader } from './NavigationHeader';
import { SearchSuggestions } from './SearchSuggestions';
import { useSuggestions } from '../hooks/useSuggestions';
export function TopBar({
mode, onModeChange, query, onQueryChange, onSubmit, onSelectSuggestion,
health, onResetHome,
}) {
// Dropdown state lives here rather than in HomePage so arrowing through the
// list doesn't re-render the whole product grid.
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const wrapperRef = useRef(null);
const listboxId = useId();
const { suggestions } = useSuggestions(query, { enabled: mode === 'search' });
// A new result set invalidates whatever was highlighted.
useEffect(() => setActiveIndex(-1), [suggestions]);
useEffect(() => {
function onPointerDown(e) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, []);
const showList = open && suggestions.length > 0;
function select(suggestion) {
setOpen(false);
setActiveIndex(-1);
onSelectSuggestion?.(suggestion);
}
function handleKeyDown(e) {
if (e.key === 'Escape') {
setOpen(false); // close but keep what was typed
setActiveIndex(-1);
return;
}
if (e.key === 'Tab') {
setOpen(false); // don't hijack focus movement
return;
}
if (!showList) return;
if (e.key === 'ArrowDown') {
e.preventDefault(); // otherwise the caret jumps to the end
setActiveIndex((i) => (i + 1) % suggestions.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
// Only intercept Enter when something is highlighted; otherwise the form
// submits the raw text exactly as it always has.
e.preventDefault();
select(suggestions[activeIndex]);
}
}
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health, onResetHome }) {
return (
<NavigationHeader health={health} onResetHome={onResetHome}>
<div className="flex flex-col sm:flex-row items-center gap-3 pt-1">
@@ -12,8 +72,10 @@ export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, hea
{mode === 'search' && (
<form
ref={wrapperRef}
onSubmit={(e) => {
e.preventDefault();
setOpen(false);
onSubmit();
}}
className="relative flex-1 w-full"
@@ -21,10 +83,35 @@ export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, hea
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search catalog products — e.g. 'Recommend Paneer under ₹150', 'How many products in Cadbury?'"
onChange={(e) => { onQueryChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={showList}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
showList && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
}
autoComplete="off"
placeholder="Search catalog products — e.g. 'Amul', 'Amul Butter', 'Recommend Paneer under ₹150'"
className="w-full rounded-full border border-ink-900/10 bg-white py-2 pl-10 pr-4 text-xs text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 shadow-xs"
/>
{showList && (
<SearchSuggestions
suggestions={suggestions}
activeIndex={activeIndex}
onHover={setActiveIndex}
onSelect={select}
listboxId={listboxId}
/>
)}
{/* Announce the list to screen readers, which can't see it appear. */}
<span className="sr-only" role="status" aria-live="polite">
{showList ? `${suggestions.length} suggestions available` : ''}
</span>
</form>
)}
</div>

View File

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

View File

@@ -45,7 +45,21 @@ function ScoreBadge({ label, score }) {
);
}
export function NutritionPanel({ brand, imageId, productName }) {
// Categories with no nutrition panel to show, mirroring
// backend/app/services/consumability.py. Kept here so the empty state can say
// WHY it is empty: "we could not find data" and "this is a bar of soap" are
// different facts, and the first reads as a failure the user might retry.
const NON_CONSUMABLE_CATEGORIES = new Set([
'hair care', 'skin care', 'skin & bath care', 'bath soap', 'beauty care',
'oral care', 'fragrance & deodorants', "men's grooming", 'feminine hygiene',
'personal care', 'detergents & fabric care', 'dishwash', 'household cleaning',
'household - agarbatti', 'household - lamp oil', 'household - air freshener',
'personal care - mosquito repellent', 'health care - cold & cough',
'health care - digestive', 'health care - antiseptic', 'health care - first aid',
'flowers',
]);
export function NutritionPanel({ brand, imageId, productName, category }) {
const [nutrition, setNutrition] = useState(null);
const [loading, setLoading] = useState(false);
const [perServing, setPerServing] = useState(false);
@@ -80,11 +94,13 @@ export function NutritionPanel({ brand, imageId, productName }) {
}
if (!nutrition || nutrition.data_status === 'unavailable') {
const notFood = NON_CONSUMABLE_CATEGORIES.has((category || '').trim().toLowerCase());
return (
<Section icon={Activity} label="Nutrition">
<p className="text-xs text-slate-400">
Nutrition data unavailable for {productName || 'this product'}. Verified nutrition facts
couldn't be confidently matched from trusted sources yet.
{notFood
? `Nutrition information doesn't apply to ${productName || 'this product'} — it isn't a food or drink.`
: `Nutrition data unavailable for ${productName || 'this product'}. Verified nutrition facts couldn't be confidently matched from trusted sources yet.`}
</p>
</Section>
);

View File

@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
/**
* Debounce a fast-changing value (here, whatever is typed in the search box).
* 180ms is roughly two requests per word at 60wpm - responsive without firing
* on every keystroke.
*/
export function useDebouncedValue(value, delay = 180) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
/**
* Brand/category suggestions for a partial query.
*
* Responses can arrive out of order, so a monotonic request id decides which
* one is allowed to land - the same guard HomePage uses for search results.
* Errors resolve to an empty list rather than propagating: a failing suggest
* endpoint must never stop someone typing or submitting a search.
*/
export function useSuggestions(query, { enabled = true, minLength = 2, limit = 8 } = {}) {
const debounced = useDebouncedValue(query);
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(false);
const requestIdRef = useRef(0);
const cacheRef = useRef(new Map());
useEffect(() => {
const trimmed = (debounced || '').trim();
if (!enabled || trimmed.length < minLength) {
requestIdRef.current += 1; // invalidate anything still in flight
setSuggestions([]);
setLoading(false);
return;
}
const cacheKey = `${limit}|${trimmed.toLowerCase()}`;
const cached = cacheRef.current.get(cacheKey);
if (cached) {
// Backspacing back through text already typed shouldn't refetch.
requestIdRef.current += 1;
setSuggestions(cached);
setLoading(false);
return;
}
const id = ++requestIdRef.current;
setLoading(true);
api.suggest(trimmed, { limit })
.then((res) => {
const results = res.suggestions || [];
cacheRef.current.set(cacheKey, results);
if (id === requestIdRef.current) setSuggestions(results);
})
.catch(() => {
if (id === requestIdRef.current) setSuggestions([]);
})
.finally(() => {
if (id === requestIdRef.current) setLoading(false);
});
}, [debounced, enabled, minLength, limit]);
return { suggestions, loading };
}

View File

@@ -1,9 +1,9 @@
@import "tailwindcss";
/*
Design tokens - "Kirana Ledger" direction.
Inspired by the price tags and ledger books of an Indian neighbourhood
kirana store, paired with a calm, modern data-tool palette. Navy/cream
Design tokens - "Global Catalogue Ledger" direction.
Inspired by the price tags and ledger books of a neighbourhood
grocery store, paired with a calm, modern data-tool palette. Navy/cream
base (echoes the navy/light-blue tone already used in this project's
engineering docs) with a turmeric-amber accent and a maroon highlight
for badges/sale-style tags.

View File

@@ -1,43 +1,70 @@
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, Layers, ClipboardCheck, Workflow, Sparkles } from 'lucide-react';
import { api } from '../api/client';
import { NavigationHeader } from '../components/NavigationHeader';
import { BatchCatalogPanel } from './BatchCatalogPanel';
import { BrandDiscoveryPanel } from './BrandDiscoveryPanel';
import { UploadResultsPanel } from './UploadResultsPanel';
import { OrchestrationPanel } from './OrchestrationPanel';
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 carries the read-only project overview plus three ingestion
* views: a multi-file batch upload, the results of a colleague's upload, and
* the Dagster orchestration screen.
*
* "Upload Results" was the Review Inbox until it stopped being useful as one:
* UPLOAD_AUTORUN defaults to true, so a colleague's upload runs on arrival and
* the inbox is permanently empty. The tab now leads with what the pipeline did
* with the newest colleague upload, and the approval gate reveals itself only
* when files are actually waiting - which is what the badge below counts.
*
* TWO KINDS OF REMOVAL HAVE HAPPENED HERE, AND THE DIFFERENCE MATTERS.
*
* Three early sub-tabs (train/test dataset upload, stock-remaining discount
* allocator, and a trigger for the brand ingestion pipeline) were retired from
* the UI ONLY. Their endpoints are all still registered and still work.
*
* Brand ingestion has since come back, as "Brand Discovery" - but it is a
* different thing behind the same idea. The retired tab drove
* POST /api/catalog/generate, which does NOT run the eleven stages and mints a
* random image_id, so running it twice produces two catalogs. The new tab
* discovers products, shows them for approval, and then stages them as an
* ordinary catalog batch through the same pipeline a spreadsheet goes through.
* The old route and `python cli/ingest_brand.py "<brand>"` are both still
* registered and untouched.
*
* "Store Catalog Ingestion" - a single-file uploader - went further: the tab,
* its panel, and the /api/admin/store-catalog/* routes behind it were all
* deleted, because Batch Catalog Ingestion does the same job for up to twenty
* files and POST /api/uploads/catalog now runs a colleague's upload
* automatically. Do not go looking for those endpoints; they are gone. The
* eleven-stage pipeline they drove is not - it lives in
* app/core/store_catalog_pipeline.py and every route below still runs it.
*/
const TABS = [
{ id: 'project', label: 'Project Overview Details', icon: Database },
{ id: 'discover', label: 'Brand Discovery', icon: Sparkles },
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
// The id stays 'inbox' - the badge, the render guard and the onBatchStarted
// wiring all key off it, and the tab still owns the review inbox; it just
// leads with the pipeline result now.
{ id: 'inbox', label: 'Upload Results', icon: ClipboardCheck },
{ id: 'orchestration', label: 'Dagster Orchestration', icon: Workflow },
];
// How often the pending count is refreshed. Slower than the 3s a running batch
// polls at, because this is a "has anything arrived?" question, not progress on
// something already in flight - and it runs on whichever tab is open, so it is
// the one poll in the app that never stops while the page is up.
const INBOX_POLL_MS = 15000;
export function AdminPage() {
const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest'
// Project details state
const [activeTab, setActiveTab] = useState('project');
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 [pendingUploads, setPendingUploads] = useState(0);
// Handed to BatchCatalogPanel when a run is started from the inbox.
const [adoptedBatch, setAdoptedBatch] = useState(null);
const fetchProjectDetails = async () => {
try {
@@ -49,430 +76,163 @@ 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);
}
};
// The badge. Polled here rather than inside UploadResultsPanel so the count
// is live
// whichever tab is open - the whole point is finding out that a colleague
// sent something while you were looking at a different screen. A failed poll
// is swallowed: a transient network blip must not replace the panel with an
// error, and the next tick retries.
//
// Expect a steady zero on a deployment running UPLOAD_AUTORUN=true: uploads
// go straight into the pipeline and never wait for review, so there is
// nothing to be notified about. The badge renders only above zero, so that
// reads as "nothing to do" rather than as a broken counter, and the poll
// stays because the setting can be turned off without a redeploy.
useEffect(() => {
let alive = true;
const check = async () => {
try {
const { pending_count: count } = await api.listInbox();
if (alive) setPendingUploads(count || 0);
} catch {
/* next tick retries */
}
};
check();
const timer = setInterval(check, INBOX_POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, []);
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 tables & single or batch spreadsheet ingestion"
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>
<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>
<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>
<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>
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap cursor-pointer ${
activeTab === id
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Icon className="h-4 w-4" /> {label}
{id === 'inbox' && pendingUploads > 0 && (
<span
className={`ml-1 min-w-5 px-1.5 py-0.5 rounded-full text-[10px] font-extrabold leading-none ${
activeTab === id
? 'bg-slate-950 text-amber-400'
: 'bg-amber-500 text-slate-950'
}`}
>
{pendingUploads}
</span>
)}
</button>
))}
</div>
{/* TAB 1: EXISTING PROJECT DETAILS */}
{/* Discovery has no progress UI of its own: it stages an ordinary
catalog batch and hands it to the Batch tab through the same
`adoptBatch` prop the approval gate uses, which already draws the
eleven stages and owns Resume and Cancel. */}
{activeTab === 'discover' && (
<BrandDiscoveryPanel
onBatchStarted={(batch) => {
setAdoptedBatch(batch);
setActiveTab('batch');
}}
/>
)}
{activeTab === 'batch' && <BatchCatalogPanel adoptBatch={adoptedBatch} />}
{activeTab === 'inbox' && (
<UploadResultsPanel
onBatchStarted={(batch) => {
setAdoptedBatch(batch);
setActiveTab('batch');
setPendingUploads((n) => Math.max(0, n - batch.files_total));
}}
/>
)}
{/* Starts from the same inbox but hands the batch to Dagster, and stays
put to show the run rather than handing off to the Batch tab - the
stage-by-stage view is the reason to be on this tab at all. */}
{activeTab === 'orchestration' && <OrchestrationPanel />}
{activeTab === 'project' && (
<div className="space-y-6">
<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>
{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>
<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>
{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>

View File

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

View File

@@ -0,0 +1,522 @@
import React, { useEffect, useRef, useState } from 'react';
import {
FileSpreadsheet, PlayCircle, Loader2, CheckCircle2,
XCircle, AlertTriangle, ArrowRight, Table2, Layers, X, RotateCcw, Ban,
Clock, MinusCircle,
} from 'lucide-react';
import { api } from '../api/client';
/*
* Several store spreadsheets -> one batch -> the same 11-stage pipeline.
*
* This began as the multi-file sibling of a single-file StoreCatalogPanel;
* that panel and its /api/admin/store-catalog/* routes have since been deleted,
* and this is now the only admin upload screen. What made it the better of the
* two is the unit of work: five files are ONE batch with one id, so the
* question a colleague actually asks - "did the drop land?" - has one answer
* instead of five.
*
* Two things are deliberately not like that single-file panel was:
*
* - the enrichment toggles default OFF. At one file, image search is a
* considered trade; at twenty it is thousands of outbound requests on a
* box that is also serving the API.
* - a file that fails does not fail the batch. The per-file table is the
* point of the screen, and a `partial` batch is rendered as its own state
* so four-of-five landing never reads as plain success.
*/
const POLL_MS = 3000;
const TERMINAL = new Set(['done', 'failed', 'partial', 'cancelled']);
const FILE_TONE = {
done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30',
running: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
queued: 'bg-white text-slate-500 border-ink-900/10',
cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10',
};
const FILE_ICON = {
done: CheckCircle2,
failed: XCircle,
running: Loader2,
queued: Clock,
cancelled: MinusCircle,
};
function fmtBytes(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function BatchCatalogPanel({ adoptBatch = null }) {
const [files, setFiles] = useState([]);
const [preview, setPreview] = useState(null);
const [previewing, setPreviewing] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState('');
const [batch, setBatch] = useState(null);
const [useLlm, setUseLlm] = useState(true);
// Ticked by default: a batch ingested without it produces rows with no
// images at all, and nothing downstream ever goes back to fill them in. The
// API default stays off - an anonymous upload must not trigger network
// calls - so this is the admin, in front of the checkbox, opting in.
const [fetchImages, setFetchImages] = useState(true);
const pollRef = useRef(null);
const batchIdRef = useRef(null);
// A batch started from the approval gate on the Upload Results tab. Without
// this, switching tabs
// after pressing "Start selected" would land on an empty panel and the run
// would look like it had not happened.
useEffect(() => {
if (!adoptBatch?.batch_id) return;
setBatch(adoptBatch);
setFiles([]);
setPreview(null);
setError('');
if (!TERMINAL.has(adoptBatch.status)) batchIdRef.current = adoptBatch.batch_id;
}, [adoptBatch]);
// One interval for the component's lifetime, reading the id from a ref
// rather than closing over it. The reason is that starting a second batch
// must not leave an older interval polling the previous one. OrchestrationPanel
// does the same thing for the same reason.
useEffect(() => {
pollRef.current = setInterval(async () => {
const id = batchIdRef.current;
if (!id) return;
try {
const next = await api.getCatalogBatch(id);
setBatch(next);
// Stop polling once there is nothing left to watch, so an admin tab
// left open overnight is not hitting the API forever.
if (TERMINAL.has(next.status)) batchIdRef.current = null;
} catch {
/* a dropped poll is not worth surfacing; the next tick retries */
}
}, POLL_MS);
return () => clearInterval(pollRef.current);
}, []);
const onPick = (e) => {
setFiles(Array.from(e.target.files || []));
setPreview(null);
setBatch(null);
setError('');
// Clearing the input means re-picking the same files fires onChange again.
e.target.value = '';
};
const removeAt = (index) => {
setFiles((current) => current.filter((_, i) => i !== index));
setPreview(null);
};
const handlePreview = async () => {
if (!files.length) return;
setPreviewing(true);
setError('');
try {
setPreview(await api.previewCatalogBatch(files));
} catch (err) {
setError(err?.message || 'Could not read those files.');
} finally {
setPreviewing(false);
}
};
const handleIngest = async () => {
if (!files.length) return;
setStarting(true);
setError('');
try {
const started = await api.ingestCatalogBatch(files, {
use_llm: useLlm,
fetch_images: fetchImages,
});
batchIdRef.current = started.batch_id;
setBatch(started);
} catch (err) {
setError(err?.message || 'Could not start the batch.');
} finally {
setStarting(false);
}
};
const handleResume = async () => {
if (!batch) return;
setError('');
try {
const resumed = await api.resumeCatalogBatch(batch.batch_id);
batchIdRef.current = resumed.batch_id;
setBatch(resumed);
} catch (err) {
setError(err?.message || 'Could not resume the batch.');
}
};
const handleCancel = async () => {
if (!batch) return;
setError('');
try {
setBatch(await api.cancelCatalogBatch(batch.batch_id));
} catch (err) {
setError(err?.message || 'Could not cancel the batch.');
}
};
const running = batch && !TERMINAL.has(batch.status);
const totals = batch?.totals;
return (
<div className="space-y-6">
{/* ---- 1. File selection --------------------------------------- */}
<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">
<Layers className="h-5 w-5 text-amber-500" /> Batch Catalog Ingestion
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
Upload one or many store product lists at once (.xlsx, .xls, .csv or .tsv). They run
as a single batch through the same 11-stage pipeline &mdash; images, price bands, pack
sizes, SKU, barcode, HSN/GST and FSSAI &mdash; then validated and written into the
matching brand tables. Files are processed one at a time; a file that fails is
reported and the rest carry on. Columns a sheet already fills are never overwritten.
</p>
<div className="mt-5 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-ink-950 cursor-pointer hover:border-amber-500/60 transition">
<FileSpreadsheet className="h-4 w-4 text-amber-600" />
{files.length ? `${files.length} file${files.length === 1 ? '' : 's'} selected` : 'Choose spreadsheets'}
<input
type="file"
multiple
accept=".xlsx,.xls,.csv,.tsv"
onChange={onPick}
className="hidden"
/>
</label>
<button
type="button"
onClick={handlePreview}
disabled={!files.length || previewing}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-ink-900 text-white text-xs font-bold hover:bg-ink-950 disabled:opacity-40 transition cursor-pointer"
>
{previewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Table2 className="h-4 w-4" />}
Preview all
</button>
<button
type="button"
onClick={handleIngest}
disabled={!files.length || starting || running}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{starting ? <Loader2 className="h-4 w-4 animate-spin" /> : <PlayCircle className="h-4 w-4" />}
Start batch ingestion <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
{/* Enrichment toggles. Off by default, and the cost is stated rather
than left for someone to discover on a 20-file run. */}
<div className="mt-4 flex flex-col gap-2">
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={fetchImages}
onChange={(e) => setFetchImages(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Search for product images</span>
{' '}&mdash; much slower, and makes outbound requests for every row. Leave off
for a large batch.
</span>
</label>
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={useLlm}
onChange={(e) => setUseLlm(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
{' '}&mdash; fills blank descriptions where an Ollama server is reachable. It is not
in production, so rows there simply keep the description the sheet gave them.
</span>
</label>
</div>
{files.length > 0 && (
<ul className="mt-4 space-y-1.5">
{files.map((file, index) => (
<li
key={`${file.name}-${index}`}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-xl border border-ink-900/10 bg-white"
>
<span className="text-[11px] font-bold text-ink-950 truncate">{file.name}</span>
<span className="flex items-center gap-2 shrink-0">
<span className="font-mono text-[10px] text-slate-400">{fmtBytes(file.size)}</span>
<button
type="button"
onClick={() => removeAt(index)}
aria-label={`Remove ${file.name}`}
className="text-slate-400 hover:text-maroon-600 transition cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
</li>
))}
</ul>
)}
{error && (
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
</div>
)}
</div>
{/* ---- 2. Per-file column mapping preview ---------------------- */}
{preview && (
<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-1">
Column mapping &mdash; {preview.files_ok}/{preview.files_total} file(s) readable,
{' '}{preview.rows_total} row(s) total
</h3>
<p className="text-[11px] text-slate-500 mb-4">
Nothing has been ingested yet. Check the mapping, then start the batch.
</p>
<div className="space-y-4">
{preview.files.map((file) => (
<div key={file.filename} className="rounded-xl border border-ink-900/10 bg-white p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-bold text-ink-950 truncate">{file.filename}</span>
{file.ok ? (
<span className="font-mono text-[10px] text-slate-400 shrink-0">
{file.rows_total} rows
</span>
) : (
<span className="text-[10px] font-bold text-maroon-600 shrink-0">
will be skipped
</span>
)}
</div>
{!file.ok && (
<p className="text-[11px] text-maroon-600 flex items-start gap-1.5">
<XCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" /> {file.error}
</p>
)}
{file.ok && (
<>
<p className="text-[11px] text-slate-500 mb-2">
{file.brand_column_present
? 'Brand column found.'
: 'No brand column; the brand will be inferred from each product name.'}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{Object.entries(file.recognised_columns).map(([field, column]) => (
<div
key={field}
className="flex items-center justify-between gap-2 p-2 rounded-lg border border-ink-900/10 bg-paper-50"
>
<span className="text-[10px] text-slate-500 truncate">{column}</span>
<ArrowRight className="h-3 w-3 text-slate-400 shrink-0" />
<span className="text-[10px] font-bold text-ink-950 truncate">{field}</span>
</div>
))}
</div>
{file.unrecognised_columns?.length > 0 && (
<p className="mt-3 text-[11px] text-amber-700 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
Ignored: {file.unrecognised_columns.join(', ')}
</p>
)}
</>
)}
</div>
))}
</div>
</div>
)}
{/* ---- 3. Live batch tracker ----------------------------------- */}
{batch && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-center justify-between gap-3 mb-1">
<h3 className="font-display text-sm font-bold text-ink-950 flex items-center gap-2">
{batch.status === 'done' && <CheckCircle2 className="h-4 w-4 text-leaf-600" />}
{batch.status === 'partial' && <AlertTriangle className="h-4 w-4 text-amber-600" />}
{batch.status === 'failed' && <XCircle className="h-4 w-4 text-maroon-600" />}
{batch.status === 'interrupted' && <RotateCcw className="h-4 w-4 text-amber-600" />}
{running && <Loader2 className="h-4 w-4 animate-spin text-amber-600" />}
Batch &mdash; {batch.files_done}/{batch.files_total} file(s) done
</h3>
<div className="flex items-center gap-2 shrink-0">
{batch.status === 'interrupted' && (
<button
type="button"
onClick={handleResume}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500 text-slate-950 text-[11px] font-bold hover:bg-amber-600 transition cursor-pointer"
>
<RotateCcw className="h-3.5 w-3.5" /> Resume
</button>
)}
{running && (
<button
type="button"
onClick={handleCancel}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-maroon-600 transition cursor-pointer"
>
<Ban className="h-3.5 w-3.5" /> Cancel
</button>
)}
<span className="font-mono text-[11px] uppercase text-slate-400">{batch.status}</span>
</div>
</div>
<p className="font-mono text-[10px] text-slate-400 mb-4">{batch.batch_id}</p>
{batch.status === 'partial' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
{batch.files_done} file(s) landed and {batch.files_failed} failed. Check the failed
rows below &mdash; the rest of the batch was written.
</div>
)}
<div className="h-2 w-full rounded-full bg-ink-100 overflow-hidden mb-4">
<div
className="h-full rounded-full bg-amber-500 transition-all"
style={{
width: `${batch.files_total
? Math.round(((batch.files_done + batch.files_failed) / batch.files_total) * 100)
: 0}%`,
}}
/>
</div>
{batch.detail && <p className="mb-4 text-xs text-slate-600">{batch.detail}</p>}
{/* Per-file steps */}
<ul className="space-y-2">
{batch.files.map((file) => {
const Icon = FILE_ICON[file.status] || Clock;
return (
<li
key={`${file.index}-${file.filename}`}
className={`rounded-xl border p-3 ${FILE_TONE[file.status] || FILE_TONE.queued}`}
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 min-w-0">
<Icon className={`h-3.5 w-3.5 shrink-0 ${file.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-[11px] font-bold truncate">{file.filename}</span>
</span>
<span className="font-mono text-[10px] uppercase shrink-0">{file.status}</span>
</div>
{file.stage_index > 0 && file.status === 'running' && (
<p className="mt-1.5 text-[10px] font-mono">
Stage {file.stage_index}/{file.total_stages} &mdash; {file.stage_name}
{' · '}{file.rows_done}/{file.rows_total} rows
</p>
)}
{file.detail && (
<p className="mt-1.5 text-[10px] leading-relaxed">{file.detail}</p>
)}
</li>
);
})}
</ul>
{/* Batch totals, once anything has landed */}
{totals && (batch.files_done > 0 || batch.files_failed > 0) && (
<>
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<Stat label="Inserted" value={totals.inserted} tone="emerald" />
<Stat label="Backfilled" value={totals.backfilled} tone="blue" />
<Stat label="Unchanged" value={totals.skipped_existing} tone="slate" />
<Stat label="Rejected" value={totals.rejected} tone="maroon" />
<Stat label="Row errors" value={totals.error_count} tone="amber" />
</div>
{batch.brands?.length > 0 && (
<p className="mt-4 text-[11px] text-slate-500">
Brands touched:{' '}
<span className="font-bold text-ink-950">{batch.brands.join(', ')}</span>
</p>
)}
<IssueList
title="Rejected by the validation gate"
items={batch.files.flatMap((f) =>
(f.result?.rejections || []).map(
(r) => `${f.filename} · ${r.product_name} ${r.size || ''} — ${r.reason}`
)
)}
/>
<IssueList
title="Row errors"
items={batch.files.flatMap((f) =>
(f.result?.errors || []).map(
(e) => `${f.filename} · row ${e.row}: ${e.product_name || '(unnamed)'} — ${e.error}`
)
)}
/>
<IssueList
title="Warnings"
items={batch.files.flatMap((f) =>
(f.result?.warnings || []).map((w) => `${f.filename} · ${w}`)
)}
/>
</>
)}
</div>
)}
</div>
);
}
const TONES = {
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700',
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700',
slate: 'border-ink-900/10 bg-white text-slate-600',
maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600',
amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700',
};
function Stat({ label, value, tone }) {
return (
<div className={`rounded-xl border p-3 ${TONES[tone] || TONES.slate}`}>
<p className="text-[10px] font-semibold uppercase">{label}</p>
<p className="font-mono text-2xl font-extrabold text-ink-950 mt-0.5">{value ?? 0}</p>
</div>
);
}
function IssueList({ title, items }) {
if (!items || items.length === 0) return null;
return (
<div className="mt-4">
<h4 className="text-[11px] font-bold text-ink-950 mb-1.5">{title} ({items.length})</h4>
<ul className="max-h-40 overflow-y-auto space-y-1 pr-1">
{items.map((text, idx) => (
<li key={idx} className="text-[11px] text-slate-600 bg-white border border-ink-900/10 rounded-lg px-2.5 py-1.5">
{text}
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,550 @@
import React, { useMemo, useState } from 'react';
import {
Search, Sparkles, Loader2, AlertTriangle, PlayCircle, Database,
ShieldCheck, PackageSearch, Barcode, Store,
} from 'lucide-react';
import { api } from '../api/client';
/*
* A brand NAME -> the same 11-stage pipeline the spreadsheet uploader drives.
*
* Two steps, and the gap between them is the feature. Discover reads Open Food
* Facts and the brand's own shop and shows what it found, writing nothing. The
* operator unticks whatever looks wrong. Only then is the selection staged, as
* an ordinary catalog batch.
*
* GROUNDED SOURCES ONLY. Discovery used to offer the local language model as a
* third source, behind a "Language model" tick box. It was removed: asked to
* enumerate a brand, a small model invents plausible products, and nothing
* downstream can catch one - a well-formed fiction resolves a category, gets a
* price band and an SKU, and clears the validation gate. The catalog is meant
* to hold products that actually exist, so the only rows offered now are ones
* Open Food Facts or the brand's own storefront can vouch for. `use_llm: false`
* is sent explicitly below; see the note there.
*
* The review step stays regardless. A person reading the list is still the last
* check before anything is written, so the list is shown.
*
* WEB & RETAIL LISTINGS (optional, off by default). For brands Open Food Facts
* does not carry - non-food above all - the server can search retailer listings
* (Blinkit, Zepto, Instamart, BigBasket, JioMart, Amazon, Flipkart). It reads
* only the search engine's result titles and links, never a shop's page and
* never a language model, and keeps a product only when a real listing names
* the brand and states a pack size. That search takes minutes, so it runs as a
* job: Discover starts it, this panel polls it, then asks for the preview.
*
* A PRODUCT NAME WORKS TOO. "Lizol" resolves to Reckitt Benckiser and searches
* only Lizol listings; "Lizol Floor Cleaner" narrows to that line. The result
* is shown grouped - product line, then each variant (Citrus, Floral...), then
* the pack sizes real listings name - above the row-by-row table, and a size
* chip ticks or unticks the rows behind it.
*
* There is no progress UI here on purpose. Ingest returns a BatchOut, and
* AdminPage hands it to BatchCatalogPanel through the `adoptBatch` prop it
* already has - which draws the eleven stages, and owns Resume and Cancel.
*/
const EVIDENCE_TONE = {
openfacts: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
retail: 'bg-violet-500/10 text-violet-700 border-violet-500/20',
catalog: 'bg-sky-500/10 text-sky-700 border-sky-500/20',
registry: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
};
const EVIDENCE_LABEL = {
openfacts: 'Open Food Facts',
retail: 'retail listing',
catalog: 'already in catalog',
registry: 'known sub-brand',
};
const WEB_POLL_MS = 2500;
const WEB_FINISHED = new Set(['done', 'partial', 'failed', 'interrupted']);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// The badge for one row. A brand-store row has no `evidence` tier (the store IS
// the source), and used to fall through to "model only" - which it is not.
function evidenceBadge(product) {
if (product.evidence === 'retail' && product.retailer_count) {
const n = product.retailer_count;
return { tone: EVIDENCE_TONE.retail, label: `retail - ${n} shop${n === 1 ? '' : 's'}` };
}
if (product.evidence) {
return { tone: EVIDENCE_TONE[product.evidence], label: EVIDENCE_LABEL[product.evidence] };
}
if ((product.sources || []).includes('store')) {
return { tone: EVIDENCE_TONE.openfacts, label: "brand's own shop" };
}
return null;
}
// "Lizol" typed, "reckitt benckiser" resolved: say so, and say where rows go.
function resolvedNote(result) {
const typed = (result.brand || '').trim().toLowerCase();
const parent = (result.parent_brand || '').trim().toLowerCase();
if (!parent || typed === parent) return null;
const maker = result.parent_brand.split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
return `"${result.brand}" is a product of ${maker} - only its own listings were searched.`;
}
// Product line -> variant -> pack sizes. A size chip stands for the preview
// rows behind it (`rows`), and clicking it ticks or unticks all of them.
function Families({ families, selected, onToggleRows }) {
if (!families?.length) return null;
return (
<div className="space-y-3">
{families.map((family) => (
<div
key={family.product_line}
className="rounded-2xl border border-ink-900/10 bg-white px-4 py-3"
>
<div className="text-sm font-bold text-ink-950">{family.product_line}</div>
<div className="mt-2 space-y-1.5">
{family.variants.map((variant) => (
<div key={variant.variant || '-'} className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="min-w-24 font-semibold text-slate-700">
{variant.variant || 'Regular'}
</span>
{variant.sizes.map((size) => {
const rows = size.rows || [];
const on = rows.length > 0 && rows.every((i) => selected.has(i));
const shops = size.retailer_count
? `${size.retailer_count} shop${size.retailer_count === 1 ? '' : 's'}`
: '';
return (
<button
key={size.size}
type="button"
onClick={() => onToggleRows(rows, !on)}
title={[shops, size.price_range, (size.providers || []).join(', ')]
.filter(Boolean).join(' - ')}
className={`rounded-full border px-2 py-0.5 text-[11px] font-semibold cursor-pointer ${
on
? 'border-amber-500 bg-amber-500/15 text-ink-950'
: 'border-ink-900/10 bg-paper-50 text-slate-500'
}`}
>
{size.size}
{size.price_range ? ` · ${size.price_range}` : ''}
</button>
);
})}
</div>
))}
</div>
</div>
))}
</div>
);
}
function Toggle({ checked, onChange, label, hint }) {
return (
<label className="flex items-start gap-2 text-xs text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-amber-500"
/>
<span>
<span className="font-semibold text-ink-950">{label}</span>
{hint && <span className="block text-[11px] text-slate-500">{hint}</span>}
</span>
</label>
);
}
function Stat({ icon: Icon, value, label }) {
return (
<div className="flex items-center gap-2 rounded-xl border border-ink-900/10 bg-white px-3 py-2">
<Icon className="h-4 w-4 text-slate-400" />
<div>
<div className="text-sm font-bold text-ink-950">{value}</div>
<div className="text-[11px] text-slate-500">{label}</div>
</div>
</div>
);
}
export function BrandDiscoveryPanel({ onBatchStarted }) {
const [brand, setBrand] = useState('');
const [discovering, setDiscovering] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(null);
const [selected, setSelected] = useState(() => new Set());
const [useOpenfacts, setUseOpenfacts] = useState(true);
const [requireEvidence, setRequireEvidence] = useState(true);
const [fetchImages, setFetchImages] = useState(true);
// Off by default: it costs minutes of paced web searches.
const [useWeb, setUseWeb] = useState(false);
const [webJob, setWebJob] = useState(null);
// Set by the 409 the ingest route answers with when the brand sits outside
// ACTIVE_BRANDS. Staging anyway is a deliberate second press, never a default.
const [acknowledgeInactive, setAcknowledgeInactive] = useState(false);
const products = result?.products || [];
const counts = result?.counts || {};
const chosen = useMemo(
() => products.filter((_, index) => selected.has(index)),
[products, selected],
);
const discover = async () => {
if (!brand.trim()) return;
setDiscovering(true);
setError('');
setResult(null);
setAcknowledgeInactive(false);
setWebJob(null);
try {
let webJobId = null;
if (useWeb) {
let job = await api.startWebDiscovery(brand.trim());
setWebJob(job);
while (!WEB_FINISHED.has(job.status)) {
await sleep(WEB_POLL_MS);
job = await api.getWebDiscoveryJob(job.job_id);
setWebJob(job);
}
webJobId = job.job_id;
}
const body = await api.previewBrandDiscovery({
brand: brand.trim(),
use_openfacts: useOpenfacts,
use_web: useWeb,
web_job_id: webJobId,
// SENT, NOT OMITTED. DiscoveryPreviewRequest.use_llm defaults to True
// on the server, so leaving the key out would quietly re-enable the
// source this panel no longer exposes.
use_llm: false,
require_evidence: requireEvidence,
});
setResult(body);
// Pre-tick what discovery judged confident. A row nothing corroborates
// arrives unticked rather than hidden, so the operator decides rather
// than the default deciding for them.
setSelected(
new Set(
(body.products || [])
.map((p, index) => (p.selected ? index : null))
.filter((index) => index !== null),
),
);
} catch (e) {
setError(e.message || 'Discovery failed.');
} finally {
setDiscovering(false);
}
};
const ingest = async () => {
if (!chosen.length) return;
setStarting(true);
setError('');
try {
const batch = await api.ingestBrandDiscovery(
result.brand,
chosen.map((p) => ({
product_name: p.product_name,
title: p.title,
category: p.category,
description: p.description,
size_variants: p.size_variants,
providers: p.providers,
highlights: p.highlights,
nutrients: p.nutrients,
fssai_license: p.fssai_license,
barcode: p.barcode,
image_url: p.image_url,
product_line: p.product_line,
variant: p.variant,
// Web-found rows only; recorded as provenance after the batch.
...(p.listings?.length
? { listings: p.listings, price_range: p.price_range, retailer_count: p.retailer_count }
: {}),
})),
{ fetch_images: fetchImages, acknowledge_inactive: acknowledgeInactive },
);
onBatchStarted?.(batch);
} catch (e) {
// 409 is the ACTIVE_BRANDS refusal, and it is recoverable in place: the
// message names the exact .env line, and the button below re-sends with
// the acknowledgement.
if (e.status === 409) setAcknowledgeInactive(true);
setError(e.message || 'Could not start the ingestion.');
} finally {
setStarting(false);
}
};
const toggle = (index) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
};
const toggleRows = (rows, on) => {
setSelected((prev) => {
const next = new Set(prev);
rows.forEach((i) => (on ? next.add(i) : next.delete(i)));
return next;
});
};
const allSelected = products.length > 0 && selected.size === products.length;
return (
<div className="space-y-5">
{/* ---- the brand box ---- */}
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-5 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-500" />
<h2 className="text-sm font-bold text-ink-950">Brand Discovery</h2>
</div>
<p className="text-xs text-slate-600 max-w-3xl">
Enter any brand - one already in the catalog or a new one - or a
product such as Lizol, which is searched under its maker. Products
come from Open Food Facts, which supplies real names, pack sizes and
barcodes, and from the brand's own online shop where one is
registered. Nothing is written until you review the list and press
Ingest.
</p>
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-64">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={brand}
onChange={(e) => setBrand(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && discover()}
placeholder="Britannia, or a product like Lizol"
className="w-full rounded-xl border border-ink-900/10 bg-white py-2 pl-9 pr-3 text-sm text-ink-950 outline-none focus:border-amber-500"
/>
</div>
<button
onClick={discover}
disabled={discovering || !brand.trim()}
className="flex items-center gap-2 rounded-xl bg-amber-500 px-4 py-2 text-xs font-bold text-slate-950 disabled:opacity-40 cursor-pointer"
>
{discovering ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageSearch className="h-4 w-4" />
)}
{discovering
? webJob && !WEB_FINISHED.has(webJob.status)
? `Searching retailers ${webJob.queries_done}/${webJob.queries_total || '?'}...`
: 'Discovering...'
: 'Discover products'}
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Toggle
checked={useOpenfacts}
onChange={setUseOpenfacts}
label="Open Food Facts"
hint="Real products, with barcodes"
/>
<Toggle
checked={requireEvidence}
onChange={setRequireEvidence}
label="Require evidence"
hint="Drop model rows nothing corroborates"
/>
<Toggle
checked={fetchImages}
onChange={setFetchImages}
label="Fetch images on ingest"
hint="Slow; nothing fills them in later"
/>
<Toggle
checked={useWeb}
onChange={setUseWeb}
label="Web & retail listings"
hint="Blinkit, Zepto, Instamart, BigBasket, JioMart, Amazon, Flipkart - for brands Open Food Facts lacks. Takes a few minutes; results are cached."
/>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-xl border border-maroon-500/30 bg-maroon-100 px-4 py-3 text-xs text-maroon-600">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="whitespace-pre-wrap">{error}</span>
</div>
)}
{/* ---- what was found ---- */}
{result && (
<div className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<Stat icon={PackageSearch} value={counts.discovered ?? 0} label="products found" />
<Stat icon={ShieldCheck} value={counts.from_openfacts ?? 0} label="from Open Food Facts" />
<Stat icon={Barcode} value={counts.with_barcode ?? 0} label="with a real barcode" />
<Stat icon={Database} value={counts.already_in_catalog ?? 0} label="already in the catalog" />
{counts.from_web !== undefined && (
<Stat icon={Store} value={counts.from_web} label="from retail listings" />
)}
</div>
{resolvedNote(result) && (
<div className="flex items-start gap-2 rounded-xl border border-sky-500/30 bg-sky-500/10 px-4 py-3 text-xs text-sky-800">
<Search className="mt-0.5 h-4 w-4 shrink-0" />
<span>{resolvedNote(result)}</span>
</div>
)}
{(result.warnings || []).map((warning) => (
<div
key={warning}
className="flex items-start gap-2 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-800"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
</div>
))}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-slate-600">
Writing to <code className="font-mono text-ink-950">{result.table}</code>
{' - '}
<span className="font-semibold text-ink-950">{selected.size}</span> of{' '}
{products.length} selected
<button
onClick={() =>
setSelected(allSelected ? new Set() : new Set(products.map((_, i) => i)))
}
className="ml-2 underline cursor-pointer hover:text-ink-950"
>
{allSelected ? 'clear all' : 'select all'}
</button>
</div>
<button
onClick={ingest}
disabled={starting || !chosen.length}
className="flex items-center gap-2 rounded-xl bg-ink-950 px-4 py-2 text-xs font-bold text-white disabled:opacity-40 cursor-pointer"
>
{starting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PlayCircle className="h-4 w-4" />
)}
{acknowledgeInactive
? `Ingest ${chosen.length} anyway`
: `Ingest ${chosen.length} product${chosen.length === 1 ? '' : 's'}`}
</button>
</div>
<Families families={result.families} selected={selected} onToggleRows={toggleRows} />
<div className="overflow-x-auto rounded-2xl border border-ink-900/10 bg-white">
<table className="w-full text-xs">
<thead className="border-b border-ink-900/10 bg-paper-50 text-left text-slate-500">
<tr>
<th className="w-8 px-3 py-2" />
<th className="px-3 py-2 font-semibold">Product</th>
<th className="px-3 py-2 font-semibold">Pack</th>
<th className="px-3 py-2 font-semibold">Likely category</th>
<th className="px-3 py-2 font-semibold">Barcode</th>
<th className="px-3 py-2 font-semibold">Evidence</th>
</tr>
</thead>
<tbody>
{products.map((product, index) => (
<tr
key={`${product.product_name}-${product.size_variants?.[0] || index}`}
className="border-b border-ink-900/5 last:border-0 hover:bg-paper-50"
>
<td className="px-3 py-2">
<input
type="checkbox"
checked={selected.has(index)}
onChange={() => toggle(index)}
className="h-3.5 w-3.5 cursor-pointer accent-amber-500"
/>
</td>
<td className="px-3 py-2">
<div className="font-semibold text-ink-950">{product.product_name}</div>
{product.matches_existing && (
<div className="text-[11px] text-sky-700">
fills in the existing row
</div>
)}
{(product.notes || []).map((note) => (
<div key={note} className="text-[11px] text-slate-500">
{note}
</div>
))}
{product.listings?.length > 0 && (
<details className="text-[11px] text-slate-500">
<summary className="cursor-pointer">
{product.listings.length} listing{product.listings.length === 1 ? '' : 's'}
{product.price_range ? ` - ${product.price_range}` : ''}
</summary>
{product.listings.map((listing) => (
<a
key={listing.url}
href={listing.url}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-violet-700 underline"
title={listing.title}
>
{listing.retailer}: {listing.title}
</a>
))}
</details>
)}
</td>
<td className="px-3 py-2 text-slate-600">
{product.size_variants?.length
? product.size_variants.join(', ')
: <span className="text-slate-400">stage 4 will decide</span>}
</td>
<td className="px-3 py-2 text-slate-600">{product.category_hint || '-'}</td>
<td className="px-3 py-2 font-mono text-[11px] text-slate-600">
{product.barcode || <span className="text-slate-400">-</span>}
</td>
<td className="px-3 py-2">
{evidenceBadge(product) ? (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${evidenceBadge(product).tone}`}
>
{evidenceBadge(product).label}
</span>
) : (
<span className="rounded-full border border-ink-900/10 bg-ink-100 px-2 py-0.5 text-[10px] font-bold text-slate-500">
model only
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{products.length === 0 && (
<p className="text-xs text-slate-500">
Nothing was found for this brand. Open Food Facts may not carry
it - non-food brands are thin there - and no verified storefront
may be registered for it.{' '}
{useWeb
? 'No retailer listing named the brand with a pack size either.'
: 'Tick "Web & retail listings" to search retailer listings too.'} Discovery only offers products a real
source can vouch for, so an empty list means nothing was found,
not that something was hidden.
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -33,6 +33,10 @@ export function HomePage() {
const [productsLoading, setProductsLoading] = useState(false);
const [productsError, setProductsError] = useState(null);
const [hasSearched, setHasSearched] = useState(false);
// How the backend answered: 'brand_catalog' (a whole brand listing) or
// 'hybrid' (ranked matches). `total` is only exact for the former.
const [matchMode, setMatchMode] = useState(null);
const [total, setTotal] = useState(null);
const [selectedProduct, setSelectedProduct] = useState(null);
@@ -87,6 +91,8 @@ export function HomePage() {
setProductsLoading(true);
setProductsError(null);
setHasSearched(false);
setMatchMode(null);
setTotal(null);
const req = selectedBrand
? api.getBrandProducts(selectedBrand, { category: selectedCategory })
: api.getAllProducts({ category: selectedCategory });
@@ -117,9 +123,12 @@ export function HomePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBrand, selectedCategory, view]);
function runSearch() {
// Takes an optional override because React batches state updates: calling
// this straight after setQuery() would otherwise search the previous text.
function runSearch(overrideQuery) {
const q = (overrideQuery ?? query).trim();
setView('products');
if (!query.trim()) {
if (!q) {
browse();
return;
}
@@ -127,10 +136,12 @@ export function HomePage() {
setProductsLoading(true);
setProductsError(null);
setHasSearched(true);
api.search(query.trim(), { brand: selectedBrand, category: selectedCategory })
api.search(q, { brand: selectedBrand, category: selectedCategory })
.then((res) => {
if (id === fetchIdRef.current) {
setProducts(res.results || []);
setMatchMode(res.match_mode || null);
setTotal(res.total ?? null);
}
})
.catch((err) => {
@@ -145,6 +156,30 @@ export function HomePage() {
});
}
function handleSelectSuggestion(s) {
// Picking a suggestion is a filter action, not a text search, so clear the
// box: the browse effect above is gated on `!query`, and leaving text
// behind would both suppress the fetch and leave the header reading
// `Results for "..."` for a listing nobody searched for.
setQuery('');
setHasSearched(false);
setMatchMode(null);
setTotal(null);
setView('products');
if (s.type === 'category') {
// Deliberately does not also set a brand: the effect above clears
// selectedCategory whenever selectedBrand changes, which would wipe this.
setSelectedCategory(s.value);
return;
}
// Brand: the effect fetches the brand's whole catalog via
// /api/brands/{brand}/products, and the sidebar highlights it. That is the
// same row set the backend's brand_catalog mode returns for a typed
// brand name, so the two routes cannot disagree.
setSelectedBrand(s.value);
}
return (
<div className="flex h-screen">
<Sidebar
@@ -166,6 +201,7 @@ export function HomePage() {
query={query}
onQueryChange={setQuery}
onSubmit={runSearch}
onSelectSuggestion={handleSelectSuggestion}
health={health}
onResetHome={() => {
setSelectedBrand(null);
@@ -218,14 +254,20 @@ export function HomePage() {
: 'Browsing All Brands'}
</p>
</div>
{products.length > 0 && <p className="text-xs text-slate-500">{products.length} shown</p>}
{products.length > 0 && (
<p className="text-xs text-slate-500">
{total != null && total > products.length
? `Showing ${products.length} of ${total}`
: `${products.length} shown`}
</p>
)}
</div>
<ProductGrid
products={products}
loading={productsLoading}
error={productsError}
showSimilarity={hasSearched}
showSimilarity={hasSearched && matchMode !== 'brand_catalog'}
emptyTitle={brands.length === 0 ? 'No catalog data yet' : 'No products found'}
emptySubtitle={
brands.length === 0

View File

@@ -1,23 +1,28 @@
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, Eye, EyeOff } 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 [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleRoleSelect = (role) => {
setSelectedRole(role);
setError('');
};
const handleFormSubmit = async (e) => {
e.preventDefault();
setError('');
@@ -31,10 +36,9 @@ 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.
if (user.role === 'admin') navigate('/admin');
else navigate('/user');
// Only `admin` can sign in, and every route is admin-only, so there is
// one destination. Sending a non-admin to /login instead would loop.
navigate(user.role === 'admin' ? '/admin' : '/login');
} catch (err) {
setLoading(false);
// Surface the server's own message: it distinguishes bad credentials
@@ -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 &amp; 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,41 +79,63 @@ 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" />
{/* pr-11, unlike the username field's pr-4, keeps a long
password from running underneath the toggle button. */}
<input
type="password"
type={showPassword ? 'text' : '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-11 text-sm text-ink-950 placeholder-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
/>
{/*
type="button" is load-bearing: a <button> inside a <form>
defaults to type="submit", so peeking at the password would
fire a half-typed login attempt instead of revealing it.
The icon shows the ACTION, not the state - an open eye means
"reveal", which is the convention every browser and password
manager uses. aria-pressed carries the state for screen
readers, which the glyph alone cannot.
*/}
<button
type="button"
onClick={() => setShowPassword((shown) => !shown)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
title={showPassword ? 'Hide password' : 'Show password'}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1.5 text-slate-400 hover:text-slate-600 hover:bg-ink-900/5 focus:outline-none focus:ring-2 focus:ring-amber-500/25 transition-colors cursor-pointer"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<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 +143,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 &amp; Multi-Store Nutrition Intelligence Engine
</p>
</div>
</div>

View File

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

View File

@@ -0,0 +1,552 @@
import React, { useEffect, useRef, useState } from 'react';
import {
AlertTriangle, ArrowRight, CheckCircle2, Clock, FileSpreadsheet,
Loader2, PlayCircle, RefreshCw, Workflow, XCircle,
} from 'lucide-react';
import { api } from '../api/client';
import { FILE_ICON, FILE_TONE, STAGE_FLAGS, ago, fmtBytes } from './pipelineFormat';
import { FileStages, Stat } from './pipelineShared';
/*
* Admin -> Dagster Orchestration.
*
* Same starting point as the Review Inbox: spreadsheets a colleague sent
* through the upload API, ticked and started as one batch. Two things are
* different, and they are the reason this is its own screen.
*
* It hands the batch to DAGSTER rather than to this container's worker thread.
* Both run the identical eleven stages out of app/core/batch_ingest - Dagster
* adds lineage, retries and a re-run launchpad on a development machine. Which
* one owns a batch is recorded on the batch itself (`runner`), because both
* watch the same directory and, before that field existed, enabling the sensor
* beside a live API meant both ingested every batch twice.
*
* And it shows the whole pipeline, not just the current position. The Batch tab
* prints one line - "Stage 5/11 ..." - which disappears the moment a file
* finishes, so you can never see what a completed file actually did. Here every
* file carries its timeline: which stages ran, how long each took, how many rows
* each saw, kept after the run ends.
*
* Progress is read from the ordinary batch endpoints, NOT from Dagster. A run
* flushes its manifest to disk every few seconds and the API serves batches from
* that same manifest, so this screen follows a Dagster run in another process
* with no coupling between the two - and keeps working in production, where
* Dagster is not deployed at all.
*/
// The inbox half asks "has anything arrived?"; the run half asks "how far has
// it got?". Different questions, different cadences - and the run poll stops
// itself at a terminal status so a tab left open overnight goes quiet.
const INBOX_POLL_MS = 5000;
const RUN_POLL_MS = 3000;
const TERMINAL = new Set(['done', 'failed', 'partial', 'cancelled']);
// Recent runs is a PICKER, not a history: you click a row to load its eleven
// stages below, and three is enough to reach the run you came for. Capped at
// the request rather than sliced after, because this refetches every
// INBOX_POLL_MS and the endpoint over-fetches `limit * 10` manifests off disk
// before filtering - asking for 20 read 200 of them per tick to draw a list
// nobody scrolls.
const RECENT_LIMIT = 3;
export function OrchestrationPanel() {
const [inbox, setInbox] = useState(null);
const [recent, setRecent] = useState([]);
const [selected, setSelected] = useState(() => new Set());
const [batch, setBatch] = useState(null);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [useLlm, setUseLlm] = useState(true);
const [fetchImages, setFetchImages] = useState(true);
const batchIdRef = useRef(null);
const loadInbox = async () => {
try {
const next = await api.listInbox();
setInbox(next);
// Drop a selection the server no longer offers - another tab may have
// started or dismissed it, and a checkbox pointing at a file that is
// already gone would 409 on the next click with no explanation.
const live = new Set(next.submissions.flatMap((s) => s.files.map((f) => f.file_id)));
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
} catch (err) {
setError(err?.message || 'Could not read the inbox.');
}
};
// Runs that started without anyone here pressing anything.
//
// UPLOAD_AUTORUN sends a colleague's upload straight into the pipeline, so
// the inbox it used to wait in is empty and the run has an id this screen was
// never told. Without this list an auto-started batch is invisible: its
// eleven stages are recorded and served, and nothing renders them. Slim
// responses, so a row does not carry a whole product manifest with it.
const loadRecent = async () => {
try {
const { batches } = await api.listCatalogBatches(RECENT_LIMIT);
setRecent(batches || []);
} catch {
/* the inbox call surfaces auth/network trouble; one banner is enough */
}
};
const watch = (run) => {
setBatch(run);
batchIdRef.current = TERMINAL.has(run.status) ? null : run.batch_id;
};
useEffect(() => {
loadInbox();
loadRecent();
const timer = setInterval(() => {
loadInbox();
loadRecent();
}, INBOX_POLL_MS);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const timer = setInterval(async () => {
const id = batchIdRef.current;
if (!id) return;
try {
const next = await api.getCatalogBatch(id);
setBatch(next);
// A dagster batch sits `queued` until the orchestrator claims it, which
// is not a terminal state - keep watching it, that wait is the thing
// the operator is here to see.
if (TERMINAL.has(next.status)) batchIdRef.current = null;
} catch {
/* a dropped poll is not worth surfacing; the next tick retries */
}
}, RUN_POLL_MS);
return () => clearInterval(timer);
}, []);
const toggle = (fileId) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(fileId)) next.delete(fileId);
else next.add(fileId);
return next;
});
};
const toggleDrop = (submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.every((id) => selected.has(id));
setSelected((current) => {
const next = new Set(current);
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
return next;
});
};
const handleStart = async () => {
if (!selected.size) return;
setBusy('start');
setError('');
setNotice('');
try {
const started = await api.startBatchFromInbox([...selected], {
use_llm: useLlm,
fetch_images: fetchImages,
runner: 'dagster',
});
setSelected(new Set());
setBatch(started);
batchIdRef.current = started.batch_id;
setNotice(
`Staged ${started.files_total} file(s) as one batch for Dagster. It will start ` +
`when the orchestrator picks it up.`
);
await loadInbox();
} catch (err) {
setError(err?.message || 'Could not stage those files.');
await loadInbox();
} finally {
setBusy('');
}
};
// The way out of a batch nothing came for. Resume hands it to this
// container's worker AND takes ownership, so Dagster will not also claim it.
const handleRunHere = async () => {
if (!batch) return;
setBusy('resume');
setError('');
try {
const resumed = await api.resumeCatalogBatch(batch.batch_id);
setBatch(resumed);
batchIdRef.current = resumed.batch_id;
setNotice('Running this batch here instead. Dagster will no longer pick it up.');
} catch (err) {
setError(err?.message || 'Could not run the batch here.');
} finally {
setBusy('');
}
};
const submissions = inbox?.submissions || [];
const pending = inbox?.pending_count ?? 0;
const running = batch && !TERMINAL.has(batch.status);
// Queued AND owned by Dagster means nothing has claimed it: either the
// orchestrator is not running, or its sensor has not ticked yet.
const awaitingOrchestrator =
batch && batch.runner === 'dagster' && batch.status === 'queued';
// Nothing ticked means "Start selected" cannot fire, which makes the two
// stage toggles below inert. On a deployment running UPLOAD_AUTORUN=true that
// is the permanent state - the inbox never fills - so they stay greyed, which
// is exactly the point: they must not read as settings that govern uploads.
const stagesIdle = !selected.size;
return (
<div className="space-y-6">
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<Workflow className="h-5 w-5 text-amber-500" /> Dagster Orchestration
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
Spreadsheets sent in by a colleague through the upload API. Those uploads now
start on their own &mdash; pick one under <strong>Recent runs</strong> to follow
its stages. Anything still waiting for a decision appears below, where you can
tick files and press <strong>Start selected</strong> to send them through the
11-stage pipeline as one batch, run by Dagster instead of this server&apos;s own
worker. Every stage each file passes through is recorded and kept after the run
finishes.
</p>
</div>
<button
type="button"
onClick={loadInbox}
aria-label="Refresh the inbox"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleStart}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'start'
? <Loader2 className="h-4 w-4 animate-spin" />
: <PlayCircle className="h-4 w-4" />}
Start selected{selected.size ? ` (${selected.size})` : ''}
<ArrowRight className="h-3.5 w-3.5" />
</button>
<span className="text-[11px] text-slate-400 font-mono">
{pending} file(s) awaiting review
</span>
</div>
{/* THESE TWO GOVERN THE MANUAL START BELOW, AND NOTHING ELSE.
An upload that ran itself never passed through here: POST
/api/uploads/catalog reads UPLOAD_AUTORUN_FETCH_IMAGES and
UPLOAD_AUTORUN_USE_LLM on the server and ignores whatever the
request asked for. What a given run actually used is on its own row
under Recent runs. */}
<fieldset
disabled={stagesIdle}
className={`mt-4 rounded-xl border border-ink-900/10 bg-white p-3 transition ${
stagesIdle ? 'opacity-40' : ''
}`}
>
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
Applies to Start selected
{stagesIdle && (
<span className="font-normal normal-case"> &mdash; tick a file below first</span>
)}
</legend>
<div className="flex flex-col gap-2">
<label
className={`flex items-start gap-2 text-[11px] text-slate-600 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
checked={fetchImages}
disabled={stagesIdle}
onChange={(e) => setFetchImages(e.target.checked)}
className={`mt-0.5 accent-amber-500 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
/>
<span>
<span className="font-bold text-ink-950">Search for product images</span>
{' '}&mdash; much slower, and makes outbound requests for every row.
</span>
</label>
<label
className={`flex items-start gap-2 text-[11px] text-slate-600 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<input
type="checkbox"
checked={useLlm}
disabled={stagesIdle}
onChange={(e) => setUseLlm(e.target.checked)}
className={`mt-0.5 accent-amber-500 ${
stagesIdle ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
/>
<span>
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
{' '}&mdash; fills blank descriptions where an Ollama server is reachable. It is not
in production, so rows there simply keep the description the sheet gave them.
</span>
</label>
</div>
</fieldset>
{notice && (
<div className="mt-4 rounded-lg bg-emerald-500/5 border border-emerald-500/20 p-3 text-xs text-emerald-700 flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" /> {notice}
</div>
)}
{error && (
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
</div>
)}
</div>
{/* ---- runs that started without anyone pressing anything -------- */}
{recent.length > 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-5 shadow-xs">
<h3 className="font-display text-sm font-bold text-ink-950 mb-1">Recent runs</h3>
<p className="text-[11px] text-slate-500 mb-3">
Uploads start on arrival, so this is where a colleague&apos;s files show up.
Pick one to see its eleven stages.
</p>
<ul className="space-y-1.5">
{recent.map((run) => {
const Icon = FILE_ICON[run.status] || Clock;
const live = !TERMINAL.has(run.status);
return (
<li key={run.batch_id}>
<button
type="button"
onClick={() => watch(run)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg border text-left transition cursor-pointer hover:border-amber-500/40 ${
batch?.batch_id === run.batch_id
? 'border-amber-500/40 bg-amber-500/5'
: 'border-ink-900/10 bg-white'
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${live ? 'text-amber-600' : 'text-slate-400'} ${
run.status === 'running' ? 'animate-spin' : ''
}`}
/>
<span className="font-mono text-[11px] text-slate-500 shrink-0">
{run.batch_id.slice(0, 8)}
</span>
{/* The sender used to be appended here as "from <name>".
Taken out deliberately: this list is for picking a run to
follow, and who sent it is not what you pick on. Upload
Results still names the sender - identifying whose upload
it is IS that screen's job. */}
<span className="text-xs text-ink-950 truncate flex-1">
{run.files_total} file(s)
</span>
{/* Read off the manifest, not assumed from .env, so it stays
true if a setting is changed without a redeploy. */}
<span className="shrink-0 hidden sm:flex items-center gap-1">
{STAGE_FLAGS.map(({ key, label, title }) => (
<span
key={key}
title={`${title}: ${run[key] ? 'ran' : 'skipped'}`}
className={`px-1.5 py-0.5 rounded border font-mono text-[10px] font-bold ${
run[key]
? 'border-amber-500/20 bg-amber-500/10 text-amber-700'
: 'border-ink-900/10 bg-paper-100 text-slate-400 line-through'
}`}
>
{label}
</span>
))}
</span>
<span
className={`shrink-0 px-2 py-0.5 rounded-md border text-[10px] font-bold ${
FILE_TONE[run.status] || FILE_TONE.queued
}`}
>
{run.status}
</span>
<span className="shrink-0 text-[10px] text-slate-400 font-mono w-16 text-right">
{ago(run.created_at)}
</span>
</button>
</li>
);
})}
</ul>
</div>
)}
{/* ---- the drops waiting to be started --------------------------- */}
{inbox && submissions.length === 0 && !batch && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Workflow className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
Nothing waiting for review &mdash; files sent to{' '}
<code className="font-mono">/api/uploads/catalog</code> start on their own and
appear under Recent runs.
</p>
<p className="text-[11px] text-slate-400 mt-2">
Set <code className="font-mono">UPLOAD_AUTORUN=false</code> to hold them here for
a decision instead.
</p>
</div>
)}
{submissions.map((submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.length > 0 && ids.every((id) => selected.has(id));
return (
<div
key={submission.submission_id}
className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs"
>
<div className="flex items-center justify-between gap-3 mb-4">
<h3 className="font-display text-sm font-bold text-ink-950">
from <span className="text-amber-600">{submission.submitted_by}</span>
<span className="ml-2 font-normal text-slate-400">
{ago(submission.created_at)} &middot; {submission.files.length} file(s)
</span>
</h3>
<button
type="button"
onClick={() => toggleDrop(submission)}
className="shrink-0 text-[11px] font-bold text-slate-500 hover:text-ink-950 transition cursor-pointer"
>
{allOn ? 'Clear all' : 'Select all'}
</button>
</div>
<ul className="space-y-1.5">
{submission.files.map((file) => (
<li key={file.file_id}>
<label className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-ink-900/10 bg-white cursor-pointer hover:border-amber-500/40 transition">
<input
type="checkbox"
checked={selected.has(file.file_id)}
onChange={() => toggle(file.file_id)}
className="accent-amber-500 cursor-pointer"
/>
<FileSpreadsheet className="h-4 w-4 text-amber-600 shrink-0" />
<span className="text-[11px] font-bold text-ink-950 truncate flex-1">
{file.filename}
</span>
<span className="font-mono text-[10px] text-slate-400 shrink-0">
{file.rows_total} rows
</span>
<span className="font-mono text-[10px] text-slate-300 shrink-0 w-16 text-right">
{fmtBytes(file.size_bytes)}
</span>
</label>
</li>
))}
</ul>
</div>
);
})}
{/* ---- the run ---------------------------------------------------- */}
{batch && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-center justify-between gap-3 mb-1">
<h3 className="font-display text-sm font-bold text-ink-950 flex items-center gap-2">
{batch.status === 'done' && <CheckCircle2 className="h-4 w-4 text-leaf-600" />}
{batch.status === 'partial' && <AlertTriangle className="h-4 w-4 text-amber-600" />}
{batch.status === 'failed' && <XCircle className="h-4 w-4 text-maroon-600" />}
{batch.status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-amber-600" />}
Run &mdash; {batch.files_done}/{batch.files_total} file(s) done
</h3>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 border border-amber-500/30 px-2.5 py-0.5 text-[10px] font-bold text-amber-600">
<Workflow className="h-3 w-3" /> {batch.runner}
</span>
<span className="font-mono text-[11px] uppercase text-slate-400">{batch.status}</span>
</div>
</div>
<p className="font-mono text-[10px] text-slate-400 mb-4">{batch.batch_id}</p>
{awaitingOrchestrator && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700">
<p className="flex items-start gap-2">
<Clock className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Staged and waiting for the Dagster orchestrator to claim it. That happens
within a minute of <code className="font-mono">batch_upload_sensor</code>{' '}
running &mdash; if Dagster is not up, which is the case in production, this
batch will wait indefinitely.
</span>
</p>
<button
type="button"
onClick={handleRunHere}
disabled={busy === 'resume'}
className="mt-2.5 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500 text-slate-950 text-[11px] font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'resume'
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <PlayCircle className="h-3.5 w-3.5" />}
Run here instead
</button>
</div>
)}
<ul className="space-y-3">
{batch.files.map((file) => (
<FileStages
key={`${file.index}-${file.filename}`}
file={file}
stageNames={batch.stage_names || []}
/>
))}
</ul>
{batch.totals && (
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<Stat label="Rows read" value={batch.totals.rows_total} tone="slate" />
<Stat label="Products built" value={batch.totals.products_built} tone="blue" />
<Stat label="Inserted" value={batch.totals.inserted} tone="emerald" />
<Stat label="Backfilled" value={batch.totals.backfilled} tone="amber" />
<Stat label="Row errors" value={batch.totals.error_count} tone="maroon" />
</div>
)}
</div>
)}
{!inbox && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
</div>
)}
{running && (
<p className="text-[11px] text-slate-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
A Dagster run reports through the same manifest this page reads, flushed every few
seconds &mdash; so progress here can lag the Dagster UI at :3030 by a tick or two.
</p>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,783 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertTriangle, Ban, CheckCircle2, ClipboardCheck, Clock, FileSpreadsheet,
Loader2, PlayCircle, RefreshCw, XCircle, ArrowRight,
} from 'lucide-react';
import { api } from '../api/client';
import { ago, fmtBytes } from './pipelineFormat';
import { FileStages } from './pipelineShared';
/*
* Admin -> Upload Results.
*
* What happened to the last file a colleague sent. No picking, no clicking:
* the newest colleague upload is found and followed automatically, all eleven
* stages, down to which rows the validation gate refused and why.
*
* THREE THINGS, IN THIS ORDER: the file's NAME, its ELEVEN STAGES, and the ROWS
* THAT FAILED. That ordering is the brief, and it is why the run counters, the
* "brands touched" line, the img/llm flag chips and the batch id were taken out
* - each was true, none was the question being asked, and together they pushed
* the filename down to eleven-point text in the middle of the card. What is
* left besides those three is the handful of banners (queued, interrupted,
* partial, storage error) that exist to explain a timeline which is entirely
* grey; without them an interrupted run reads as a broken screen rather than as
* a run waiting for a Resume.
*
* There is no history here and there is not meant to be one: exactly one run is
* ever on screen, the newest, replaced in place as newer ones arrive.
*
* THIS TAB USED TO BE THE REVIEW INBOX, and the inbox is still here - see the
* gate section below. It is just no longer the point of the screen, because
* with UPLOAD_AUTORUN at its default of true a colleague's upload starts on
* arrival and never waits for review, so the inbox is permanently empty and the
* tab showed nothing but an empty state pointing at another tab.
*
* HOW THIS DIFFERS FROM DAGSTER ORCHESTRATION, which also draws stage
* timelines: that screen is for a run YOU launched - you tick files and watch
* the batch you started, including your own admin uploads. This one answers the
* opposite question, about a run nobody here started, and it is the only place
* that shows the per-row rejection reasons a sender needs to fix their sheet.
*
* WHICH RUN IS "THE COLLEAGUE'S". `submitted_by` is the discriminator and it is
* exact, not a heuristic: POST /api/uploads/catalog always sets it (falling back
* to the `sender` form label and then to "anonymous"), and the admin's own
* POST /api/admin/catalog-batch/ingest never passes it at all, so it is null
* there. A batch released from the inbox carries the original senders, which is
* correct - it is still their upload, just started by hand.
*
* IT IS A PREFERENCE, NOT A GATE. It used to be a gate, and that made this tab
* a dead end: a colleague who walked over and used the Batch Catalog Ingestion
* tab instead of the API produced a run with a null `submitted_by`, which was
* filtered out permanently however recent it was, and the screen said "No
* colleague upload yet" - true of the field and false of the world. So when the
* window holds no colleague upload we now adopt the newest run of any origin
* and SAY SO, rather than showing nothing. The exactness above is what makes
* that label honest: a null sender means the Admin UI started it.
*
* AND A FAILED LOOKUP IS NOT AN EMPTY ONE. Every discovery error used to be
* swallowed into that same empty state, so an expired session, a 403, a dead
* backend and a genuinely quiet week were one indistinguishable sentence.
* `discoverError` now separates them. An error still never wipes a run already
* on screen - a blip must not blank a report someone is reading - it annotates
* it instead.
*/
// Two questions at two cadences. "Has anything new arrived?" is cheap and slow;
// "how far has the run I am watching got?" is the one that needs to feel live,
// and it stops itself at a terminal status so a tab left open overnight goes
// quiet. Both are cleared on unmount.
const DISCOVER_POLL_MS = 10000;
const RUN_POLL_MS = 3000;
// How far back to look for a colleague upload. The endpoint returns ALL runs
// newest-first, admin batches included, so a window of 20 could be filled by
// the admin's own uploads and hide the colleague's. Slim rows are not tiny -
// they keep stages[], rejections and errors - so ask for 50 on the steady-state
// tick and widen to the endpoint's ceiling only when nothing was found.
const DISCOVER_LIMIT = 50;
const DISCOVER_MAX_LIMIT = 100;
// When to stop following a run. Wider than the Orchestration tab's set
// (OrchestrationPanel.jsx), which omits both `retired` and `interrupted`.
//
// `interrupted` is not terminal on the backend - a Resume can still move it -
// but nothing will move it on its own, so a 3s poll would hammer a batch that
// cannot change until an operator acts. Park on it; the 10s discovery tick
// notices if BATCH_AUTO_RESUME re-queues it after a restart.
const SETTLED = new Set([
'done', 'failed', 'partial', 'cancelled', 'retired', 'interrupted',
]);
const STATUS_ICON = {
done: CheckCircle2,
partial: AlertTriangle,
failed: XCircle,
running: Loader2,
interrupted: AlertTriangle,
};
const STATUS_TONE = {
done: 'text-leaf-600',
partial: 'text-amber-600',
failed: 'text-maroon-600',
running: 'text-amber-600',
interrupted: 'text-amber-600',
};
/* Is this row a colleague's upload, and does it have anything to show?
*
* `submitted_by` alone is not enough. GET /batches drops only PENDING rows
* (batch_catalog.py), so a drop that was dismissed outright survives the filter
* as a RETIRED husk - it still carries the sender's name, but every file was
* discarded, so it has no result and no stage timeline. Without the second
* clause, dismissing a drop makes that husk "the latest upload" and the panel
* renders eleven grey rows that will never fill in.
*
* A drop that was RELEASED rather than dismissed is harmless either way: the
* run it produced is staged before the drop is retired, so the run is newer and
* wins the ordering regardless. */
function isColleagueUpload(batch) {
return (
typeof batch?.submitted_by === 'string' &&
batch.submitted_by.trim() !== '' &&
batch.status !== 'retired'
);
}
/* The fallback: any run at all, as long as it is not a retired husk.
*
* Same second clause as above and for the same reason - a dismissed drop keeps
* its row but has no stages and no result, so adopting one would draw eleven
* grey rows that can never fill in. `pending` needs no clause here: GET
* /batches drops those server-side (batch_catalog.py). */
function isShowableRun(batch) {
return Boolean(batch) && batch.status !== 'retired';
}
function IssueList({ title, items, tone = 'slate' }) {
if (!items.length) return null;
const border =
tone === 'maroon' ? 'border-maroon-500/25 bg-maroon-100/30' : 'border-ink-900/10 bg-white';
return (
<div className={`mt-4 rounded-xl border p-3 ${border}`}>
<p className="text-[10px] font-bold uppercase tracking-wide text-slate-500 mb-1.5">
{title} <span className="font-mono opacity-60">({items.length})</span>
</p>
<ul className="space-y-1">
{items.map((text, i) => (
<li key={i} className="text-[11px] leading-relaxed text-slate-600">
{text}
</li>
))}
</ul>
</div>
);
}
export function UploadResultsPanel({ onBatchStarted }) {
// --- the results half ---------------------------------------------------
const [run, setRun] = useState(null);
const [discovered, setDiscovered] = useState(false); // first discovery done
// Why the panel is empty, when it is. '' means the last lookup succeeded.
const [discoverError, setDiscoverError] = useState('');
// How many runs that lookup examined, so an empty screen can say "looked at
// 40 runs, none of them showable" rather than an unqualified "nothing".
const [scanned, setScanned] = useState(0);
const runIdRef = useRef(null);
// Whether the widened lookup has already been spent - see discover().
const escalatedRef = useRef(false);
// --- the gate half (only rendered when something is actually waiting) ----
const [inbox, setInbox] = useState(null);
const [selected, setSelected] = useState(() => new Set());
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [useLlm, setUseLlm] = useState(true);
const [fetchImages, setFetchImages] = useState(true);
/* Find the run to show, and read the inbox in the same tick.
*
* A colleague upload is preferred; failing that, the newest run of any
* origin, so the tab is never a dead end. See the header comment.
*
* A failed poll never replaces a run already on screen - a transient blip
* must not wipe a report the admin is reading, and the next tick retries.
* But it IS recorded in `discoverError`, and the two renderings differ: with
* a run up it is a staleness strip, with nothing up it is the whole card.
* Silently treating a failure as "no uploads" is the bug this tab had.
*
* The inbox read is kept separate and stays silent. It is the secondary half
* of the screen and its failure must not blank the results. */
const discover = useCallback(async () => {
try {
let { batches } = await api.listCatalogBatches(DISCOVER_LIMIT);
let latest = (batches || []).find(isColleagueUpload);
// Nothing in the window. Before concluding there is no colleague upload,
// widen once to the endpoint's ceiling - a burst of admin batches can push
// a real one past 50. Guarded so it happens at most once per mount (and
// once per manual Refresh), never on the steady-state path where a run is
// already on screen.
if (!latest && !escalatedRef.current) {
escalatedRef.current = true;
({ batches } = await api.listCatalogBatches(DISCOVER_MAX_LIMIT));
latest = (batches || []).find(isColleagueUpload);
}
// No colleague upload in reach. Show the newest run there is rather than
// an empty screen - see the header comment. The label that goes with it is
// derived at render from `submitted_by`, which is exact.
const chosen = latest || (batches || []).find(isShowableRun) || null;
setScanned((batches || []).length);
setDiscoverError('');
if (chosen && chosen.batch_id !== runIdRef.current) {
runIdRef.current = chosen.batch_id;
// Show the slim row immediately - it already carries stages[] and the
// counters, because slim strips only result.products - then let the
// detail poll replace it with the full record.
setRun(chosen);
} else if (!chosen) {
runIdRef.current = null;
setRun(null);
}
} catch (err) {
// Deliberately NOT clearing `run`: a transient blip must not wipe a
// report someone is reading. The strip below says it may be stale.
setDiscoverError(err?.message || 'Could not read the list of ingestion runs.');
}
try {
setInbox(await api.listInbox());
} catch {
/* next tick retries */
}
setDiscovered(true);
}, []);
useEffect(() => {
discover();
const timer = setInterval(discover, DISCOVER_POLL_MS);
return () => clearInterval(timer);
}, [discover]);
// An explicit retry buys back the widened lookup, so an admin who knows a
// colleague just uploaded can force the deep search. Shared by the header's
// Refresh button and the error card below, which must behave identically.
const retry = useCallback(() => {
escalatedRef.current = false;
discover();
}, [discover]);
// Follow the watched run until it settles. Re-created whenever the id or the
// status changes, so it stops itself the moment the run settles rather than
// polling a finished batch forever.
const runId = run?.batch_id;
const runStatus = run?.status;
useEffect(() => {
if (!runId || SETTLED.has(runStatus)) return undefined;
let alive = true;
const tick = async () => {
try {
const full = await api.getCatalogBatch(runId);
if (alive && full?.batch_id === runIdRef.current) setRun(full);
} catch {
/* next tick retries */
}
};
tick();
const timer = setInterval(tick, RUN_POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [runId, runStatus]);
// A run that was already settled when we adopted it never enters the poll
// above, so fetch its full record once. A run that settles while being
// followed already had its last fetch from that poll.
useEffect(() => {
if (!runId || !SETTLED.has(runStatus)) return undefined;
let alive = true;
(async () => {
try {
const full = await api.getCatalogBatch(runId);
if (alive && full?.batch_id === runIdRef.current) setRun(full);
} catch {
/* the slim row is already on screen; it is enough */
}
})();
return () => {
alive = false;
};
// Deliberately keyed on the id alone: re-running this on every status
// change would re-fetch a settled batch for no reason.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId]);
// --- gate actions (unchanged behaviour, just conditionally rendered) -----
const toggle = (fileId) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(fileId)) next.delete(fileId);
else next.add(fileId);
return next;
});
};
const toggleDrop = (submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.every((id) => selected.has(id));
setSelected((current) => {
const next = new Set(current);
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
return next;
});
};
const handleStart = async () => {
if (!selected.size) return;
setBusy('start');
setError('');
setNotice('');
try {
const batch = await api.startBatchFromInbox([...selected], {
use_llm: useLlm,
fetch_images: fetchImages,
});
setSelected(new Set());
setNotice(
`Started ${batch.files_total} file(s) as one batch. Follow it on the ` +
`Batch Catalog Ingestion tab.`
);
await discover();
if (onBatchStarted) onBatchStarted(batch);
} catch (err) {
setError(err?.message || 'Could not start those files.');
await discover();
} finally {
setBusy('');
}
};
const handleDismiss = async () => {
if (!selected.size) return;
setBusy('dismiss');
setError('');
setNotice('');
try {
const result = await api.dismissInboxFiles([...selected]);
setSelected(new Set());
setNotice(`Dismissed ${result.dismissed} file(s). They will not be processed.`);
await discover();
} catch (err) {
setError(err?.message || 'Could not dismiss those files.');
await discover();
} finally {
setBusy('');
}
};
// Drop any selection that is no longer pending - another tab may have started
// or dismissed it, and a checkbox pointing at a file that is already gone
// would 409 on the next click with no explanation.
useEffect(() => {
if (!inbox) return;
const live = new Set(inbox.submissions.flatMap((s) => s.files.map((f) => f.file_id)));
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
}, [inbox]);
const pending = inbox?.pending_count ?? 0;
const submissions = inbox?.submissions || [];
// Exact, not a guess: only POST /api/admin/catalog-batch/ingest leaves the
// sender null - uploads.py always sets one, and a release from the inbox
// carries the original senders through.
const fromColleague = isColleagueUpload(run);
const files = run?.files || [];
/* The answer to "what did they send?", promoted to the headline.
*
* A drop can carry several files and each still gets its own named row in the
* timeline below, so naming the first and counting the rest loses nothing.
* `current_file` is the fallback for the window where a batch exists but its
* file list has not been rendered yet. */
const headlineName = files[0]?.filename || run?.current_file || 'file name unavailable';
const extraFiles = Math.max(0, files.length - 1);
const rejections = files.flatMap((f) =>
(f.result?.rejections || []).map((r) => {
const where = r.row == null ? 'row unknown' : `row ${r.row}`;
const size = r.size ? ` ${r.size}` : '';
return `${where} · ${r.product_name || '(unnamed)'}${size} — ${r.reason}`;
})
);
const rowErrors = files.flatMap((f) =>
(f.result?.errors || []).map(
(e) => `row ${e.row} · ${e.product_name || '(unnamed)'} — ${e.error}`
)
);
const warnings = files.flatMap((f) => (f.result?.warnings || []).map((w) => w));
const storageErrors = files
.filter((f) => f.result?.storage_error)
.map((f) => `${f.filename} — ${f.result.storage_error}`);
const StatusIcon = STATUS_ICON[run?.status] || Clock;
return (
<div className="space-y-6">
{/* ---- the review gate, only when something is actually waiting ------
With UPLOAD_AUTORUN on this never renders. Turn the setting off and
it comes back on its own, which is the behaviour settings.py promises
- "the review inbox comes back with no code change". */}
{pending > 0 && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-6 shadow-xs">
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-amber-600" /> Waiting for review
</h2>
<p className="text-xs text-slate-600 leading-relaxed max-w-3xl">
<code className="font-mono">UPLOAD_AUTORUN</code> is off, so these have been held
rather than run. Tick what you want and press <strong>Start selected</strong> to send
them through the 11-stage pipeline as one batch. Files from different drops can go in
the same batch.
</p>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleStart}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'start'
? <Loader2 className="h-4 w-4 animate-spin" />
: <PlayCircle className="h-4 w-4" />}
Start selected{selected.size ? ` (${selected.size})` : ''}
<ArrowRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleDismiss}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-slate-600 hover:text-maroon-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'dismiss'
? <Loader2 className="h-4 w-4 animate-spin" />
: <Ban className="h-4 w-4" />}
Dismiss
</button>
<span className="text-[11px] text-slate-500 font-mono">
{pending} file(s) awaiting review
</span>
</div>
{/* These govern the manual start above and nothing else. An upload
that ran itself never passed through here - the server reads
UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM and ignores
whatever a request asked for. What a given run actually used is
the img/llm pair on its own header below. */}
<fieldset className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3">
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
Applies to Start selected
</legend>
<div className="flex flex-col gap-2">
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={fetchImages}
onChange={(e) => setFetchImages(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Search for product images</span>
{' '}&mdash; much slower, and makes outbound requests for every row.
</span>
</label>
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={useLlm}
onChange={(e) => setUseLlm(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
{' '}&mdash; fills blank descriptions where an Ollama server is reachable.
</span>
</label>
</div>
</fieldset>
{notice && (
<div className="mt-4 rounded-lg bg-emerald-500/5 border border-emerald-500/20 p-3 text-xs text-emerald-700 flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" /> {notice}
</div>
)}
{error && (
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
</div>
)}
<div className="mt-4 space-y-3">
{submissions.map((submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.length > 0 && ids.every((id) => selected.has(id));
return (
<div
key={submission.submission_id}
className="rounded-xl border border-ink-900/10 bg-white p-4"
>
<div className="flex items-center justify-between gap-3 mb-3">
<h3 className="font-display text-xs font-bold text-ink-950">
from <span className="text-amber-600">{submission.submitted_by}</span>
<span className="ml-2 font-normal text-slate-400">
{ago(submission.created_at)} &middot; {submission.files.length} file(s)
</span>
</h3>
<button
type="button"
onClick={() => toggleDrop(submission)}
className="shrink-0 text-[11px] font-bold text-slate-500 hover:text-ink-950 transition cursor-pointer"
>
{allOn ? 'Clear all' : 'Select all'}
</button>
</div>
<ul className="space-y-1.5">
{submission.files.map((file) => (
<li key={file.file_id}>
<label className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-ink-900/10 bg-paper-50 cursor-pointer hover:border-amber-500/40 transition">
<input
type="checkbox"
checked={selected.has(file.file_id)}
onChange={() => toggle(file.file_id)}
className="accent-amber-500 cursor-pointer"
/>
<FileSpreadsheet className="h-4 w-4 text-amber-600 shrink-0" />
<span className="text-[11px] font-bold text-ink-950 truncate flex-1">
{file.filename}
</span>
<span className="font-mono text-[10px] text-slate-400 shrink-0">
{file.rows_total} rows
</span>
<span className="font-mono text-[10px] text-slate-300 shrink-0 w-16 text-right">
{fmtBytes(file.size_bytes)}
</span>
</label>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
)}
{/* ---- what the page is for ---------------------------------------- */}
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<ClipboardCheck className="h-5 w-5 text-amber-500" /> Upload Results
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
The most recent spreadsheet a colleague sent to{' '}
<code className="font-mono">/api/uploads/catalog</code>, and everything the
11-stage pipeline did with it &mdash; including which rows were refused and why.
It updates on its own; there is nothing to pick. When no colleague upload is in
reach it falls back to the most recent run of any kind, and says so, rather than
showing you an empty screen.
</p>
</div>
<button
type="button"
onClick={retry}
aria-label="Check for a newer upload"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</div>
</div>
{/* ---- the run ------------------------------------------------------ */}
{!discovered && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
</div>
)}
{/* Nothing on screen AND the lookup failed. This is the case that used to
render as "No colleague upload yet", sending people to look for an
upload that had arrived perfectly well - the request for it 401'd. */}
{discovered && !run && discoverError && (
<div className="rounded-2xl border border-maroon-500/30 bg-maroon-100/30 p-10 text-center">
<XCircle className="h-8 w-8 text-maroon-600/60 mx-auto mb-3" />
<p className="text-xs font-bold text-maroon-600">Could not read the ingestion runs</p>
<p className="mt-1.5 font-mono text-[11px] text-maroon-600">{discoverError}</p>
<p className="mt-3 max-w-md mx-auto text-[11px] leading-relaxed text-slate-500">
This is not the same as having no uploads &mdash; nothing has been checked. If that
reads as expired or forbidden, sign in again; otherwise the backend is unreachable.
</p>
<button
type="button"
onClick={retry}
className="mt-4 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" /> Try again
</button>
</div>
)}
{/* Gate open but nothing has run: "no upload yet" would be flatly false -
there is one, it is sitting above waiting for a decision. */}
{discovered && !run && !discoverError && pending > 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Clock className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
Nothing has run yet &mdash; the upload above is waiting for your approval. Tick the
files and press <strong>Start selected</strong>, and its stage breakdown appears here.
</p>
</div>
)}
{/* Genuinely nothing to show - and it now says WHICH nothing. With the
any-origin fallback in discover(), `scanned > 0` survives only for the
case where every run in reach is a retired husk. */}
{discovered && !run && !discoverError && pending === 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<ClipboardCheck className="h-8 w-8 text-slate-300 mx-auto mb-3" />
{scanned === 0 ? (
<p className="text-xs text-slate-500">
No ingestion run at all yet. The moment a colleague sends a sheet to{' '}
<code className="font-mono">POST /api/uploads/catalog</code>, its run appears here
with the full stage breakdown.
</p>
) : (
<p className="text-xs text-slate-500">
Looked at the last {scanned} ingestion run(s) and none can be shown &mdash; every
one of them was dismissed from the review inbox.
</p>
)}
<p className="text-[11px] text-slate-400 mt-2">
Only the most recent {DISCOVER_MAX_LIMIT} runs are searched, and uploads older than a
week are cleared by retention.
</p>
</div>
)}
{run && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
{/* What is below is whatever the last SUCCESSFUL lookup found. Saying
so is the point - showing it silently as live is how a dead backend
passed for a quiet one. */}
{discoverError && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
The last refresh failed ({discoverError}), so this may be out of date. Retrying
every {Math.round(DISCOVER_POLL_MS / 1000)}s.
</span>
</div>
)}
{/* No colleague upload was in reach, so this is the newest run of any
origin. Never silent about it: the tab promises a colleague's
upload, and a run with no sender is not one. */}
{!fromColleague && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
No colleague upload in the last {scanned} ingestion run(s), so this is simply the
most recent run. It carries no sender, which means it was started from the Admin
UI (<strong>Batch Catalog Ingestion</strong>) rather than sent to{' '}
<code className="font-mono">POST /api/uploads/catalog</code>. Its eleven stages
are below all the same.
</span>
</div>
)}
{/* The filename IS the headline - `truncate` on a min-w-0 flex child
so a long name is cut rather than pushing the card sideways. */}
<h3 className="font-display text-base font-bold text-ink-950 flex items-center gap-2 min-w-0 mb-1.5">
<FileSpreadsheet className="h-4 w-4 shrink-0 text-amber-600" />
<span className="truncate" title={headlineName}>{headlineName}</span>
</h3>
{/* Everything that used to crowd the filename, demoted to one line. */}
<p className="mb-4 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] text-slate-500">
<StatusIcon
className={`h-3.5 w-3.5 shrink-0 ${STATUS_TONE[run.status] || 'text-slate-400'} ${
run.status === 'running' ? 'animate-spin' : ''
}`}
/>
{run.submitted_by ? (
<span>from <span className="font-bold text-amber-600">{run.submitted_by}</span></span>
) : (
<span>started from the Admin UI</span>
)}
<span className="text-slate-300">&middot;</span>
<span>{ago(run.created_at)}</span>
<span className="text-slate-300">&middot;</span>
<span className="font-mono uppercase">{run.status}</span>
{extraFiles > 0 && (
<>
<span className="text-slate-300">&middot;</span>
<span>+{extraFiles} more file(s), listed below</span>
</>
)}
</p>
{/* `partial` exists so this does not read as success: some files
landed and some did not. */}
{run.status === 'partial' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{run.files_done} of {run.files_total} file(s) landed; {run.files_failed} did not.
</span>
</div>
)}
{run.status === 'queued' && (
<div className="mb-4 rounded-lg bg-white border border-ink-900/10 p-3 text-[11px] text-slate-500 flex items-start gap-2">
<Clock className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{run.runner === 'dagster'
? 'Staged and waiting for the Dagster orchestrator to claim it. Dagster is not deployed in production, so this will wait indefinitely there.'
: 'Staged and waiting its turn - the ingestion queue was busy when this arrived.'}
</span>
</div>
)}
{run.status === 'interrupted' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
The backend restarted while this was running, so its unfinished files were put
back in the queue. Resume it from <strong>Batch Catalog Ingestion</strong>.
</span>
</div>
)}
{run.detail && (
<p className="mb-4 text-[11px] text-slate-500 leading-relaxed">{run.detail}</p>
)}
{/* A storage error still leaves rows built, so it hides among the
counters - but it is what forces the file to failed, so it is
called out on its own. */}
{storageErrors.length > 0 && (
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-[11px] text-maroon-600">
<p className="font-bold mb-1">Could not write to the catalogue</p>
{storageErrors.map((text, i) => (
<p key={i} className="leading-relaxed">{text}</p>
))}
</div>
)}
<ul className="space-y-3">
{files.map((file) => (
<FileStages
key={`${file.index}-${file.filename}`}
file={file}
stageNames={run.stage_names || []}
/>
))}
</ul>
{/* The third of the three things: a refused row, named, with the sheet
line number the sender sees on screen (header counted as row 1).
This is the only place in the app that reports them. */}
<IssueList
title="Refused by the validation gate"
items={rejections}
tone="maroon"
/>
<IssueList title="Rows that could not be read" items={rowErrors} />
<IssueList title="Warnings" items={warnings} />
</div>
)}
</div>
);
}

View File

@@ -1,972 +0,0 @@
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
} 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
const [brand, setBrand] = useState('Lion Dates');
const [productName, setProductName] = useState('Lion Dates 450g');
const [category, setCategory] = useState('Health Foods');
const [priceRange, setPriceRange] = useState('₹160-220');
const [finalPrice, setFinalPrice] = useState('185.00');
const [barcode, setBarcode] = useState('20086040');
const [hsnCode, setHsnCode] = useState('2008');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [submittingSingle, setSubmittingSingle] = useState(false);
const [singleResult, setSingleResult] = useState(null);
const [singleError, setSingleError] = useState('');
// Batch Upload State
const [batchFile, setBatchFile] = useState(null);
const [uploadingBatch, setUploadingBatch] = useState(false);
const [batchResult, setBatchResult] = useState(null);
const [batchError, setBatchError] = useState('');
// Catalog Inspection & Modal
const [brandSearch, setBrandSearch] = useState('Lion Dates');
const [brandProducts, setBrandProducts] = useState([]);
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);
try {
const data = await api.getBrandProducts(bName, { limit: 50 });
const itemsList = Array.isArray(data)
? data
: Array.isArray(data?.products)
? data.products
: [];
setBrandProducts(itemsList);
} catch (e) {
console.warn('Failed to fetch brand products', e);
setBrandProducts([]);
} finally {
setLoadingBrand(false);
}
};
useEffect(() => {
fetchBrandCatalog(brandSearch);
}, [brandSearch]);
const handlePresetLionDates = () => {
setEntryMode('single');
switchTab('upload');
setBrand('Lion Dates');
setProductName('Lion Dates 450g');
setCategory('Health Foods');
setPriceRange('₹160-220');
setFinalPrice('185.00');
setBarcode('20086040');
setHsnCode('2008');
setDescription('Premium quality Lion Dates 450g pack enriched with natural nutrients and vitamins.');
setImageUrl('https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445');
};
const handleSingleSubmit = async (e) => {
e.preventDefault();
setSingleError('');
setSingleResult(null);
setSubmittingSingle(true);
try {
const payload = {
brand,
product_name: productName,
category,
price_range: priceRange,
final_selling_price: finalPrice ? parseFloat(finalPrice) : null,
barcode: barcode || null,
hsn_code: hsnCode || null,
description: description || undefined,
image_url: imageUrl || undefined,
};
const data = await api.addUserProduct(payload);
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);
} catch (err) {
setSubmittingSingle(false);
setSingleError(err.message || 'An error occurred while adding the product.');
}
};
const handleBatchSubmit = async (e) => {
e.preventDefault();
if (!batchFile) return;
setBatchError('');
setBatchResult(null);
setUploadingBatch(true);
try {
const data = await api.uploadUserProductsFile(batchFile);
setBatchResult(data);
setUploadingBatch(false);
if (data.added_products && Array.isArray(data.added_products)) {
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);
setBatchError(err.message || 'Error uploading file.');
}
};
const downloadSampleCSV = () => {
const csvContent =
'Brand Name,Product Name / Variant,Category,Price Range,Final Price (₹),Barcode (GTIN/EAN),HSN Code,Custom Image URL,Description\n' +
'Lion Dates,Lion Dates 450g,Health Foods,₹160-220,185.00,20086040,2008,https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445,Premium Quality Lion Dates 450g\n' +
'Naga,Naga Maida 2kg,Flour & Grains,₹90-110,98.00,8906012345001,1101,,High grade refined wheat flour maida\n';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'sample_product_batch_template.csv');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
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]);
return (
<ErrorBoundary>
<div className="min-h-screen bg-paper-100 font-sans text-ink-950">
<NavigationHeader
title="User Product Workspace"
subtitle="Upload new product records & view store catalog product cards"
icon={PlusCircle}
/>
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* Top View Selector Tabs: [ Add / Upload Records ] vs [ View Product Cards ] */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-slate-900 text-white rounded-2xl p-4 shadow-lg border border-slate-800">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/20 text-blue-400 border border-blue-500/30">
<LayoutGrid className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold font-display">User Product Management</h2>
<p className="text-xs text-slate-300">
Single/Batch Product Uploads with Dual Persistence & Store Product Cards
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-slate-950 p-1.5 rounded-xl border border-slate-800">
<button
type="button"
onClick={() => switchTab('upload')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
activeTab === 'upload'
? 'bg-blue-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
}`}
>
<PlusCircle className="h-4 w-4" /> ➕ Add / Upload Records
</button>
<button
type="button"
onClick={() => switchTab('cards')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
activeTab === 'cards'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-white hover:bg-slate-800/60'
}`}
>
<ShoppingBag className="h-4 w-4" /> 🎴 Product Catalog Cards ({allCardProducts.length})
</button>
</div>
</div>
{/* ========================================================================= */}
{/* VIEW 1: PRODUCT ENTRY & BATCH UPLOAD */}
{/* ========================================================================= */}
{activeTab === 'upload' && (
<div className="space-y-6">
<div className="rounded-2xl border border-blue-500/20 bg-gradient-to-r from-blue-950 to-slate-900 text-white p-6 shadow-md flex flex-wrap items-center justify-between gap-4">
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/20 px-3 py-1 text-xs font-bold text-blue-300 border border-blue-400/30 mb-2">
<Database className="h-3.5 w-3.5" /> Database & Seed JSON Auto-Sync
</span>
<h3 className="text-xl font-bold font-display">Upload & Add New Product Records</h3>
<p className="text-sm text-blue-200 mt-1 max-w-2xl">
Add new product variants (e.g. <strong>"Lion Dates 450g"</strong>) via single form or batch CSV/Excel upload. Product details & images sync directly into PostgreSQL database tables and JSON seed catalog files.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={downloadSampleCSV}
className="flex items-center gap-1.5 rounded-xl bg-white/10 hover:bg-white/20 border border-white/20 text-white font-semibold px-3.5 py-2 text-xs transition cursor-pointer"
>
<Download className="h-3.5 w-3.5" /> Download Sample CSV
</button>
<button
type="button"
onClick={handlePresetLionDates}
className="flex items-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold px-4 py-2 text-xs shadow-md transition active:scale-95 cursor-pointer"
>
<Sparkles className="h-4 w-4" /> Preset: Add Lion Dates 450g
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Form: Single vs Batch Upload Tabs */}
<div className="lg:col-span-7 rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm">
<div className="flex items-center justify-between border-b border-ink-900/10 pb-4 mb-6">
<div className="flex items-center gap-2 p-1 rounded-xl bg-slate-200/80">
<button
type="button"
onClick={() => setEntryMode('single')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
entryMode === 'single'
? 'bg-blue-600 text-white shadow-xs'
: 'text-slate-600 hover:text-ink-950'
}`}
>
<PlusCircle className="h-4 w-4" /> Single Entry Form
</button>
<button
type="button"
onClick={() => setEntryMode('batch')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-bold transition cursor-pointer ${
entryMode === 'batch'
? 'bg-blue-600 text-white shadow-xs'
: 'text-slate-600 hover:text-ink-950'
}`}
>
<FileSpreadsheet className="h-4 w-4" /> Batch CSV / Excel Upload
</button>
</div>
<span className="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2.5 py-1 rounded-full">
Auto-Enrich & Dual Persistence
</span>
</div>
{/* SINGLE PRODUCT FORM */}
{entryMode === 'single' && (
<div>
{singleError && (
<div className="mb-4 rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
{singleError}
</div>
)}
<form onSubmit={handleSingleSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Brand Name *</label>
<input
type="text"
required
value={brand}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Product Name / Variant *</label>
<input
type="text"
required
value={productName}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Category</label>
<input
type="text"
value={category}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Price Range</label>
<input
type="text"
value={priceRange}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Final Price (₹)</label>
<input
type="number"
step="0.01"
value={finalPrice}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Barcode (GTIN/EAN)</label>
<input
type="text"
value={barcode}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">HSN Code</label>
<input
type="text"
value={hsnCode}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Custom Image URL (Optional)</label>
<input
type="url"
value={imageUrl}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Description (Optional)</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter detailed description or leave empty for AI auto-description..."
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"
/>
</div>
<button
type="submit"
disabled={submittingSingle}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 text-xs font-bold shadow-md transition active:scale-98 disabled:opacity-50 cursor-pointer"
>
{submittingSingle ? <RefreshCw className="h-4 w-4 animate-spin" /> : <PlusCircle className="h-4 w-4" />}
{submittingSingle ? 'Syncing to Database & JSON Catalog...' : 'Save Record to DB Table & JSON File'}
</button>
</form>
</div>
)}
{/* BATCH CSV/EXCEL UPLOAD */}
{entryMode === 'batch' && (
<div>
{batchError && (
<div className="mb-4 rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
{batchError}
</div>
)}
<form onSubmit={handleBatchSubmit} className="space-y-4">
<div className="border-2 border-dashed border-blue-500/30 rounded-2xl bg-blue-50/50 p-6 text-center hover:bg-blue-50 transition">
<FileSpreadsheet className="h-10 w-10 text-blue-600 mx-auto mb-2" />
<h4 className="font-bold text-sm text-ink-950">Upload Product Spreadsheet (.csv, .xlsx, .xls)</h4>
<p className="text-xs text-slate-500 mt-1 mb-4">
Supports multiple rows containing Brand Name, Product Name, Category, Price, Barcode, HSN Code.
</p>
<input
type="file"
accept=".csv, .xlsx, .xls"
onChange={(e) => setBatchFile(e.target.files?.[0] || null)}
className="hidden"
id="batch-file-input"
/>
<label
htmlFor="batch-file-input"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs cursor-pointer shadow-sm transition"
>
<Upload className="h-4 w-4" /> Select CSV or Excel File
</label>
{batchFile && (
<div className="mt-4 p-3 rounded-xl bg-white border border-blue-200 inline-flex items-center gap-2 text-xs font-bold text-ink-950">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
{batchFile.name} ({(batchFile.size / 1024).toFixed(1)} KB)
</div>
)}
</div>
<button
type="submit"
disabled={!batchFile || uploadingBatch}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 text-xs font-bold shadow-md transition active:scale-98 disabled:opacity-50 cursor-pointer"
>
{uploadingBatch ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{uploadingBatch ? 'Processing & Enriching Batch Products...' : 'Upload & Sync Batch Records'}
</button>
</form>
</div>
)}
</div>
{/* Right Panel: Results & Quick Brand Inspection */}
<div className="lg:col-span-5 space-y-6">
{singleResult && (
<div className="rounded-2xl border border-emerald-500/30 bg-emerald-50/70 p-6 shadow-sm space-y-3">
<div className="flex items-center gap-2 text-emerald-700">
<CheckCircle2 className="h-5 w-5" />
<h4 className="font-bold text-sm">Product Record Created & Synced</h4>
</div>
<p className="text-xs text-slate-700">{singleResult.message}</p>
<div className="p-3 rounded-xl bg-white border border-emerald-200 text-xs space-y-1">
<p className="font-bold text-ink-950">{singleResult.product.product_name}</p>
<p className="text-[11px] text-slate-500 font-mono">Brand: {singleResult.product.brand}</p>
{singleResult.product.barcode && (
<p className="text-[11px] text-slate-600 font-mono">Barcode: {singleResult.product.barcode}</p>
)}
</div>
<button
type="button"
onClick={() => switchTab('cards')}
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl bg-emerald-600 text-white font-bold text-xs shadow-sm hover:bg-emerald-700 transition cursor-pointer"
>
<ShoppingBag className="h-4 w-4" /> View as Product Card <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
)}
{batchResult && (
<div className="rounded-2xl border border-emerald-500/30 bg-emerald-50/70 p-6 shadow-sm space-y-3">
<div className="flex items-center gap-2 text-emerald-700">
<CheckCircle2 className="h-5 w-5" />
<h4 className="font-bold text-sm">Batch Products Processed</h4>
</div>
<p className="text-xs text-slate-700">{batchResult.message}</p>
<button
type="button"
onClick={() => switchTab('cards')}
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl bg-emerald-600 text-white font-bold text-xs shadow-sm hover:bg-emerald-700 transition cursor-pointer"
>
<ShoppingBag className="h-4 w-4" /> View Batch Items as Product Cards <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
)}
{!singleResult && !batchResult && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm text-center">
<Database className="h-10 w-10 text-blue-500 mx-auto mb-3" />
<h4 className="font-bold text-sm text-ink-950">Dual Storage Persistence</h4>
<p className="text-xs text-slate-500 mt-1">
Every product submitted (single or batch CSV/Excel) is automatically parsed, assigned high-resolution images, and synced into PostgreSQL brand tables and JSON catalog seed files.
</p>
</div>
)}
{/* Quick Brand Catalog Search */}
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-bold text-sm text-ink-950 flex items-center gap-2">
<Search className="h-4 w-4 text-amber-500" /> Inspect Brand Products
</h4>
<span className="text-[11px] font-mono text-slate-400">{brandProducts.length} items</span>
</div>
<div className="flex gap-2">
<input
type="text"
value={brandSearch}
onChange={(e) => setBrandSearch(e.target.value)}
placeholder="Enter brand to inspect (e.g. Lion Dates)"
className="flex-1 rounded-xl border border-ink-900/15 bg-white p-2 text-xs font-medium text-ink-950 focus:outline-none"
/>
<button
type="button"
onClick={() => fetchBrandCatalog(brandSearch)}
className="px-3 py-1.5 bg-ink-900 text-white text-xs font-semibold rounded-xl hover:bg-ink-950 cursor-pointer"
>
Fetch
</button>
</div>
{loadingBrand ? (
<div className="text-center py-4 text-xs text-slate-400">Loading catalog items...</div>
) : (
<div className="max-h-64 overflow-y-auto space-y-2 pr-1 no-scrollbar">
{brandProducts.map((p, idx) => (
<div
key={idx}
onClick={() => setSelectedProduct(p)}
className="cursor-pointer flex items-center justify-between gap-2 p-2.5 rounded-xl border border-slate-200 bg-white hover:border-amber-500/60 transition shadow-xs"
>
<div className="flex items-center gap-2.5 overflow-hidden">
{p.image_url ? (
<img src={p.image_url} alt="" className="h-9 w-9 object-contain bg-slate-50 rounded" />
) : (
<div className="h-9 w-9 rounded bg-slate-100 flex items-center justify-center text-[10px] text-slate-400">No img</div>
)}
<div className="truncate">
<p className="text-xs font-bold text-ink-950 truncate">{p.product_name}</p>
<p className="text-[10px] text-slate-400 font-mono">
{p.barcode ? `Barcode: ${p.barcode}` : p.product_sku ? `SKU: ${p.product_sku}` : 'Standard Record'}
</p>
</div>
</div>
{p.final_selling_price && (
<span className="font-mono text-xs font-bold text-leaf-600 shrink-0">₹{p.final_selling_price}</span>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* ========================================================================= */}
{/* VIEW 2: PRODUCT CATALOG CARDS (STORE-A & USER UPLOADED CARDS) */}
{/* ========================================================================= */}
{activeTab === 'cards' && (
<div className="space-y-6">
{/* Header Toolbar */}
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-sm space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-ink-950 flex items-center gap-2">
<ShoppingBag className="h-5 w-5 text-amber-500" /> User Product Catalog Cards
</h3>
<p className="text-xs text-slate-500 mt-0.5">
Displaying sample store records (e.g. <strong>Store-A Gandhipuram</strong>) alongside your uploaded product records as rich cards.
</p>
</div>
{/* Store Selector Dropdown */}
<div className="flex items-center gap-2 bg-slate-100 p-1.5 rounded-xl border border-slate-200">
<Store className="h-4 w-4 text-emerald-600 ml-2" />
<span className="text-xs font-bold text-slate-700">Sample Store:</span>
<select
value={selectedStoreId}
onChange={(e) => setSelectedStoreId(e.target.value)}
className="bg-white border border-slate-300 rounded-lg px-3 py-1 text-xs font-bold text-slate-800 focus:outline-none"
>
{STORES_SAMPLE.map((s) => (
<option key={s.store_id} value={s.store_id}>
{s.store_name} ({s.tier})
</option>
))}
</select>
</div>
</div>
{/* Filter & Search Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-ink-900/10 pt-4">
{/* Brand Filter Pills */}
<div className="flex items-center gap-1.5 overflow-x-auto max-w-full pb-1 no-scrollbar">
<span className="text-xs font-semibold text-slate-500 flex items-center gap-1 mr-1">
<Filter className="h-3.5 w-3.5" /> Brands:
</span>
{uniqueBrandsList.slice(0, 8).map((b) => (
<button
type="button"
key={b}
onClick={() => setSelectedBrandFilter(b)}
className={`px-3 py-1 rounded-full text-xs font-bold transition-all cursor-pointer ${
selectedBrandFilter === b
? 'bg-ink-950 text-white shadow-xs scale-105'
: 'bg-slate-200/70 text-slate-700 hover:bg-slate-300'
}`}
>
{b}
</button>
))}
</div>
{/* Search Bar */}
<div className="relative min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400" />
<input
type="text"
value={cardSearchQuery}
onChange={(e) => setCardSearchQuery(e.target.value)}
placeholder="Search by product, brand or barcode..."
className="w-full rounded-xl border border-slate-300 bg-white py-1.5 pl-8 pr-3 text-xs text-ink-950 placeholder-slate-400 focus:outline-none focus:border-amber-500"
/>
</div>
</div>
</div>
{/* Product Cards Grid */}
{loadingCards ? (
<div className="text-center py-16 text-slate-400 text-sm font-medium flex items-center justify-center gap-2">
<RefreshCw className="h-5 w-5 animate-spin text-amber-500" /> Loading store and uploaded product cards...
</div>
) : filteredCards.length === 0 ? (
<div className="text-center py-16 rounded-2xl border border-dashed border-slate-300 bg-paper-50 p-8">
<ShoppingBag className="h-12 w-12 text-slate-300 mx-auto mb-2" />
<p className="text-sm font-bold text-slate-700">No product cards match your filter</p>
<p className="text-xs text-slate-400 mt-1">Try resetting the brand filter or search term</p>
<button
type="button"
onClick={() => { setSelectedBrandFilter('All'); setCardSearchQuery(''); }}
className="mt-3 px-4 py-1.5 rounded-xl bg-slate-900 text-white text-xs font-bold cursor-pointer"
>
Reset Filters
</button>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold text-slate-500 font-mono">
Showing {filteredCards.length} product cards ({selectedStoreId} + Uploads)
</span>
{uploadedCards.length > 0 && (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-700 border border-emerald-500/30 text-xs font-bold">
<Sparkles className="h-3 w-3" /> {uploadedCards.length} Newly Uploaded Items Added
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{filteredCards.map((prod, idx) => (
<div key={prod.image_id || idx} className="relative group">
{prod.is_user_uploaded && (
<div className="absolute top-2 left-2 z-10 bg-emerald-600 text-white font-extrabold text-[10px] px-2 py-0.5 rounded-full shadow-md">
NEWLY UPLOADED
</div>
)}
<ProductCard product={prod} onClick={() => setSelectedProduct(prod)} />
</div>
))}
</div>
</div>
)}
</div>
)}
</main>
{/* Interactive Product Details Modal */}
{selectedProduct && (
<ProductModal product={selectedProduct} onClose={() => setSelectedProduct(null)} />
)}
</div>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,77 @@
import { CheckCircle2, Clock, Loader2, MinusCircle, XCircle } from 'lucide-react';
/*
* Constants and formatters shared by the two ingestion screens.
*
* Kept apart from pipelineShared.jsx, which holds the components: a module that
* exports both breaks React Fast Refresh, which oxlint flags.
*
* These lived inside OrchestrationPanel until UploadResultsPanel needed the
* same timeline. Copying them again was the alternative - the old InboxPanel
* and OrchestrationPanel already carried duplicate `ago`/`fmtBytes`, and
* BatchCatalogPanel still carries its own `Stat` - and a second copy of
* FileStages in particular is the one worth avoiding, because its stage lookup
* is subtle (see the comment on the component).
*
* Moved verbatim. Nothing here changed behaviour when it was lifted; if this
* file and the Orchestration screen ever disagree, this file is wrong.
*/
export const FILE_TONE = {
done: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
failed: 'bg-maroon-100 text-maroon-600 border-maroon-500/30',
running: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
queued: 'bg-white text-slate-500 border-ink-900/10',
cancelled: 'bg-ink-100 text-slate-500 border-ink-900/10',
};
export const FILE_ICON = {
done: CheckCircle2,
failed: XCircle,
running: Loader2,
queued: Clock,
cancelled: MinusCircle,
};
// The two optional network stages, read back off the manifest. A run that
// started on its own took these from the SERVER settings
// (UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM), never from the
// checkboxes on any page, so the manifest is the only honest report of what a
// colleague's upload actually did.
export const STAGE_FLAGS = [
{ key: 'fetch_images', label: 'img', title: 'Product image search' },
{ key: 'use_llm', label: 'llm', title: 'LLM description fill' },
];
export function ago(seconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - seconds));
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export function fmtBytes(n) {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function fmtSecs(from, to) {
if (!from || !to) return '';
const s = to - from;
if (s < 1) return '<1s';
if (s < 60) return `${s.toFixed(1)}s`;
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
}
// Stat tile tones. Semantic, not decorative: emerald for rows that landed,
// maroon for rows that did not.
export const TONES = {
emerald: 'border-emerald-500/20 bg-emerald-500/5 text-emerald-700',
blue: 'border-blue-500/20 bg-blue-500/5 text-blue-700',
slate: 'border-ink-900/10 bg-white text-slate-600',
maroon: 'border-maroon-500/20 bg-maroon-100/40 text-maroon-600',
amber: 'border-amber-500/20 bg-amber-500/5 text-amber-700',
};

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { CheckCircle2, Clock, Loader2 } from 'lucide-react';
import { FILE_ICON, FILE_TONE, TONES, fmtSecs } from './pipelineFormat';
/*
* The two pieces of a batch run both ingestion screens draw: the eleven-stage
* timeline and the stat tile. Constants and formatters live in
* pipelineFormat.js so this module exports components only.
*
* Moved verbatim out of OrchestrationPanel when UploadResultsPanel needed the
* same timeline. Nothing changed behaviour in the move; if this file and the
* Orchestration screen ever disagree, this file is wrong.
*/
/* One file, and every stage of the pipeline as it happened to that file.
*
* Renders an <li>, so the caller must wrap it in a list.
*
* All eleven are drawn from the moment the batch exists, greyed out until
* reached, so the shape of the pipeline is visible before anything runs and
* does not reflow as stages appear.
*
* A stage is looked up BY INDEX rather than by position in `file.stages`.
* Stages 8-11 run once per brand in the sheet, so the backend folds repeat
* visits into one record per index; matching on index is what keeps a
* three-brand file from appearing to run backwards. */
export function FileStages({ file, stageNames }) {
const Icon = FILE_ICON[file.status] || Clock;
const byIndex = new Map((file.stages || []).map((s) => [s.index, s]));
const names = stageNames.length ? stageNames : (file.stages || []).map((s) => s.name);
return (
<li className={`rounded-xl border p-3 ${FILE_TONE[file.status] || FILE_TONE.queued}`}>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 min-w-0">
<Icon className={`h-3.5 w-3.5 shrink-0 ${file.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-[11px] font-bold truncate">{file.filename}</span>
</span>
<span className="flex items-center gap-2 shrink-0">
{file.started_at && file.finished_at && (
<span className="font-mono text-[10px] opacity-70">
{fmtSecs(file.started_at, file.finished_at)}
</span>
)}
<span className="font-mono text-[10px] uppercase">{file.status}</span>
</span>
</div>
{file.detail && <p className="mt-1.5 text-[10px] leading-relaxed">{file.detail}</p>}
{names.length > 0 && (
<ol className="mt-2.5 space-y-1">
{names.map((name, i) => {
const index = i + 1;
const record = byIndex.get(index);
const done = record && record.finished_at;
const active = record && !record.finished_at;
return (
<li
key={index}
className={`flex items-center gap-2 rounded-lg px-2 py-1 text-[10px] ${
done
? 'bg-white/60 text-slate-600'
: active
? 'bg-amber-500/15 text-amber-700 font-bold'
: 'text-slate-400'
}`}
>
<span className="font-mono w-7 shrink-0 text-right opacity-60">{index}/11</span>
{done && <CheckCircle2 className="h-3 w-3 shrink-0 text-leaf-600" />}
{active && <Loader2 className="h-3 w-3 shrink-0 animate-spin" />}
{!record && <Clock className="h-3 w-3 shrink-0 opacity-40" />}
<span className="truncate flex-1">{name}</span>
{record && record.rows_total > 0 && (
<span className="font-mono shrink-0 opacity-70">
{record.rows_done}/{record.rows_total}
</span>
)}
{done && (
<span className="font-mono shrink-0 w-12 text-right opacity-50">
{fmtSecs(record.started_at, record.finished_at)}
</span>
)}
</li>
);
})}
</ol>
)}
</li>
);
}
export function Stat({ label, value, tone }) {
return (
<div className={`rounded-xl border p-3 ${TONES[tone] || TONES.slate}`}>
<p className="text-[10px] font-semibold uppercase">{label}</p>
<p className="font-mono text-2xl font-extrabold text-ink-950 mt-0.5">{value ?? 0}</p>
</div>
);
}

View File

@@ -2,19 +2,29 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Both dev and preview forward /api/* to FastAPI, so a same-origin (relative)
// request works whichever local server is delivering the page. Keep the two in
// sync: `preview` serves dist/, and without this it would answer /api with a
// 404 index.html, which surfaces in the UI as a login failure.
const apiProxy = {
'/api': {
// Proxy all /api/* requests straight to FastAPI on port 8000
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
ws: true,
},
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
// Proxy all /api/* requests straight to FastAPI on port 8000
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
ws: true,
},
},
proxy: apiProxy,
},
preview: {
port: 4173,
proxy: apiProxy,
},
})