frontend changes with drop elements
This commit is contained in:
20
src/App.jsx
20
src/App.jsx
@@ -18,7 +18,6 @@ import { LoginPage } from './pages/LoginPage';
|
||||
* 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 McpPage = lazy(() => import('./pages/McpPage').then((m) => ({ default: m.McpPage })));
|
||||
|
||||
@@ -48,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;
|
||||
@@ -78,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"
|
||||
|
||||
@@ -43,11 +43,11 @@ export function NavigationHeader({
|
||||
{/* Left Section: Home/Back Button + Page Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={role === 'admin' ? '/' : '/user'}
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'}
|
||||
title="Navigate to Catalog"
|
||||
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" />
|
||||
@@ -107,16 +107,6 @@ export function NavigationHeader({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 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' ? '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>
|
||||
|
||||
{/* MCP tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
|
||||
@@ -35,10 +35,9 @@ export function LoginPage() {
|
||||
try {
|
||||
const user = await login(username, password);
|
||||
setLoading(false);
|
||||
// Route by the role the SERVER assigned. Only `admin` can sign in now,
|
||||
// but a token minted for any other role still lands somewhere valid.
|
||||
if (user.role === 'admin') navigate('/admin');
|
||||
else navigate('/user');
|
||||
// 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
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { ProductModal } from '../components/ProductModal';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
import {
|
||||
PlusCircle, Upload, CheckCircle2, Database, Sparkles,
|
||||
RefreshCw, Search, FileSpreadsheet, Download, LayoutGrid,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
export function UserPage() {
|
||||
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);
|
||||
|
||||
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');
|
||||
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);
|
||||
|
||||
// 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)) {
|
||||
// Re-read the brand from the API rather than trusting the response, so
|
||||
// the panel below is showing what the database actually holds. A card
|
||||
// built purely from the upload response is what made a failed import
|
||||
// look successful.
|
||||
const firstBrand = data.added_products[0]?.brand;
|
||||
if (firstBrand) {
|
||||
setBrandSearch(firstBrand);
|
||||
fetchBrandCatalog(firstBrand);
|
||||
}
|
||||
|
||||
}
|
||||
} 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);
|
||||
};
|
||||
|
||||
// A spreadsheet import can genuinely half-succeed, and that must not be
|
||||
// painted the same green as a clean one - the whole complaint was a success
|
||||
// panel over rows that never landed.
|
||||
const batchHadErrors = Boolean(
|
||||
batchResult && ((batchResult.error_count ?? 0) > 0 || batchResult.status === 'partial')
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-paper-100 font-sans text-ink-950">
|
||||
<NavigationHeader
|
||||
title="User Product Workspace"
|
||||
subtitle="Add and batch-upload product records with DB & JSON sync"
|
||||
icon={PlusCircle}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
{/* Workspace banner */}
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ========================================================================= */}
|
||||
{/* VIEW 1: PRODUCT ENTRY & BATCH 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchResult && (
|
||||
<div
|
||||
className={`rounded-2xl border p-6 shadow-sm space-y-3 ${
|
||||
batchHadErrors
|
||||
? 'border-amber-500/40 bg-amber-50/70'
|
||||
: 'border-emerald-500/30 bg-emerald-50/70'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center gap-2 ${batchHadErrors ? 'text-amber-700' : 'text-emerald-700'}`}>
|
||||
{batchHadErrors ? <AlertTriangle className="h-5 w-5" /> : <CheckCircle2 className="h-5 w-5" />}
|
||||
<h4 className="font-bold text-sm">
|
||||
{batchHadErrors ? 'Batch Partially Imported' : 'Batch Products Saved to Database'}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-700">{batchResult.message}</p>
|
||||
|
||||
{/* Counts, so "success" is never a claim without a number
|
||||
behind it. added_count is the number of rows verified
|
||||
present in the database, not the number attempted. */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-xl bg-white border border-emerald-200 py-2">
|
||||
<p className="text-base font-extrabold text-emerald-700">{batchResult.added_count ?? 0}</p>
|
||||
<p className="text-[10px] font-semibold text-slate-500 uppercase">Saved</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-rose-200 py-2">
|
||||
<p className="text-base font-extrabold text-rose-700">{batchResult.error_count ?? 0}</p>
|
||||
<p className="text-[10px] font-semibold text-slate-500 uppercase">Failed</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-slate-200 py-2">
|
||||
<p className="text-base font-extrabold text-slate-700">{batchResult.skipped_blank_rows ?? 0}</p>
|
||||
<p className="text-[10px] font-semibold text-slate-500 uppercase">Blank Rows</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.isArray(batchResult.warnings) && batchResult.warnings.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{batchResult.warnings.map((w, idx) => (
|
||||
<li key={idx} className="flex items-start gap-1.5 text-[11px] text-amber-800">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0 mt-0.5" />
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{Array.isArray(batchResult.errors) && batchResult.errors.length > 0 && (
|
||||
<div className="rounded-xl bg-white border border-rose-200 p-3 space-y-1 max-h-40 overflow-y-auto no-scrollbar">
|
||||
<p className="text-[11px] font-bold text-rose-800">Rows that were not saved</p>
|
||||
{batchResult.errors.map((e, idx) => (
|
||||
<p key={idx} className="text-[11px] text-slate-600 font-mono">
|
||||
{e.row ? `Row ${e.row}: ` : ''}
|
||||
{e.product_name ? `${e.product_name} - ` : ''}
|
||||
{e.error}
|
||||
</p>
|
||||
))}
|
||||
{batchResult.errors_truncated > 0 && (
|
||||
<p className="text-[11px] text-slate-400 italic">
|
||||
+{batchResult.errors_truncated} more not shown
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</main>
|
||||
|
||||
{/* Interactive Product Details Modal */}
|
||||
{selectedProduct && (
|
||||
<ProductModal product={selectedProduct} onClose={() => setSelectedProduct(null)} />
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user