diff --git a/src/api/client.js b/src/api/client.js
index 1899f0f..2139321 100644
--- a/src/api/client.js
+++ b/src/api/client.js
@@ -380,6 +380,23 @@ export const api = {
method: 'POST',
}),
+ // --- 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'),
+
+ startBatchFromInbox: (fileIds, { use_llm = false, fetch_images = false } = {}) =>
+ request('/api/admin/catalog-batch/from-inbox', {
+ method: 'POST',
+ body: JSON.stringify({ file_ids: fileIds, use_llm, fetch_images }),
+ }),
+
+ 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.
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index eeecff1..712763b 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
-import { Wrench, Database, UploadCloud, Layers } from 'lucide-react';
+import { Wrench, Database, UploadCloud, Layers, Inbox } from 'lucide-react';
import { api } from '../api/client';
import { NavigationHeader } from '../components/NavigationHeader';
import { StoreCatalogPanel } from './StoreCatalogPanel';
import { BatchCatalogPanel } from './BatchCatalogPanel';
+import { InboxPanel } from './InboxPanel';
/*
* The admin panel carries two views: the read-only project overview, and the
@@ -20,12 +21,22 @@ const TABS = [
{ id: 'project', label: 'Project Overview Details', icon: Database },
{ id: 'ingest', label: 'Store Catalog Ingestion', icon: UploadCloud },
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
+ { id: 'inbox', label: 'Review Inbox', icon: Inbox },
];
+// 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');
const [projectDetails, setProjectDetails] = useState(null);
const [loadingProject, setLoadingProject] = useState(true);
+ 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 {
@@ -43,6 +54,29 @@ export function AdminPage() {
fetchProjectDetails();
}, []);
+ // The badge. Polled here rather than inside InboxPanel 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.
+ 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 (
diff --git a/src/pages/BatchCatalogPanel.jsx b/src/pages/BatchCatalogPanel.jsx
index 2eaf668..a656a03 100644
--- a/src/pages/BatchCatalogPanel.jsx
+++ b/src/pages/BatchCatalogPanel.jsx
@@ -50,7 +50,7 @@ function fmtBytes(n) {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
-export function BatchCatalogPanel() {
+export function BatchCatalogPanel({ adoptBatch = null }) {
const [files, setFiles] = useState([]);
const [preview, setPreview] = useState(null);
const [previewing, setPreviewing] = useState(false);
@@ -63,6 +63,18 @@ export function BatchCatalogPanel() {
const pollRef = useRef(null);
const batchIdRef = useRef(null);
+ // A batch started from the Review Inbox 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. Same reasoning as StoreCatalogPanel: starting
// a second batch must not leave an older interval polling the previous one.
diff --git a/src/pages/InboxPanel.jsx b/src/pages/InboxPanel.jsx
new file mode 100644
index 0000000..1a84863
--- /dev/null
+++ b/src/pages/InboxPanel.jsx
@@ -0,0 +1,315 @@
+import React, { useEffect, useRef, useState } from 'react';
+import {
+ Inbox, Loader2, CheckCircle2, XCircle, AlertTriangle, ArrowRight,
+ PlayCircle, Ban, FileSpreadsheet, RefreshCw,
+} from 'lucide-react';
+import { api } from '../api/client';
+
+/*
+ * Files a colleague dropped, waiting for you to decide.
+ *
+ * This is the admin half of the two-actor flow. The colleague never sees this
+ * page - they POST to /api/uploads/catalog with an API key and that is the end
+ * of their involvement. Nothing they upload runs until somebody ticks it here.
+ *
+ * Selection is per FILE, not per drop, and files from different drops can be
+ * started as one batch. That is the whole reason the inbox is its own thing
+ * rather than a list of pre-made batches waiting on Resume: Resume runs a whole
+ * batch, and "two files from Monday plus one from today" has no expression in
+ * that model.
+ *
+ * Once started, this hands off to the Batch tab, which already knows how to
+ * render a running batch. Nothing here duplicates that.
+ */
+
+const POLL_MS = 5000;
+
+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`;
+}
+
+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 InboxPanel({ onBatchStarted }) {
+ const [data, setData] = useState(null);
+ const [selected, setSelected] = useState(() => new Set());
+ const [busy, setBusy] = useState('');
+ const [error, setError] = useState('');
+ const [notice, setNotice] = useState('');
+ const [useLlm, setUseLlm] = useState(false);
+ const [fetchImages, setFetchImages] = useState(false);
+
+ const pollRef = useRef(null);
+
+ const load = async () => {
+ try {
+ const next = await api.listInbox();
+ setData(next);
+ // 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.
+ 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.');
+ }
+ };
+
+ useEffect(() => {
+ load();
+ pollRef.current = setInterval(load, POLL_MS);
+ return () => clearInterval(pollRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ 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 load();
+ if (onBatchStarted) onBatchStarted(batch);
+ } catch (err) {
+ setError(err?.message || 'Could not start those files.');
+ await load();
+ } 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 load();
+ } catch (err) {
+ setError(err?.message || 'Could not dismiss those files.');
+ await load();
+ } finally {
+ setBusy('');
+ }
+ };
+
+ const submissions = data?.submissions || [];
+ const pending = data?.pending_count ?? 0;
+
+ return (
+
+
+
+
+
+ Review Inbox
+
+
+ Spreadsheets sent in by a colleague through the upload API. Nothing here has run
+ yet — tick the files you want and press Start selected to
+ send them through the 11-stage pipeline as one batch. Files from different drops
+ can go in the same batch.
+
+
+
+
+
+
+
+
+
+
+
+ {pending} file(s) awaiting review
+
+
+
+ {/* Same defaults and the same warning as the batch tab. */}
+
+
+ Dismissing a file does not delete it from the server; it only takes it out of this
+ list so the count can reach zero. Staged files are purged automatically after a week.
+