feat: add SellerContext provider and inventory and bulk upload dashboard pages
This commit is contained in:
parent
a196780135
commit
2874bf94df
3 changed files with 1023 additions and 324 deletions
|
|
@ -168,6 +168,7 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
|||
compare_at_price: parseFloat(p.compare_at_price) || undefined,
|
||||
package_weight: p.package_weight || 1.0,
|
||||
shipping_profile: p.shipping_profile || 'Standard Fragile',
|
||||
uploadSource: p.upload_source || (p.sku?.startsWith('TRDX-') || p.sku?.includes('CSV') || p.sku?.startsWith('BATCH-') ? 'bulk' : 'individual'),
|
||||
salesCount: 0
|
||||
})));
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,27 @@ import {
|
|||
FileText,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Archive,
|
||||
Info
|
||||
Info,
|
||||
Package,
|
||||
Layers,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
Minus,
|
||||
Award,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Tag,
|
||||
ShieldCheck,
|
||||
Check,
|
||||
X,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
ArrowUpRight
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../services/api';
|
||||
import { useSeller } from '../../context/SellerContext';
|
||||
import { formatISTDateTime } from '../../utils/date';
|
||||
|
||||
interface BatchLog {
|
||||
id: string;
|
||||
|
|
@ -26,8 +41,23 @@ interface BatchLog {
|
|||
errorSummary?: string;
|
||||
}
|
||||
|
||||
const HEALTH_CONFIG: Record<string, { label: string; bg: string; color: string; dot: string }> = {
|
||||
Critical: { label: 'Critical', bg: '#FEE2E2', color: '#991B1B', dot: '#DC2626' },
|
||||
Low: { label: 'Low Buffer', bg: '#FFF3E0', color: '#C2410C', dot: '#EA580C' },
|
||||
Adequate: { label: 'Adequate', bg: '#FEF9C3', color: '#854D0E', dot: '#CA8A04' },
|
||||
Healthy: { label: 'Healthy', bg: '#E6F4EA', color: '#166534', dot: '#16A34A' },
|
||||
};
|
||||
|
||||
function healthFromStock(stock: number): string {
|
||||
if (stock === 0) return 'Critical';
|
||||
if (stock <= 4) return 'Low';
|
||||
if (stock <= 15) return 'Adequate';
|
||||
return 'Healthy';
|
||||
}
|
||||
|
||||
export const BulkUploadPage: React.FC = () => {
|
||||
const { fetchData } = useSeller();
|
||||
const { inventory, updateStock, deleteProduct, fetchData } = useSeller();
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||
const [uploadSummary, setUploadSummary] = useState<{ total: number; created: number; updated: number; skipped: number; errors: string[] } | null>(null);
|
||||
|
|
@ -38,6 +68,31 @@ export const BulkUploadPage: React.FC = () => {
|
|||
const [batches, setBatches] = useState<BatchLog[]>([]);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
|
||||
// Filters for items
|
||||
const [activeTab, setActiveTab] = useState<'all_items' | 'bulk_logs'>('all_items');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'bulk' | 'individual'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState('All');
|
||||
const [stockFilter, setStockFilter] = useState<'all' | 'in_stock' | 'low_stock'>('all');
|
||||
|
||||
// Quick Edit Modal state
|
||||
const [editingProduct, setEditingProduct] = useState<any | null>(null);
|
||||
const [editPrice, setEditPrice] = useState<number | ''>('');
|
||||
const [editStock, setEditStock] = useState<number | ''>('');
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
const [stockValues, setStockValues] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setStockValues(prev => {
|
||||
const next = { ...prev };
|
||||
inventory.forEach(item => {
|
||||
next[item.id] = item.stock;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [inventory]);
|
||||
|
||||
const triggerCsvDownload = (csvString: string, filename = 'Validation_Errors.csv') => {
|
||||
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -82,18 +137,7 @@ export const BulkUploadPage: React.FC = () => {
|
|||
errorSummary: l.error_summary
|
||||
})));
|
||||
} else {
|
||||
// Fallback demo log if no history exists yet
|
||||
setBatches([
|
||||
{
|
||||
id: 'BATCH-2026-DEFAULT',
|
||||
fileName: 'Craft_Standard_Catalog_Template.csv',
|
||||
date: formatISTDateTime(),
|
||||
totalRows: 3,
|
||||
successfulRows: 3,
|
||||
failedRows: 0,
|
||||
status: 'Completed'
|
||||
}
|
||||
]);
|
||||
setBatches([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch bulk upload logs, fallback to local state:', err);
|
||||
|
|
@ -104,6 +148,7 @@ export const BulkUploadPage: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
loadBatchLogs();
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -175,30 +220,217 @@ export const BulkUploadPage: React.FC = () => {
|
|||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Classify item source: Bulk vs Individual
|
||||
const getItemSourceInfo = (item: any) => {
|
||||
const isBulk = item.uploadSource === 'bulk' ||
|
||||
item.sku?.startsWith('TRDX-') ||
|
||||
item.sku?.startsWith('BATCH-') ||
|
||||
item.sku?.includes('CSV');
|
||||
if (isBulk) {
|
||||
return {
|
||||
type: 'bulk',
|
||||
label: 'Bulk CSV Batch',
|
||||
icon: FileSpreadsheet,
|
||||
bg: '#EFF6FF',
|
||||
color: '#1D4ED8',
|
||||
border: '#BFDBFE'
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'individual',
|
||||
label: 'Individual Form',
|
||||
icon: Plus,
|
||||
bg: '#F0FDF4',
|
||||
color: '#15803D',
|
||||
border: '#BBF7D0'
|
||||
};
|
||||
};
|
||||
|
||||
const handleStepStock = (id: string, delta: number) => {
|
||||
setStockValues(prev => {
|
||||
const current = prev[id] ?? (inventory.find(i => i.id === id)?.stock ?? 0);
|
||||
const next = Math.max(0, current + delta);
|
||||
updateStock(id, next);
|
||||
return { ...prev, [id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id: string, title: string) => {
|
||||
if (window.confirm(`Are you sure you want to remove "${title}" from your catalog?`)) {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete product');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEdit = (item: any) => {
|
||||
setEditingProduct(item);
|
||||
setEditPrice(item.price);
|
||||
setEditStock(stockValues[item.id] ?? item.stock);
|
||||
};
|
||||
|
||||
const handleSaveEditModal = async () => {
|
||||
if (!editingProduct) return;
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
const updatedPrice = editPrice !== '' ? Number(editPrice) : editingProduct.price;
|
||||
const updatedStock = editStock !== '' ? Number(editStock) : (stockValues[editingProduct.id] ?? editingProduct.stock);
|
||||
|
||||
if (editingProduct.id && !String(editingProduct.id).startsWith('prod-')) {
|
||||
await api.patchProduct(editingProduct.id, {
|
||||
price: updatedPrice,
|
||||
stock: updatedStock
|
||||
});
|
||||
}
|
||||
await updateStock(editingProduct.id, updatedStock);
|
||||
await fetchData();
|
||||
setEditingProduct(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Could not update product details');
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered inventory list
|
||||
const categoriesList = ['All', ...Array.from(new Set(inventory.map(i => i.category || 'General')))];
|
||||
|
||||
const filteredInventory = inventory.filter(item => {
|
||||
const q = searchTerm.toLowerCase();
|
||||
const matchesSearch = !q ||
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.sku.toLowerCase().includes(q) ||
|
||||
item.category.toLowerCase().includes(q);
|
||||
|
||||
const source = getItemSourceInfo(item).type;
|
||||
const matchesSource = sourceFilter === 'all' ? true : sourceFilter === source;
|
||||
const matchesCategory = categoryFilter === 'All' || item.category === categoryFilter;
|
||||
|
||||
const currentStock = stockValues[item.id] ?? item.stock;
|
||||
const matchesStock = stockFilter === 'all' ? true : stockFilter === 'in_stock' ? currentStock > 0 : currentStock === 0;
|
||||
|
||||
return matchesSearch && matchesSource && matchesCategory && matchesStock;
|
||||
});
|
||||
|
||||
const bulkCount = inventory.filter(i => getItemSourceInfo(i).type === 'bulk').length;
|
||||
const individualCount = inventory.filter(i => getItemSourceInfo(i).type === 'individual').length;
|
||||
const totalCatalogValue = inventory.reduce((acc, i) => acc + ((stockValues[i.id] ?? i.stock) * i.price), 0);
|
||||
const lowStockCount = inventory.filter(i => (stockValues[i.id] ?? i.stock) <= 4).length;
|
||||
|
||||
return (
|
||||
<div className="bulk-upload-page" style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
|
||||
{/* Top Header */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 'clamp(1.25rem, 2.5vw, 1.55rem)', fontWeight: 800, color: '#111827', letterSpacing: '-0.02em', lineHeight: 1.2 }}>
|
||||
Bulk Catalog & Batch Management
|
||||
Bulk & Account Catalog Registry
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.84rem', color: '#6B7280', marginTop: '4px' }}>
|
||||
Add or update dozens of handcrafted SKUs at once using our official spreadsheets or image-packed ZIP archives.
|
||||
Manage all products uploaded from your account — both bulk CSV/ZIP batch uploads and individual craft listings.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { fetchData(); loadBatchLogs(); }}
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: '10px',
|
||||
padding: '8px 14px',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.03)'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoadingLogs ? 'is-loading' : ''} />
|
||||
<span>Sync Account Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary KPI Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.25rem', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#6B7280', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Total Account SKUs</span>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: '#FFF0F2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Package size={16} color="#701A29" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800, color: '#111827' }}>{inventory.length}</div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#6B7280', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ color: '#16A34A', fontWeight: 700 }}>✓ All Active</span>
|
||||
<span>· Ready for discovery</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.25rem', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#6B7280', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Bulk Batch Uploads</span>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: '#EFF6FF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FileSpreadsheet size={16} color="#2563EB" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800, color: '#111827' }}>{bulkCount} <span style={{ fontSize: '0.82rem', color: '#6B7280', fontWeight: 500 }}>SKUs</span></div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#2563EB', marginTop: '2px', fontWeight: 600 }}>
|
||||
{batches.length} Batch Log Records
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.25rem', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#6B7280', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Individual Uploads</span>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: '#F0FDF4', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Plus size={16} color="#16A34A" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800, color: '#111827' }}>{individualCount} <span style={{ fontSize: '0.82rem', color: '#6B7280', fontWeight: 500 }}>SKUs</span></div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#16A34A', marginTop: '2px', fontWeight: 600 }}>
|
||||
Direct craft form entry
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.25rem', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: '#6B7280', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Catalog Value</span>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: '#FEF3C7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Tag size={16} color="#D97706" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800, color: '#111827' }}>₹{totalCatalogValue.toLocaleString('en-IN')}</div>
|
||||
<div style={{ fontSize: '0.72rem', color: lowStockCount > 0 ? '#DC2626' : '#6B7280', marginTop: '2px', fontWeight: lowStockCount > 0 ? 700 : 400 }}>
|
||||
{lowStockCount > 0 ? `⚠ ${lowStockCount} items low stock` : '✓ Stock levels healthy'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* File Upload Zone (Drop Zone & Download Template Cards) */}
|
||||
<div className="columns is-multiline is-variable is-5 mb-0">
|
||||
|
||||
{/* Left: File Upload Zone */}
|
||||
<div className="column is-7-desktop is-12-tablet">
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '18px', padding: '2rem', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '18px', padding: '1.75rem', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<h2 style={{ fontSize: '1.15rem', fontWeight: 800, color: '#111827' }}>Upload Batch File</h2>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 800, color: '#111827' }}>Upload Bulk Batch File</h2>
|
||||
<span style={{ fontSize: '0.72rem', backgroundColor: '#F3F4F6', color: '#4B5563', padding: '3px 8px', borderRadius: '6px', fontWeight: 600 }}>
|
||||
.CSV · .ZIP
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.78rem', color: '#6B7280', marginBottom: '1.25rem' }}>
|
||||
Supported formats: <strong>.csv</strong> (catalog list) or <strong>.zip</strong> (catalog + image folders). Max 15 MB.
|
||||
Supported formats: <strong>.csv</strong> (catalog list) or <strong>.zip</strong> (catalog + product image folders).
|
||||
</p>
|
||||
|
||||
<input
|
||||
|
|
@ -215,7 +447,7 @@ export const BulkUploadPage: React.FC = () => {
|
|||
border: '2px dashed #701A29',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: '#FFF9F9',
|
||||
padding: '2.5rem 1.5rem',
|
||||
padding: '2.2rem 1.25rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
|
|
@ -223,14 +455,14 @@ export const BulkUploadPage: React.FC = () => {
|
|||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#FFF0F2'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#FFF9F9'}
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '50%', backgroundColor: '#FEE2E2', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}>
|
||||
<UploadCloud size={24} color="#701A29" />
|
||||
<div style={{ width: '44px', height: '44px', borderRadius: '50%', backgroundColor: '#FEE2E2', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 10px' }}>
|
||||
<UploadCloud size={22} color="#701A29" />
|
||||
</div>
|
||||
<div style={{ fontWeight: 800, fontSize: '0.95rem', color: '#111827', marginBottom: '4px' }}>
|
||||
{isUploading ? 'Validating & Ingesting SKUs to Database...' : 'Click to Browse or Drop Batch File Here'}
|
||||
<div style={{ fontWeight: 800, fontSize: '0.92rem', color: '#111827', marginBottom: '4px' }}>
|
||||
{isUploading ? 'Validating & Ingesting SKUs...' : 'Click to Browse or Drop Batch File Here'}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.75rem', color: '#6B7280', marginBottom: '1rem', maxWidth: '380px', margin: '0 auto 1rem auto' }}>
|
||||
Columns are automatically verified for GST rates, craft origin, prices, and stock inventory.
|
||||
<p style={{ fontSize: '0.74rem', color: '#6B7280', marginBottom: '1rem', maxWidth: '380px', margin: '0 auto 1rem auto' }}>
|
||||
Automatic validation for GST rates, craft origin, price, stock, and GI certification tags.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -328,13 +560,13 @@ export const BulkUploadPage: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Template Download and Instructions */}
|
||||
{/* Right: Template Download & Direct Upload Options */}
|
||||
<div className="column is-5-desktop is-12-tablet">
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '18px', padding: '1.75rem', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.15rem', fontWeight: 800, color: '#111827', marginBottom: '4px' }}>Official Catalog Template</h3>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 800, color: '#111827', marginBottom: '4px' }}>Official Catalog Template</h3>
|
||||
<p style={{ fontSize: '0.78rem', color: '#6B7280', marginBottom: '1.25rem' }}>
|
||||
Use our standardized template to avoid inventory sync delays or incorrect GI tagging.
|
||||
Download our standardized CSV template for batch product imports.
|
||||
</p>
|
||||
|
||||
<div style={{ background: '#FAF8F5', borderRadius: '14px', border: '1px solid #E5E7EB', padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem' }}>
|
||||
|
|
@ -370,53 +602,386 @@ export const BulkUploadPage: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#F9FAFB', borderRadius: '12px', padding: '12px 16px', fontSize: '0.75rem', color: '#4B5563', lineHeight: 1.6 }}>
|
||||
<div style={{ fontWeight: 700, color: '#111827', marginBottom: '4px' }}>Spreadsheet Guidelines:</div>
|
||||
<div style={{ fontWeight: 700, color: '#111827', marginBottom: '4px' }}>Batch Standards:</div>
|
||||
<ul style={{ paddingLeft: '1.2rem', listStyleType: 'disc' }}>
|
||||
<li><strong>Title & SKU</strong>: Unique identifier per variant.</li>
|
||||
<li><strong>Price & MRP</strong>: Pure numeric values in INR.</li>
|
||||
<li><strong>Price & MRP</strong>: Numeric values in INR.</li>
|
||||
<li><strong>GI_Tagged</strong>: Set to <code>Yes</code> or <code>No</code>.</li>
|
||||
<li><strong>ZIP Ingestion</strong>: Name your image files to match the SKU (e.g. <code>TRDX-001.jpg</code>).</li>
|
||||
<li><strong>ZIP Ingestion</strong>: Name image files matching SKU (e.g. <code>TRDX-001.jpg</code>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '1rem', fontSize: '0.72rem', color: '#6B7280' }}>
|
||||
<Info size={14} color="#701A29" />
|
||||
<span>Need help? Check Heritage Cataloging standards in Settings.</span>
|
||||
</div>
|
||||
<span>Have a single craft item to add? Use the direct add product form.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Processing History Table */}
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '18px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid #F3F4F6', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '0.96rem', fontWeight: 800, color: '#111827' }}>Recent Batch History & Upload Logs</h3>
|
||||
<p style={{ fontSize: '0.75rem', color: '#6B7280', marginTop: '2px' }}>Track live processing results from your bulk uploads</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs for All Account Items & Bulk Logs */}
|
||||
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '18px', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
|
||||
{/* Tab Header */}
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid #E5E7EB', display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', backgroundColor: '#FAF9F6' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadBatchLogs}
|
||||
disabled={isLoadingLogs}
|
||||
onClick={() => setActiveTab('all_items')}
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E5E7EB',
|
||||
backgroundColor: activeTab === 'all_items' ? '#701A29' : '#FFFFFF',
|
||||
color: activeTab === 'all_items' ? '#FFFFFF' : '#374151',
|
||||
border: activeTab === 'all_items' ? 'none' : '1px solid #D1D5DB',
|
||||
borderRadius: '9999px',
|
||||
padding: '8px 18px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: activeTab === 'all_items' ? '0 2px 4px rgba(112,26,41,0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<Package size={15} />
|
||||
<span>All Uploaded Items ({inventory.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('bulk_logs')}
|
||||
style={{
|
||||
backgroundColor: activeTab === 'bulk_logs' ? '#701A29' : '#FFFFFF',
|
||||
color: activeTab === 'bulk_logs' ? '#FFFFFF' : '#374151',
|
||||
border: activeTab === 'bulk_logs' ? 'none' : '1px solid #D1D5DB',
|
||||
borderRadius: '9999px',
|
||||
padding: '8px 18px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: activeTab === 'bulk_logs' ? '0 2px 4px rgba(112,26,41,0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<FileSpreadsheet size={15} />
|
||||
<span>Bulk Batch History ({batches.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.78rem', color: '#6B7280', fontWeight: 500 }}>
|
||||
{activeTab === 'all_items' ? `Showing ${filteredInventory.length} of ${inventory.length} total products` : `Showing ${batches.length} batch upload sessions`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: All Uploaded Items (Registry with Bulk & Individual details) */}
|
||||
{activeTab === 'all_items' && (
|
||||
<div>
|
||||
{/* Filter & Search Toolbar */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid #F3F4F6', display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', backgroundColor: '#FFFFFF' }}>
|
||||
|
||||
{/* Search input */}
|
||||
<div style={{ position: 'relative', minWidth: '260px', flex: '1 1 300px' }}>
|
||||
<Search size={16} color="#9CA3AF" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by SKU, Product Title, or Craft Category..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px 8px 36px',
|
||||
fontSize: '0.8rem',
|
||||
borderRadius: '8px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
border: '1px solid #D1D5DB',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '10px' }}>
|
||||
|
||||
{/* Source Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: '#6B7280', fontWeight: 600 }}>Source:</span>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={e => setSourceFilter(e.target.value as any)}
|
||||
style={{ padding: '6px 10px', borderRadius: '8px', border: '1px solid #D1D5DB', fontSize: '0.78rem', backgroundColor: '#FFFFFF', cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
<option value="all">All Sources ({inventory.length})</option>
|
||||
<option value="bulk">Bulk CSV Batch ({bulkCount})</option>
|
||||
<option value="individual">Individual Form ({individualCount})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: '#6B7280', fontWeight: 600 }}>Category:</span>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
style={{ padding: '6px 10px', borderRadius: '8px', border: '1px solid #D1D5DB', fontSize: '0.78rem', backgroundColor: '#FFFFFF', cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
{categoriesList.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stock Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: '#6B7280', fontWeight: 600 }}>Stock:</span>
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={e => setStockFilter(e.target.value as any)}
|
||||
style={{ padding: '6px 10px', borderRadius: '8px', border: '1px solid #D1D5DB', fontSize: '0.78rem', backgroundColor: '#FFFFFF', cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
<option value="all">All Stock Levels</option>
|
||||
<option value="in_stock">In Stock (>0)</option>
|
||||
<option value="low_stock">Out of Stock (=0)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Product Items Table */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#F9FAFB', borderBottom: '1px solid #E5E7EB', fontSize: '0.72rem', fontWeight: 700, color: '#6B7280', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<th style={{ padding: '12px 18px' }}>Craft Product & SKU</th>
|
||||
<th style={{ padding: '12px 18px' }}>Upload Source</th>
|
||||
<th style={{ padding: '12px 18px' }}>Category & GI Tag</th>
|
||||
<th style={{ padding: '12px 18px' }}>Pricing (INR)</th>
|
||||
<th style={{ padding: '12px 18px' }}>Stock & Adjust</th>
|
||||
<th style={{ padding: '12px 18px' }}>Status</th>
|
||||
<th style={{ padding: '12px 18px', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredInventory.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '36px', textAlign: 'center', color: '#6B7280', fontSize: '0.84rem' }}>
|
||||
No items found matching your filter criteria. Try uploading a batch file above or clearing filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredInventory.map(item => {
|
||||
const srcInfo = getItemSourceInfo(item);
|
||||
const SourceIcon = srcInfo.icon;
|
||||
const stockVal = stockValues[item.id] ?? item.stock;
|
||||
const healthKey = healthFromStock(stockVal);
|
||||
const healthCfg = HEALTH_CONFIG[healthKey] || HEALTH_CONFIG.Adequate;
|
||||
const discountPct = item.mrp && item.mrp > item.price ? Math.round(((item.mrp - item.price) / item.mrp) * 100) : 0;
|
||||
|
||||
return (
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid #F3F4F6', fontSize: '0.78rem' }}>
|
||||
|
||||
{/* Item Details */}
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<img
|
||||
src={item.image || '/images/placeholder.jpg'}
|
||||
alt={item.title}
|
||||
style={{ width: '44px', height: '44px', borderRadius: '8px', objectFit: 'cover', border: '1px solid #E5E7EB' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).setAttribute('src', 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?auto=format&fit=crop&q=80&w=200');
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: '#111827', fontSize: '0.84rem', lineHeight: 1.3 }}>{item.title}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
|
||||
<span style={{ fontSize: '0.7rem', fontWeight: 700, color: '#701A29', backgroundColor: '#FFF0F2', padding: '1px 6px', borderRadius: '4px' }}>
|
||||
{item.sku}
|
||||
</span>
|
||||
{item.giTagged && (
|
||||
<span style={{ fontSize: '0.66rem', fontWeight: 700, color: '#047857', backgroundColor: '#D1FAE5', padding: '1px 6px', borderRadius: '4px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}>
|
||||
<ShieldCheck size={10} />
|
||||
GI Tagged
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Source Badge */}
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
backgroundColor: srcInfo.bg,
|
||||
color: srcInfo.color,
|
||||
border: `1px solid ${srcInfo.border}`,
|
||||
borderRadius: '9999px',
|
||||
padding: '4px 10px'
|
||||
}}>
|
||||
<SourceIcon size={12} />
|
||||
<span>{srcInfo.label}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Category */}
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#374151' }}>{item.category}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#6B7280', marginTop: '2px' }}>{item.craftTechnique || 'Authentic Craft'}</div>
|
||||
</td>
|
||||
|
||||
{/* Pricing */}
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
<div style={{ fontWeight: 700, color: '#111827', fontSize: '0.85rem' }}>₹{item.price.toLocaleString('en-IN')}</div>
|
||||
{item.mrp && item.mrp > item.price && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#9CA3AF', textDecoration: 'line-through' }}>
|
||||
MRP ₹{item.mrp.toLocaleString('en-IN')} {discountPct > 0 && <span style={{ color: '#16A34A', textDecoration: 'none', fontWeight: 700 }}>({discountPct}% off)</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Stock Controls */}
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStepStock(item.id, -1)}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
backgroundColor: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={12} className={isLoadingLogs ? 'is-loading' : ''} />
|
||||
<span>Refresh Logs</span>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, minWidth: '24px', textAlign: 'center', fontSize: '0.85rem', color: stockVal === 0 ? '#DC2626' : '#111827' }}>
|
||||
{stockVal}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStepStock(item.id, 1)}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
backgroundColor: '#FFFFFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<span style={{
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
backgroundColor: healthCfg.bg,
|
||||
color: healthCfg.color,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px'
|
||||
}}>
|
||||
<span style={{ width: '5px', height: '5px', borderRadius: '50%', backgroundColor: healthCfg.dot }}></span>
|
||||
{healthCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Approval Status */}
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
<span style={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: item.approvalStatus === 'approved' ? '#DCFCE7' : item.approvalStatus === 'rejected' ? '#FEE2E2' : '#FEF3C7',
|
||||
color: item.approvalStatus === 'approved' ? '#166534' : item.approvalStatus === 'rejected' ? '#991B1B' : '#92400E',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{item.approvalStatus === 'approved' ? <CheckCircle2 size={12} /> : item.approvalStatus === 'rejected' ? <AlertTriangle size={12} /> : <Clock size={12} />}
|
||||
<span>{item.approvalStatus === 'approved' ? 'Approved & Live' : item.approvalStatus === 'rejected' ? 'Needs Review' : 'In Verification'}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td style={{ padding: '14px 18px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenEdit(item)}
|
||||
title="Quick edit product"
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: '6px',
|
||||
padding: '5px 8px',
|
||||
fontSize: '0.72rem',
|
||||
color: '#374151',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<Edit3 size={12} />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteItem(item.id, item.title)}
|
||||
title="Delete item"
|
||||
style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #FCA5A5',
|
||||
borderRadius: '6px',
|
||||
padding: '5px 8px',
|
||||
fontSize: '0.72rem',
|
||||
color: '#DC2626',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Bulk Batch History & Ingestion Logs */}
|
||||
{activeTab === 'bulk_logs' && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
||||
<thead>
|
||||
|
|
@ -431,7 +996,14 @@ export const BulkUploadPage: React.FC = () => {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => (
|
||||
{batches.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '36px', textAlign: 'center', color: '#6B7280', fontSize: '0.84rem' }}>
|
||||
No bulk upload batches processed yet. Upload your CSV or ZIP batch file above to add products to your catalog.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
batches.map((b) => (
|
||||
<tr key={b.id} style={{ borderBottom: '1px solid #F3F4F6', fontSize: '0.78rem' }}>
|
||||
<td style={{ padding: '14px 18px', fontWeight: 700, color: '#701A29' }}>{b.id}</td>
|
||||
<td style={{ padding: '14px 18px' }}>
|
||||
|
|
@ -481,11 +1053,140 @@ export const BulkUploadPage: React.FC = () => {
|
|||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Quick Edit Modal */}
|
||||
{editingProduct && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: '16px',
|
||||
maxWidth: '460px',
|
||||
width: '100%',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 800, color: '#111827' }}>Quick Edit Product</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProduct(null)}
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#6B7280' }}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '1.25rem', backgroundColor: '#FAF9F6', padding: '10px 12px', borderRadius: '10px' }}>
|
||||
<img
|
||||
src={editingProduct.image || '/images/placeholder.jpg'}
|
||||
alt={editingProduct.title}
|
||||
style={{ width: '40px', height: '40px', borderRadius: '6px', objectFit: 'cover' }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.82rem', color: '#111827' }}>{editingProduct.title}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#6B7280' }}>SKU: {editingProduct.sku}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: '#374151', marginBottom: '4px' }}>
|
||||
Selling Price (₹)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={e => setEditPrice(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: '0.84rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #D1D5DB',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: '#374151', marginBottom: '4px' }}>
|
||||
Available Stock Quantity
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editStock}
|
||||
onChange={e => setEditStock(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: '0.84rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #D1D5DB',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProduct(null)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #D1D5DB',
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#374151',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEditModal}
|
||||
disabled={isSavingEdit}
|
||||
style={{
|
||||
padding: '8px 18px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: '#701A29',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
cursor: isSavingEdit ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isSavingEdit ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeller } from '../../context/SellerContext';
|
||||
import { api } from '../../services/api';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
Plus,
|
||||
Minus,
|
||||
Award,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Package,
|
||||
IndianRupee,
|
||||
|
|
@ -19,8 +17,7 @@ import {
|
|||
Edit3,
|
||||
ShieldCheck,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle
|
||||
FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
|
||||
const HEALTH_CONFIG: Record<string, { label: string; bg: string; color: string; dot: string }> = {
|
||||
|
|
@ -31,8 +28,8 @@ const HEALTH_CONFIG: Record<string, { label: string; bg: string; color: string;
|
|||
};
|
||||
|
||||
function healthFromStock(stock: number): string {
|
||||
if (stock <= 4) return 'Critical';
|
||||
if (stock <= 10) return 'Low';
|
||||
if (stock === 0) return 'Critical';
|
||||
if (stock <= 4) return 'Low';
|
||||
if (stock <= 20) return 'Adequate';
|
||||
return 'Healthy';
|
||||
}
|
||||
|
|
@ -42,14 +39,45 @@ const PAGE_SIZE = 10;
|
|||
export const InventoryPage: React.FC = () => {
|
||||
const { inventory, updateStock, deleteProduct, fetchData } = useSeller();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const getItemSourceInfo = (item: any) => {
|
||||
const isBulk = item.uploadSource === 'bulk' ||
|
||||
item.sku?.startsWith('TRDX-') ||
|
||||
item.sku?.startsWith('BATCH-') ||
|
||||
item.sku?.includes('CSV');
|
||||
if (isBulk) {
|
||||
return {
|
||||
type: 'bulk',
|
||||
label: 'Bulk CSV',
|
||||
icon: FileSpreadsheet,
|
||||
bg: '#EFF6FF',
|
||||
color: '#1D4ED8',
|
||||
border: '#BFDBFE'
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'individual',
|
||||
label: 'Single Craft',
|
||||
icon: Plus,
|
||||
bg: '#F0FDF4',
|
||||
color: '#15803D',
|
||||
border: '#BBF7D0'
|
||||
};
|
||||
};
|
||||
|
||||
const allSkus = inventory.map(inv => ({
|
||||
...inv,
|
||||
subtitle: inv.craftTechnique || inv.category,
|
||||
health: healthFromStock(inv.stock)
|
||||
health: healthFromStock(inv.stock),
|
||||
sourceInfo: getItemSourceInfo(inv)
|
||||
}));
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [stockFilter, setStockFilter] = useState<'all' | 'in_stock' | 'low_stock'>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'bulk' | 'individual'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState('All');
|
||||
const [stockValues, setStockValues] = useState<Record<string, number>>({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -57,7 +85,7 @@ export const InventoryPage: React.FC = () => {
|
|||
const [editPrice, setEditPrice] = useState<number | ''>('');
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setStockValues(prev => {
|
||||
const next = { ...prev };
|
||||
inventory.forEach(inv => {
|
||||
|
|
@ -67,21 +95,24 @@ export const InventoryPage: React.FC = () => {
|
|||
});
|
||||
}, [inventory]);
|
||||
|
||||
const categories = ['All', 'Pottery & Ceramics', 'Brass & Metal Craft', 'Woodcraft & Carvings', 'Handloom & Textiles', 'Jewellery & Ornaments'];
|
||||
const categories = ['All', ...Array.from(new Set(allSkus.map(s => s.category || 'General')))];
|
||||
|
||||
const filtered = allSkus.filter(item => {
|
||||
const q = searchTerm.toLowerCase();
|
||||
const matchSearch = !q || item.title.toLowerCase().includes(q) || item.sku.toLowerCase().includes(q) || item.category.toLowerCase().includes(q);
|
||||
const stk = stockValues[item.id] ?? item.stock;
|
||||
const matchStock = stockFilter === 'all' ? true : stockFilter === 'in_stock' ? stk > 4 : stk <= 4;
|
||||
const matchStock = stockFilter === 'all' ? true : stockFilter === 'in_stock' ? stk > 0 : stk <= 4;
|
||||
const matchSource = sourceFilter === 'all' ? true : sourceFilter === item.sourceInfo.type;
|
||||
const matchCat = categoryFilter === 'All' || item.category === categoryFilter;
|
||||
return matchSearch && matchStock && matchCat;
|
||||
return matchSearch && matchStock && matchSource && matchCat;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
|
||||
const totalListed = allSkus.length;
|
||||
const bulkCount = allSkus.filter(s => s.sourceInfo.type === 'bulk').length;
|
||||
const singleCount = allSkus.filter(s => s.sourceInfo.type === 'individual').length;
|
||||
const lowStockCount = allSkus.filter(s => (stockValues[s.id] ?? s.stock) <= 4).length;
|
||||
const catalogValue = allSkus.reduce((sum, s) => sum + (stockValues[s.id] ?? s.stock) * s.price, 0);
|
||||
const saleableValue = allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 0)
|
||||
|
|
@ -102,6 +133,17 @@ export const InventoryPage: React.FC = () => {
|
|||
updateStock(id, n);
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (id: string, title: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${title}" from your inventory catalog?`)) {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Could not delete product');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingItem) return;
|
||||
setIsSavingEdit(true);
|
||||
|
|
@ -114,6 +156,7 @@ export const InventoryPage: React.FC = () => {
|
|||
if (editingItem.id && !String(editingItem.id).startsWith('prod-')) {
|
||||
await api.patchProduct(editingItem.id, patchPayload);
|
||||
}
|
||||
await updateStock(editingItem.id, currentStock);
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
console.warn('Failed to update product via quick edit drawer:', err);
|
||||
|
|
@ -133,7 +176,7 @@ export const InventoryPage: React.FC = () => {
|
|||
Catalog & Inventory
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.82rem', color: '#6B7280', marginTop: '4px' }}>
|
||||
Manage your artisan SKUs, stock levels, and pricing across all craft disciplines.
|
||||
Manage your artisan SKUs (both single and bulk uploaded items), stock levels, and pricing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="is-flex" style={{ gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||
|
|
@ -158,10 +201,11 @@ export const InventoryPage: React.FC = () => {
|
|||
<Package size={14} color="#BE123C" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#111827', lineHeight: 1.1 }}>{totalListed} <span style={{ fontSize: '1rem', fontWeight: 600, color: '#6B7280' }}>SKUs</span></div>
|
||||
<div style={{ fontSize: '1.8rem', fontWeight: 800, color: '#111827', lineHeight: 1.1 }}>{totalListed} <span style={{ fontSize: '0.9rem', fontWeight: 600, color: '#6B7280' }}>SKUs</span></div>
|
||||
<div className="is-flex is-align-items-center mt-2" style={{ gap: '6px', fontSize: '0.74rem' }}>
|
||||
<span style={{ color: '#16A34A', fontWeight: 700 }}>↑ +6 this week</span>
|
||||
<span style={{ color: '#6B7280' }}>· {allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 0).length} active</span>
|
||||
<span style={{ color: '#2563EB', fontWeight: 700 }}>{bulkCount} Bulk</span>
|
||||
<span style={{ color: '#6B7280' }}>·</span>
|
||||
<span style={{ color: '#16A34A', fontWeight: 700 }}>{singleCount} Single</span>
|
||||
</div>
|
||||
<div style={{ width: '36px', height: '3px', backgroundColor: '#701A29', borderRadius: '2px', marginTop: '8px' }} />
|
||||
</div>
|
||||
|
|
@ -175,8 +219,8 @@ export const InventoryPage: React.FC = () => {
|
|||
<AlertTriangle size={14} color="#DC2626" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 800, color: lowStockCount > 0 ? '#DC2626' : '#16A34A', lineHeight: 1.1 }}>
|
||||
{lowStockCount} <span style={{ fontSize: '1rem', fontWeight: 600, color: '#6B7280' }}>Crafts</span>
|
||||
<div style={{ fontSize: '1.8rem', fontWeight: 800, color: lowStockCount > 0 ? '#DC2626' : '#16A34A', lineHeight: 1.1 }}>
|
||||
{lowStockCount} <span style={{ fontSize: '0.9rem', fontWeight: 600, color: '#6B7280' }}>Crafts</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.74rem', color: '#6B7280', marginTop: '6px' }}>
|
||||
{lowStockCount > 0 ? 'Restock urgently to avoid cancellations' : 'All SKUs adequately stocked'}
|
||||
|
|
@ -207,19 +251,43 @@ export const InventoryPage: React.FC = () => {
|
|||
{/* Filter Controls */}
|
||||
<div className="p-4" style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
|
||||
<div className="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap" style={{ gap: '0.75rem' }}>
|
||||
|
||||
<div className="is-flex" style={{ gap: '6px', flexWrap: 'wrap' }}>
|
||||
{([
|
||||
{ key: 'all', label: `All (${allSkus.length})` },
|
||||
{ key: 'in_stock', label: `In Stock (${allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 4).length})` },
|
||||
{ key: 'low_stock', label: `Low Stock (${lowStockCount})` },
|
||||
] as const).map(f => (
|
||||
<button key={f.key} type="button" onClick={() => { setStockFilter(f.key); setCurrentPage(1); }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSourceFilter('all'); setStockFilter('all'); setCurrentPage(1); }}
|
||||
className="button is-small"
|
||||
style={{ borderRadius: '9999px', backgroundColor: stockFilter === f.key ? '#701A29' : '#F9FAFB', borderColor: stockFilter === f.key ? '#701A29' : '#E5E7EB', color: stockFilter === f.key ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px', transition: 'all 0.15s ease' }}
|
||||
style={{ borderRadius: '9999px', backgroundColor: (sourceFilter === 'all' && stockFilter === 'all') ? '#701A29' : '#F9FAFB', borderColor: (sourceFilter === 'all' && stockFilter === 'all') ? '#701A29' : '#E5E7EB', color: (sourceFilter === 'all' && stockFilter === 'all') ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
|
||||
>
|
||||
{f.label}
|
||||
All ({allSkus.length})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSourceFilter('bulk'); setCurrentPage(1); }}
|
||||
className="button is-small"
|
||||
style={{ borderRadius: '9999px', backgroundColor: sourceFilter === 'bulk' ? '#2563EB' : '#F9FAFB', borderColor: sourceFilter === 'bulk' ? '#2563EB' : '#E5E7EB', color: sourceFilter === 'bulk' ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
|
||||
>
|
||||
Bulk Uploaded ({bulkCount})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSourceFilter('individual'); setCurrentPage(1); }}
|
||||
className="button is-small"
|
||||
style={{ borderRadius: '9999px', backgroundColor: sourceFilter === 'individual' ? '#16A34A' : '#F9FAFB', borderColor: sourceFilter === 'individual' ? '#16A34A' : '#E5E7EB', color: sourceFilter === 'individual' ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
|
||||
>
|
||||
Single Uploaded ({singleCount})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStockFilter('low_stock'); setCurrentPage(1); }}
|
||||
className="button is-small"
|
||||
style={{ borderRadius: '9999px', backgroundColor: stockFilter === 'low_stock' ? '#DC2626' : '#F9FAFB', borderColor: stockFilter === 'low_stock' ? '#DC2626' : '#E5E7EB', color: stockFilter === 'low_stock' ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
|
||||
>
|
||||
Low Stock ({lowStockCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="is-flex is-align-items-center" style={{ gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||
|
|
@ -268,6 +336,8 @@ export const InventoryPage: React.FC = () => {
|
|||
const stk = stockValues[item.id] ?? item.stock;
|
||||
const health = (item as { health?: string }).health || healthFromStock(stk);
|
||||
const hConf = HEALTH_CONFIG[health] || HEALTH_CONFIG['Healthy'];
|
||||
const SrcIcon = item.sourceInfo.icon;
|
||||
|
||||
return (
|
||||
<tr key={item.id} style={{ borderBottom: idx < paginated.length - 1 ? '1px solid #F3F4F6' : 'none' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#FAFAFA')}
|
||||
|
|
@ -275,16 +345,33 @@ export const InventoryPage: React.FC = () => {
|
|||
>
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '260px' }}>
|
||||
<div className="is-flex is-align-items-center" style={{ gap: '12px' }}>
|
||||
<img src={item.image} alt={item.title}
|
||||
<img src={item.image || '/images/placeholder.jpg'} alt={item.title}
|
||||
style={{ width: '48px', height: '48px', borderRadius: '10px', objectFit: 'cover', border: '1px solid #E5E7EB', flexShrink: 0 }}
|
||||
onError={e => { (e.target as HTMLImageElement).src = '/terracotta_vases.jpg'; }}
|
||||
onError={e => { (e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?auto=format&fit=crop&q=80&w=200'; }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.86rem', fontWeight: 700, color: '#111827', lineHeight: 1.3 }}>{item.title}</div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#6B7280', marginTop: '2px' }}>
|
||||
{(item as { subtitle?: string }).subtitle || item.craftTechnique}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', fontWeight: 700, color: '#701A29', marginTop: '3px' }}>{item.sku}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '3px' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontWeight: 700, color: '#701A29' }}>{item.sku}</span>
|
||||
<span style={{
|
||||
fontSize: '0.62rem',
|
||||
fontWeight: 700,
|
||||
backgroundColor: item.sourceInfo.bg,
|
||||
color: item.sourceInfo.color,
|
||||
border: `1px solid ${item.sourceInfo.border}`,
|
||||
borderRadius: '4px',
|
||||
padding: '1px 5px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px'
|
||||
}}>
|
||||
<SrcIcon size={9} />
|
||||
{item.sourceInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -301,8 +388,12 @@ export const InventoryPage: React.FC = () => {
|
|||
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '120px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#111827' }}>₹{item.price.toLocaleString('en-IN')}</div>
|
||||
{item.mrp && item.mrp > item.price && (
|
||||
<div style={{ fontSize: '0.72rem', color: '#9CA3AF', textDecoration: 'line-through', marginTop: '2px' }}>MRP ₹{item.mrp.toLocaleString('en-IN')}</div>
|
||||
)}
|
||||
{item.mrp && item.mrp > item.price && (
|
||||
<div style={{ fontSize: '0.68rem', color: '#16A34A', fontWeight: 700, marginTop: '2px' }}>{Math.round(((item.mrp - item.price) / item.mrp) * 100)}% off</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '140px' }}>
|
||||
|
|
@ -318,60 +409,61 @@ export const InventoryPage: React.FC = () => {
|
|||
<Plus size={12} color="#374151" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#9CA3AF', marginTop: '4px' }}>{item.salesCount} sold all-time</div>
|
||||
</td>
|
||||
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '140px' }}>
|
||||
{((item as any).approvalStatus === 'pending' || (item as any).status === 'pending') ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', backgroundColor: '#FEF3C7', color: '#92400E', fontSize: '0.72rem', fontWeight: 700, borderRadius: '9999px', padding: '4px 10px' }}>
|
||||
<Clock size={12} color="#D97706" />
|
||||
Pending Approval
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '130px' }}>
|
||||
<span className="is-inline-flex is-align-items-center" style={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: item.approvalStatus === 'approved' ? '#DCFCE7' : item.approvalStatus === 'rejected' ? '#FEE2E2' : '#FEF3C7',
|
||||
color: item.approvalStatus === 'approved' ? '#166534' : item.approvalStatus === 'rejected' ? '#991B1B' : '#92400E',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<CheckCircle2 size={12} />
|
||||
<span>{item.approvalStatus === 'approved' ? 'Approved' : item.approvalStatus === 'rejected' ? 'Rejected' : 'Pending'}</span>
|
||||
</span>
|
||||
) : (item as any).approvalStatus === 'rejected' ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', backgroundColor: '#FEE2E2', color: '#991B1B', fontSize: '0.72rem', fontWeight: 700, borderRadius: '9999px', padding: '4px 10px' }}>
|
||||
<XCircle size={12} color="#DC2626" />
|
||||
Rejected
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', backgroundColor: '#E6F4EA', color: '#166534', fontSize: '0.72rem', fontWeight: 700, borderRadius: '9999px', padding: '4px 10px' }}>
|
||||
<CheckCircle2 size={12} color="#16A34A" />
|
||||
Approved
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '120px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', backgroundColor: hConf.bg, color: hConf.color, fontSize: '0.72rem', fontWeight: 700, borderRadius: '9999px', padding: '4px 10px' }}>
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: hConf.dot, flexShrink: 0 }} />
|
||||
{hConf.label}
|
||||
<span className="is-inline-flex is-align-items-center" style={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
backgroundColor: hConf.bg,
|
||||
color: hConf.color,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '9999px',
|
||||
gap: '5px'
|
||||
}}>
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: hConf.dot }}></span>
|
||||
<span>{hConf.label}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '80px' }}>
|
||||
<div className="is-flex" style={{ gap: '4px' }}>
|
||||
<button type="button" title="Quick Edit SKU" onClick={() => { setEditingItem(item); setEditPrice(item.price); }}
|
||||
style={{ width: '30px', height: '30px', borderRadius: '8px', border: '1px solid #E5E7EB', backgroundColor: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.2s' }}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#F3F4F6'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#FFFFFF'}
|
||||
<td style={{ padding: '14px 16px', verticalAlign: 'middle', textAlign: 'right' }}>
|
||||
<div className="is-flex is-align-items-center is-justify-content-end" style={{ gap: '6px' }}>
|
||||
<button type="button" onClick={() => { setEditingItem(item); setEditPrice(item.price); }}
|
||||
className="button is-small" style={{ borderRadius: '6px', height: '28px', padding: '0 8px', border: '1px solid #D1D5DB', backgroundColor: '#FFFFFF', color: '#374151' }}
|
||||
>
|
||||
<Edit3 size={13} color="#6B7280" />
|
||||
<Edit3 size={12} />
|
||||
</button>
|
||||
<button type="button" onClick={() => deleteProduct(item.id)} title="Delete SKU"
|
||||
style={{ width: '30px', height: '30px', borderRadius: '8px', border: '1px solid #FFE4E6', backgroundColor: '#FFF5F5', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.2s' }}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#FEE2E2'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#FFF5F5'}
|
||||
<button type="button" onClick={() => handleDeleteProduct(item.id, item.title)}
|
||||
className="button is-small" style={{ borderRadius: '6px', height: '28px', padding: '0 8px', border: '1px solid #FCA5A5', backgroundColor: '#FFFFFF', color: '#DC2626' }}
|
||||
>
|
||||
<Trash2 size={13} color="#DC2626" />
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: '3rem', textAlign: 'center' }}>
|
||||
<AlertCircle size={32} color="#D1D5DB" style={{ marginBottom: '8px' }} />
|
||||
<p style={{ fontSize: '0.88rem', color: '#6B7280' }}>No matching craft SKUs found.</p>
|
||||
<td colSpan={7} style={{ padding: '48px 24px', textAlign: 'center', color: '#6B7280' }}>
|
||||
<AlertTriangle size={24} color="#9CA3AF" style={{ margin: '0 auto 8px', display: 'block' }} />
|
||||
<p style={{ fontSize: '0.86rem', fontWeight: 600, color: '#374151' }}>No matching craft SKUs found.</p>
|
||||
<p style={{ fontSize: '0.78rem', color: '#9CA3AF', marginTop: '4px' }}>Try resetting your search query or filter settings above.</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
@ -379,127 +471,32 @@ export const InventoryPage: React.FC = () => {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Strip */}
|
||||
<div className="px-5 py-3 is-flex is-align-items-center is-justify-content-space-between" style={{ borderTop: '1px solid #F3F4F6', backgroundColor: '#FAFAFA', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.76rem', color: '#6B7280' }}>
|
||||
Showing <strong style={{ color: '#111827' }}>{Math.min(filtered.length, currentPage * PAGE_SIZE)}</strong> of{' '}
|
||||
<strong style={{ color: '#111827' }}>{filtered.length > 0 ? filtered.length : allSkus.length} Handcrafted SKUs</strong> · All GI Tags Verified
|
||||
</span>
|
||||
<div className="is-flex is-align-items-center" style={{ gap: '4px' }}>
|
||||
{/* Pagination Footer */}
|
||||
{filtered.length > 0 && (
|
||||
<div className="px-5 py-3 is-flex is-align-items-center is-justify-content-space-between" style={{ borderTop: '1px solid #F3F4F6', fontSize: '0.78rem', color: '#6B7280' }}>
|
||||
<div>
|
||||
Showing <strong style={{ color: '#111827' }}>{paginated.length}</strong> of <strong style={{ color: '#111827' }}>{filtered.length}</strong> Handcrafted SKUs
|
||||
</div>
|
||||
<div className="is-flex is-align-items-center" style={{ gap: '6px' }}>
|
||||
<button type="button" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}
|
||||
style={{ width: '28px', height: '28px', borderRadius: '6px', border: '1px solid #E5E7EB', backgroundColor: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: currentPage === 1 ? 'not-allowed' : 'pointer', opacity: currentPage === 1 ? 0.4 : 1 }}>
|
||||
<ChevronLeft size={14} color="#374151" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(pg => (
|
||||
<button key={pg} type="button" onClick={() => setCurrentPage(pg)}
|
||||
style={{ minWidth: '28px', height: '28px', borderRadius: '6px', border: `1px solid ${pg === currentPage ? '#701A29' : '#E5E7EB'}`, backgroundColor: pg === currentPage ? '#701A29' : '#FFFFFF', color: pg === currentPage ? '#FFFFFF' : '#374151', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer', padding: '0 6px' }}>
|
||||
{pg}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
style={{ width: '28px', height: '28px', borderRadius: '6px', border: '1px solid #E5E7EB', backgroundColor: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages ? 0.4 : 1 }}>
|
||||
<ChevronRight size={14} color="#374151" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Edit Drawer */}
|
||||
{editingItem && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(3px)' }}
|
||||
onClick={() => setEditingItem(null)}
|
||||
/>
|
||||
|
||||
{/* Drawer Panel */}
|
||||
<div style={{ position: 'relative', width: '440px', backgroundColor: '#FFFFFF', height: '100%', display: 'flex', flexDirection: 'column', boxShadow: '-4px 0 24px rgba(0,0,0,0.06)' }}>
|
||||
|
||||
{/* Drawer Header */}
|
||||
<div style={{ padding: '24px 32px', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 800, color: '#111827' }}>Quick Edit SKU</h2>
|
||||
<div style={{ fontSize: '0.8rem', color: '#6B7280', marginTop: '2px' }}>SKU: {editingItem.sku}</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEditingItem(null)} style={{ width: '32px', height: '32px', borderRadius: '50%', backgroundColor: '#F3F4F6', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#374151" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Content Scrollable */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 32px 32px 32px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
{/* Product Info Card */}
|
||||
<div style={{ backgroundColor: '#FAF7F7', borderRadius: '12px', padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<img src={editingItem.image} alt="Product" style={{ width: '48px', height: '48px', borderRadius: '8px', objectFit: 'cover' }} onError={e => { (e.target as HTMLImageElement).src = '/terracotta_vases.jpg'; }} />
|
||||
<div>
|
||||
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#111827', lineHeight: 1.2 }}>{editingItem.title}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#6B7280', marginTop: '2px' }}>{editingItem.category} · Handcrafted</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
|
||||
{/* Toggle Switch */}
|
||||
<div style={{ width: '40px', height: '22px', backgroundColor: '#701A29', borderRadius: '11px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', padding: '2px', cursor: 'pointer' }}>
|
||||
<div style={{ width: '18px', height: '18px', backgroundColor: '#FFFFFF', borderRadius: '50%' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', fontWeight: 700, color: '#166534' }}>Live on Storefront</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selling Price Card */}
|
||||
<div style={{ backgroundColor: '#FAF7F7', borderRadius: '12px', padding: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#374151', marginBottom: '8px' }}>Selling Price</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', fontWeight: 600, color: '#374151' }}>₹</span>
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={e => setEditPrice(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
style={{ width: '100%', border: 'none', borderRadius: '4px', padding: '12px 16px 12px 32px', fontSize: '1rem', fontWeight: 700, color: '#111827', outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', fontSize: '0.75rem' }}>
|
||||
<span style={{ color: '#6B7280' }}>Artisan Payout (100% retained)</span>
|
||||
<span style={{ fontWeight: 700, color: '#701A29', fontSize: '0.9rem' }}>₹{editPrice || editingItem.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock on Hand Card */}
|
||||
<div style={{ backgroundColor: '#FAF7F7', borderRadius: '12px', padding: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Stock on Hand</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#6B7280', marginTop: '2px' }}>Finished sets ready for dispatch</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: '#FFFFFF', borderRadius: '4px', border: 'none', overflow: 'hidden' }}>
|
||||
<button type="button" onClick={() => handleStepStock(editingItem.id, -1)} style={{ width: '32px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', backgroundColor: '#FFFFFF', cursor: 'pointer', fontSize: '1.2rem', color: '#374151' }}>−</button>
|
||||
<div style={{ width: '36px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.9rem', fontWeight: 700, color: '#111827' }}>{(stockValues[editingItem.id] ?? editingItem.stock)}</div>
|
||||
<button type="button" onClick={() => handleStepStock(editingItem.id, 1)} style={{ width: '32px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', backgroundColor: '#FFFFFF', cursor: 'pointer', fontSize: '1.2rem', color: '#374151' }}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151', paddingLeft: '4px' }}>Lead Time / Dispatch Readiness</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Drawer Footer */}
|
||||
<div style={{ padding: '24px 32px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button type="button" onClick={() => setEditingItem(null)} style={{ padding: '10px 24px', borderRadius: '9999px', border: 'none', backgroundColor: '#F3F4F6', color: '#374151', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={isSavingEdit}
|
||||
style={{ padding: '10px 24px', borderRadius: '9999px', border: 'none', backgroundColor: '#701A29', color: '#FFFFFF', fontSize: '0.85rem', fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', opacity: isSavingEdit ? 0.7 : 1 }}
|
||||
className="button is-small" style={{ borderRadius: '6px', height: '28px', width: '28px', padding: 0, opacity: currentPage === 1 ? 0.4 : 1 }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{isSavingEdit ? 'Saving...' : 'Save Changes'}
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, color: '#701A29', backgroundColor: '#FFF0F2', width: '28px', height: '28px', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '6px' }}>
|
||||
{currentPage}
|
||||
</span>
|
||||
<button type="button" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
|
||||
className="button is-small" style={{ borderRadius: '6px', height: '28px', width: '28px', padding: 0, opacity: currentPage === totalPages ? 0.4 : 1 }}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue