Compare commits
27 Commits
ba9e06e7d8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4d9a7c8a | ||
|
|
b58b291a0c | ||
|
|
5315f20ea9 | ||
|
|
7f601cc919 | ||
|
|
a24113f1e4 | ||
|
|
d4845c27f7 | ||
|
|
2ff8fd41ea | ||
|
|
2108e402b0 | ||
|
|
b677ddf9e6 | ||
|
|
21642c172b | ||
|
|
dd5c70fd54 | ||
|
|
d5420f824e | ||
|
|
097a4a5bfe | ||
|
|
80daf30a42 | ||
|
|
8efff41226 | ||
|
|
8f8f370566 | ||
|
|
e073d68d60 | ||
|
|
e1247ce1b2 | ||
|
|
814347e2d2 | ||
|
|
f5d3d80f42 | ||
|
|
c84f0a9529 | ||
|
|
8bb53d19dd | ||
|
|
39ae51e291 | ||
|
|
e05d4d6e2b | ||
|
|
8e219ca277 | ||
|
|
2d1f8e4156 | ||
|
|
0096008db5 |
@@ -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
22
.env.unified
Normal 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
15
.gitignore
vendored
@@ -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
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -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 ----
|
||||
|
||||
24
README.md
24
README.md
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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 |
66
src/App.jsx
66
src/App.jsx
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
61
src/components/SearchSuggestions.jsx
Normal file
61
src/components/SearchSuggestions.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
74
src/hooks/useSuggestions.js
Normal file
74
src/hooks/useSuggestions.js
Normal 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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
522
src/pages/BatchCatalogPanel.jsx
Normal file
522
src/pages/BatchCatalogPanel.jsx
Normal 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 — images, price bands, pack
|
||||
sizes, SKU, barcode, HSN/GST and FSSAI — 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>
|
||||
{' '}— 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>
|
||||
{' '}— 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 — {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 — {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 — 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} — {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>
|
||||
);
|
||||
}
|
||||
550
src/pages/BrandDiscoveryPanel.jsx
Normal file
550
src/pages/BrandDiscoveryPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 & Multi-Store Nutrition Intelligence Engine
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis,
|
||||
Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
|
||||
|
||||
export function NutritionAnalyticsPage() {
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [job, setJob] = useState(null);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api.getNutritionAnalyticsDashboard(8).then(setDashboard).catch(() => setDashboard(null)).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || job.status === 'done' || job.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (job?.status === 'done') load();
|
||||
return;
|
||||
}
|
||||
pollRef.current = setInterval(() => {
|
||||
api.getNutritionIntelligenceJob(job.job_id).then(setJob).catch(() => {});
|
||||
}, 2000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [job]);
|
||||
|
||||
const runEnrich = () => api.enrichNutrition({}).then(setJob);
|
||||
const runTrain = () => api.trainNutritionModels().then(setJob);
|
||||
|
||||
const distribution = dashboard?.distribution;
|
||||
const enrichmentCounts = distribution?.enrichment_status || {};
|
||||
const totalTracked = Object.values(enrichmentCounts).reduce((a, b) => a + b, 0);
|
||||
const hasData = totalTracked > 0;
|
||||
|
||||
const distributionData = distribution?.health_score_distribution
|
||||
? Object.entries(distribution.health_score_distribution).map(([bucket, count]) => ({ bucket, count }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<NavigationHeader
|
||||
title="Nutrition Intelligence"
|
||||
subtitle="Verified nutrition data, health scores & leaderboards across the catalog"
|
||||
icon={HeartPulse}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div>
|
||||
<h3 className="font-display text-sm font-semibold text-ink-950">Nutrition data pipeline</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{hasData
|
||||
? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products`
|
||||
: 'No products enriched yet - run enrichment or upload Excel/CSV data.'}
|
||||
</p>
|
||||
{job && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
{job.kind === 'enrich' ? 'Enrichment' : 'Training'} {job.status}
|
||||
{job.total ? ` (${job.processed}/${job.total})` : ''}
|
||||
{job.detail ? ` - ${job.detail}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Nutrition (Excel / CSV)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={runEnrich}
|
||||
disabled={job && job.status === 'running'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-3 py-1.5 text-xs font-semibold text-paper-100 transition hover:bg-ink-800 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${job?.kind === 'enrich' && job.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
Auto Enrich OFF
|
||||
</button>
|
||||
<button
|
||||
onClick={runTrain}
|
||||
disabled={job && job.status === 'running'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-leaf-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-leaf-700 disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Train Similarity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner label="Loading nutrition analytics…" />
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={HeartPulse}
|
||||
title="No nutrition analytics yet"
|
||||
subtitle="Run enrichment above (or `python scripts/enrich_nutrition.py`), then training, to populate this dashboard."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="Healthiest brands" icon={Award}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dashboard.rankings.healthiest_brands}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="brand" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg_health_score" fill="#3f8556" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Healthiest categories" icon={Apple}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dashboard.rankings.healthiest_categories}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
||||
<XAxis dataKey="category" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg_health_score" fill="#e2a33d" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{distributionData.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ChartCard title="Health score distribution" icon={HeartPulse}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={distributionData} dataKey="count" nameKey="bucket" outerRadius={85} label={(e) => e.bucket}>
|
||||
{distributionData.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<Leaderboard title="Highest protein" icon={Beef} items={dashboard.leaderboards.highest_protein} field="protein_g" unit="g" />
|
||||
<Leaderboard title="Highest fiber" icon={Wheat} items={dashboard.leaderboards.highest_fiber} field="dietary_fiber_g" unit="g" />
|
||||
<Leaderboard title="Lowest sugar" icon={Droplet} items={dashboard.leaderboards.lowest_sugar} field="total_sugar_g" unit="g" />
|
||||
<Leaderboard title="Lowest sodium" icon={Flame} items={dashboard.leaderboards.lowest_sodium} field="sodium_mg" unit="mg" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="nutrition"
|
||||
tabTitle="Nutritional Intelligence Facts"
|
||||
onSuccess={() => load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-amber-500" /> {title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Leaderboard({ title, icon: Icon, items = [], field, unit }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<h3 className="mb-3 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
||||
<Icon className="h-4 w-4 text-leaf-600" /> {title}
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-slate-400">No data yet.</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{items.map((p, i) => (
|
||||
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
|
||||
<span className="truncate text-ink-900">{p.product_name || p.image_id}</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
|
||||
{p[field] != null ? `${Number(p[field]).toFixed(1)} ${unit}` : '–'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
552
src/pages/OrchestrationPanel.jsx
Normal file
552
src/pages/OrchestrationPanel.jsx
Normal 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 — 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'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"> — 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>
|
||||
{' '}— 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>
|
||||
{' '}— 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'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 — 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)} · {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 — {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 — 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 — so progress here can lag the Dagster UI at :3030 by a tick or two.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
|
||||
PackageSearch, BarChart3, Search, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const STOCK_STATUS_STYLES = {
|
||||
'In Stock': 'bg-leaf-100 text-leaf-600',
|
||||
'Low Stock': 'bg-amber-100 text-amber-600',
|
||||
'Out of Stock': 'bg-maroon-100 text-maroon-600',
|
||||
'Overstocked': 'bg-ink-100 text-ink-700',
|
||||
};
|
||||
|
||||
export function StoresPage() {
|
||||
const [stores, setStores] = useState([]);
|
||||
const [storesLoading, setStoresLoading] = useState(true);
|
||||
const [selectedStore, setSelectedStore] = useState(null);
|
||||
|
||||
const [products, setProducts] = useState([]);
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [dashboardLoading, setDashboardLoading] = useState(false);
|
||||
const [discounts, setDiscounts] = useState({}); // keyed by brand||image_id
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [inStockOnly, setInStockOnly] = useState(false);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStores()
|
||||
.then((res) => {
|
||||
setStores(res);
|
||||
if (res.length > 0) setSelectedStore(res[0].store_id);
|
||||
})
|
||||
.catch(() => setStores([]))
|
||||
.finally(() => setStoresLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadStoreData = useCallback((storeId) => {
|
||||
if (!storeId) return;
|
||||
setProductsLoading(true);
|
||||
setDashboardLoading(true);
|
||||
api.getStoreProducts(storeId, { in_stock_only: inStockOnly })
|
||||
.then(setProducts)
|
||||
.catch(() => setProducts([]))
|
||||
.finally(() => setProductsLoading(false));
|
||||
api.getStoreAnalytics(storeId)
|
||||
.then(setDashboard)
|
||||
.catch(() => setDashboard(null))
|
||||
.finally(() => setDashboardLoading(false));
|
||||
api.getStoreDiscounts(storeId)
|
||||
.then((rows) => {
|
||||
const map = {};
|
||||
rows.forEach((r) => { map[`${r.brand}||${r.image_id}`] = r; });
|
||||
setDiscounts(map);
|
||||
})
|
||||
.catch(() => setDiscounts({}));
|
||||
}, [inStockOnly]);
|
||||
|
||||
useEffect(() => { loadStoreData(selectedStore); }, [selectedStore, loadStoreData]);
|
||||
|
||||
const filteredProducts = products.filter((p) =>
|
||||
!search || (p.title || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-paper-100">
|
||||
<NavigationHeader title="Store Intelligence" subtitle="Multi-store inventory, pricing & ML-based discounts" icon={StoreIcon} />
|
||||
|
||||
{storesLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center"><Spinner label="Loading stores…" /></div>
|
||||
) : stores.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<EmptyState
|
||||
icon={StoreIcon}
|
||||
title="No stores set up yet"
|
||||
subtitle="Run scripts/seed_store_intelligence.py (or POST /api/admin/store-intelligence/seed) to provision the 5 stores and simulate order history, then scripts/train_ml_models.py to train the discount/trending/recommendation models."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex gap-2 overflow-x-auto border-b border-ink-900/10 bg-paper-50 px-6 py-3">
|
||||
{stores.map((s) => (
|
||||
<button
|
||||
key={s.store_id}
|
||||
onClick={() => setSelectedStore(s.store_id)}
|
||||
className={`flex shrink-0 items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||
selectedStore === s.store_id ? 'bg-ink-900 text-paper-100' : 'bg-ink-100 text-ink-700 hover:bg-ink-100/70'
|
||||
}`}
|
||||
>
|
||||
<StoreIcon className="h-3.5 w-3.5" />
|
||||
{s.store_name}
|
||||
<span className="rounded-full bg-black/10 px-1.5 py-0.5 text-[10px] uppercase">{s.tier}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{dashboardLoading ? (
|
||||
<Spinner label="Loading store analytics…" className="mb-4" />
|
||||
) : dashboard ? (
|
||||
<KpiRow dashboard={dashboard} />
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-950">Products in this store</h2>
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter products…"
|
||||
className="rounded-full border border-ink-900/10 bg-white py-1.5 pl-8 pr-3 text-sm focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-500 mr-2">
|
||||
<input type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} />
|
||||
In stock only
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Excel / CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productsLoading ? (
|
||||
<Spinner label="Loading products…" className="mt-4" />
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<EmptyState icon={PackageSearch} title="No products found" subtitle="Try clearing filters." />
|
||||
) : (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-ink-900/10 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-ink-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">Product</th>
|
||||
<th className="px-4 py-2.5">Category</th>
|
||||
<th className="px-4 py-2.5">Stock</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">MRP</th>
|
||||
<th className="px-4 py-2.5">Selling Price</th>
|
||||
<th className="px-4 py-2.5">Discount</th>
|
||||
<th className="px-4 py-2.5">Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-900/5">
|
||||
{filteredProducts.map((p) => {
|
||||
const d = discounts[`${p.brand}||${p.image_id}`];
|
||||
return (
|
||||
<tr key={`${p.brand}-${p.image_id}`} className="hover:bg-amber-50/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-ink-900">{p.title}</p>
|
||||
<p className="text-xs text-slate-400">{p.brand}{p.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-500">{p.category}</td>
|
||||
<td className="px-4 py-2.5 font-mono">{p.available_stock}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${STOCK_STATUS_STYLES[p.stock_status] || 'bg-ink-100 text-ink-700'}`}>
|
||||
{p.stock_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-slate-500 line-through decoration-slate-300">₹{p.mrp}</td>
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-ink-950">₹{p.selling_price}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{d && d.discount_pct > 0 ? (
|
||||
<span className="rounded-full bg-maroon-100 px-2 py-0.5 text-[11px] font-medium text-maroon-600">
|
||||
-{d.discount_pct}% → ₹{d.final_price}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-leaf-600">{p.gross_profit_pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="stores"
|
||||
tabTitle="Store Inventory & Pricing"
|
||||
onSuccess={() => loadStoreData(selectedStore)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiRow({ dashboard }) {
|
||||
const { sales, profit, inventory } = dashboard;
|
||||
const TONE_TEXT = { ink: 'text-ink-600', leaf: 'text-leaf-600', maroon: 'text-maroon-600' };
|
||||
const cards = [
|
||||
{ icon: IndianRupee, label: 'Revenue', value: `₹${Number(sales.revenue).toLocaleString('en-IN')}`, tone: 'ink' },
|
||||
{ icon: TrendingUp, label: 'Profit', value: `₹${Number(profit.total_profit).toLocaleString('en-IN')}`, sub: `${profit.gross_profit_pct}% GP`, tone: 'leaf' },
|
||||
{ icon: BarChart3, label: 'Orders', value: sales.total_sales.toLocaleString('en-IN'), sub: `₹${sales.average_basket_value} avg basket`, tone: 'ink' },
|
||||
{ icon: PackageCheck, label: 'In Stock', value: inventory.in_stock, sub: `${inventory.total_products} products total`, tone: 'leaf' },
|
||||
{ icon: PackageX, label: 'Out of Stock', value: inventory.out_of_stock, sub: `${inventory.low_stock} running low`, tone: 'maroon' },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
<c.icon className="h-3.5 w-3.5" /> {c.label}
|
||||
</div>
|
||||
<p className={`mt-1.5 font-display text-xl font-bold ${TONE_TEXT[c.tone]}`}>{c.value}</p>
|
||||
{c.sub && <p className="text-xs text-slate-400">{c.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
783
src/pages/UploadResultsPanel.jsx
Normal file
783
src/pages/UploadResultsPanel.jsx
Normal 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>
|
||||
{' '}— 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>
|
||||
{' '}— 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)} · {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 — 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 — 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 — 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 — 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">·</span>
|
||||
<span>{ago(run.created_at)}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="font-mono uppercase">{run.status}</span>
|
||||
{extraFiles > 0 && (
|
||||
<>
|
||||
<span className="text-slate-300">·</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
77
src/pages/pipelineFormat.js
Normal file
77
src/pages/pipelineFormat.js
Normal 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',
|
||||
};
|
||||
100
src/pages/pipelineShared.jsx
Normal file
100
src/pages/pipelineShared.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user