- Remove localStorage from all 4 Redux slices (products, orders, expenses, customers) - Layout fetches all data from API on mount; adds logout button with active nav highlighting - Wire API calls in Products, Orders, Expenses pages for all CRUD operations - DataImport uses POST /orders/bulk for CSV upserts and API for PDF slip orders - MissingProductsModal creates products via API - Relax Order model: optional customerId, embedded customer, fees, printingCost on items, default paymentStatus=paid - Relax Expense model: free-string category, add taxDeductible/vendor/reference fields - Add printingCost to Product model - Add POST /orders/bulk endpoint for upsert-by-orderNumber - Raise rate limit to 1000 req/15min for bulk imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
603 lines
No EOL
23 KiB
TypeScript
603 lines
No EOL
23 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { useSelector, useDispatch } from 'react-redux';
|
|
import { RootState } from '../store';
|
|
import { addProduct, updateProduct, deleteProduct } from '../store/slices/productSlice';
|
|
import { Upload, Plus, Search, Edit, Trash2, Package } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import api from '../utils/api';
|
|
|
|
interface ProductFormData {
|
|
title: string;
|
|
description: string;
|
|
price: number;
|
|
costOfGoods: number;
|
|
printingCost: number;
|
|
sku: string;
|
|
category: string;
|
|
tags: string;
|
|
inventory: {
|
|
quantity: number;
|
|
lowStockAlert: number;
|
|
};
|
|
}
|
|
|
|
const Products = () => {
|
|
const { products } = useSelector((state: RootState) => state.products);
|
|
const dispatch = useDispatch();
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
const [editingProduct, setEditingProduct] = useState<string | null>(null);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [formData, setFormData] = useState<ProductFormData>({
|
|
title: '',
|
|
description: '',
|
|
price: 0,
|
|
costOfGoods: 0,
|
|
printingCost: 0,
|
|
sku: '',
|
|
category: '',
|
|
tags: '',
|
|
inventory: {
|
|
quantity: 0,
|
|
lowStockAlert: 5
|
|
}
|
|
});
|
|
|
|
const categories = ['Jewelry', 'Accessories', 'Home & Living', 'Clothing', 'Art', 'Craft Supplies', 'Other'];
|
|
|
|
const handleCSVImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.name.endsWith('.csv')) {
|
|
toast.error('Please select a valid CSV file');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const text = e.target?.result as string;
|
|
const lines = text.split('\n');
|
|
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
|
|
|
toast.loading('Importing products from CSV...');
|
|
|
|
const importedProducts = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (lines[i].trim() === '') continue;
|
|
|
|
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
|
const productData: any = {
|
|
printingCost: 0
|
|
};
|
|
|
|
headers.forEach((header, index) => {
|
|
const value = values[index] || '';
|
|
switch (header.toLowerCase()) {
|
|
case 'title':
|
|
case 'product title':
|
|
case 'listing title':
|
|
productData.title = value;
|
|
break;
|
|
case 'description':
|
|
productData.description = value;
|
|
break;
|
|
case 'price':
|
|
case 'listing price':
|
|
productData.price = parseFloat(value.replace(/[$,]/g, '')) || 0;
|
|
break;
|
|
case 'cost':
|
|
case 'cost of goods':
|
|
case 'cogs':
|
|
productData.costOfGoods = parseFloat(value.replace(/[$,]/g, '')) || 0;
|
|
break;
|
|
case 'printing cost':
|
|
case 'print cost':
|
|
case 'printing':
|
|
productData.printingCost = parseFloat(value.replace(/[$,]/g, '')) || 0;
|
|
break;
|
|
case 'sku':
|
|
productData.sku = value || `SKU-${Date.now()}-${i}`;
|
|
break;
|
|
case 'category':
|
|
productData.category = value || 'Other';
|
|
break;
|
|
case 'tags':
|
|
productData.tags = value.split(';').map((t: string) => t.trim());
|
|
break;
|
|
case 'quantity':
|
|
case 'stock':
|
|
case 'inventory':
|
|
productData.quantity = parseInt(value) || 0;
|
|
break;
|
|
}
|
|
});
|
|
|
|
if (productData.title) {
|
|
importedProducts.push({
|
|
...productData,
|
|
inventory: { quantity: productData.quantity || 0, lowStockAlert: 5 },
|
|
tags: productData.tags || [],
|
|
isActive: true
|
|
});
|
|
}
|
|
}
|
|
|
|
let imported = 0;
|
|
for (const p of importedProducts) {
|
|
try {
|
|
const res = await api.post('/products', p);
|
|
dispatch(addProduct(res.data));
|
|
imported++;
|
|
} catch {}
|
|
}
|
|
toast.success(`Successfully imported ${imported} products`);
|
|
|
|
} catch (error) {
|
|
console.error('Import error:', error);
|
|
toast.error('Failed to import CSV file');
|
|
}
|
|
};
|
|
|
|
reader.readAsText(file);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (editingProduct) {
|
|
handleUpdateProduct();
|
|
} else {
|
|
handleAddProduct();
|
|
}
|
|
};
|
|
|
|
const handleAddProduct = async () => {
|
|
try {
|
|
const payload = {
|
|
...formData,
|
|
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
|
isActive: true
|
|
};
|
|
const res = await api.post('/products', payload);
|
|
dispatch(addProduct(res.data));
|
|
toast.success('Product added successfully');
|
|
setShowAddForm(false);
|
|
setFormData({
|
|
title: '',
|
|
description: '',
|
|
price: 0,
|
|
costOfGoods: 0,
|
|
printingCost: 0,
|
|
sku: '',
|
|
category: '',
|
|
tags: '',
|
|
inventory: { quantity: 0, lowStockAlert: 5 }
|
|
});
|
|
} catch (error) {
|
|
toast.error('Failed to add product');
|
|
}
|
|
};
|
|
|
|
const handleEditProduct = (productId: string) => {
|
|
const product = products.find(p => p._id === productId);
|
|
if (product) {
|
|
setFormData({
|
|
title: product.title,
|
|
description: product.description || '',
|
|
price: product.price,
|
|
costOfGoods: product.costOfGoods,
|
|
printingCost: product.printingCost || 0,
|
|
sku: product.sku || '',
|
|
category: product.category || '',
|
|
tags: product.tags?.join(', ') || '',
|
|
inventory: {
|
|
quantity: product.inventory?.quantity || 0,
|
|
lowStockAlert: product.inventory?.lowStockAlert || 5
|
|
}
|
|
});
|
|
setEditingProduct(productId);
|
|
setShowAddForm(true);
|
|
}
|
|
};
|
|
|
|
const handleUpdateProduct = async () => {
|
|
if (!editingProduct) return;
|
|
|
|
try {
|
|
const payload = {
|
|
...formData,
|
|
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
|
isActive: true
|
|
};
|
|
const res = await api.put(`/products/${editingProduct}`, payload);
|
|
dispatch(updateProduct(res.data));
|
|
toast.success('Product updated successfully');
|
|
setShowAddForm(false);
|
|
setEditingProduct(null);
|
|
setFormData({
|
|
title: '',
|
|
description: '',
|
|
price: 0,
|
|
costOfGoods: 0,
|
|
printingCost: 0,
|
|
sku: '',
|
|
category: '',
|
|
tags: '',
|
|
inventory: { quantity: 0, lowStockAlert: 5 }
|
|
});
|
|
} catch (error) {
|
|
toast.error('Failed to update product');
|
|
}
|
|
};
|
|
|
|
const handleDeleteProduct = (productId: string) => {
|
|
setShowDeleteConfirm(productId);
|
|
};
|
|
|
|
const confirmDeleteProduct = async () => {
|
|
if (showDeleteConfirm) {
|
|
try {
|
|
await api.delete(`/products/${showDeleteConfirm}`);
|
|
dispatch(deleteProduct(showDeleteConfirm));
|
|
toast.success('Product deleted successfully');
|
|
} catch {
|
|
toast.error('Failed to delete product');
|
|
}
|
|
setShowDeleteConfirm(null);
|
|
}
|
|
};
|
|
|
|
const cancelDelete = () => {
|
|
setShowDeleteConfirm(null);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setShowAddForm(false);
|
|
setEditingProduct(null);
|
|
setFormData({
|
|
title: '',
|
|
description: '',
|
|
price: 0,
|
|
costOfGoods: 0,
|
|
printingCost: 0,
|
|
sku: '',
|
|
category: '',
|
|
tags: '',
|
|
inventory: { quantity: 0, lowStockAlert: 5 }
|
|
});
|
|
};
|
|
|
|
const filteredProducts = products.filter(product => {
|
|
const matchesSearch = product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
product.sku.toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesCategory = selectedCategory === 'all' || product.category === selectedCategory;
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Products</h1>
|
|
<p className="text-gray-600 mt-1">Manage your product inventory and listings</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleCSVImport}
|
|
accept=".csv"
|
|
style={{ display: 'none' }}
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
Import CSV
|
|
</button>
|
|
<button
|
|
onClick={() => setShowAddForm(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Product
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-4 mb-6">
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search products..."
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
<select
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={selectedCategory}
|
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
|
>
|
|
<option value="all">All Categories</option>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Add Product Form Modal */}
|
|
{showAddForm && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<h2 className="text-xl font-bold mb-4">
|
|
{editingProduct ? 'Edit Product' : 'Add New Product'}
|
|
</h2>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.title}
|
|
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">SKU</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.sku}
|
|
onChange={(e) => setFormData({...formData, sku: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
<textarea
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
rows={3}
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Price ($)</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
required
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.price}
|
|
onChange={(e) => setFormData({...formData, price: parseFloat(e.target.value) || 0})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Cost of Goods ($)</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.costOfGoods}
|
|
onChange={(e) => setFormData({...formData, costOfGoods: parseFloat(e.target.value) || 0})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Printing Cost per Item ($)</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.printingCost}
|
|
onChange={(e) => setFormData({...formData, printingCost: parseFloat(e.target.value) || 0})}
|
|
placeholder="Enter printing cost per item"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
|
<select
|
|
required
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.category}
|
|
onChange={(e) => setFormData({...formData, category: e.target.value})}
|
|
>
|
|
<option value="">Select Category</option>
|
|
{categories.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Tags (comma separated)</label>
|
|
<input
|
|
type="text"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
placeholder="handmade, jewelry, silver"
|
|
value={formData.tags}
|
|
onChange={(e) => setFormData({...formData, tags: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.inventory.quantity}
|
|
onChange={(e) => setFormData({...formData, inventory: {...formData.inventory, quantity: parseInt(e.target.value) || 0}})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Low Stock Alert</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={formData.inventory.lowStockAlert}
|
|
onChange={(e) => setFormData({...formData, inventory: {...formData.inventory, lowStockAlert: parseInt(e.target.value) || 5}})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={cancelEdit}
|
|
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
|
>
|
|
{editingProduct ? 'Update Product' : 'Add Product'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Products Grid */}
|
|
<div className="grid gap-6">
|
|
{filteredProducts.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No products found</h3>
|
|
<p className="text-gray-500 mb-4">Get started by adding your first product or importing from CSV.</p>
|
|
<button
|
|
onClick={() => setShowAddForm(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Your First Product
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filteredProducts.map((product) => (
|
|
<div key={product._id} className="bg-white rounded-lg shadow border p-6">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-lg text-gray-900 mb-1">{product.title}</h3>
|
|
<p className="text-sm text-gray-500">SKU: {product.sku}</p>
|
|
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mt-1">
|
|
{product.category}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleEditProduct(product._id)}
|
|
className="p-1 text-gray-400 hover:text-blue-600"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteProduct(product._id)}
|
|
className="p-1 text-gray-400 hover:text-red-600"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Price:</span>
|
|
<span className="font-medium">${product.price.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Material Cost:</span>
|
|
<span>${product.costOfGoods.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Printing Cost:</span>
|
|
<span>${(product.printingCost || 0).toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Profit per Item:</span>
|
|
<span className="font-medium text-green-600">
|
|
${(product.price - product.costOfGoods - (product.printingCost || 0)).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Stock:</span>
|
|
<span className={`font-medium ${product.inventory.quantity <= product.inventory.lowStockAlert ? 'text-red-600' : 'text-green-600'}`}>
|
|
{product.inventory.quantity}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{product.tags.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap gap-1">
|
|
{product.tags.slice(0, 3).map((tag, idx) => (
|
|
<span key={idx} className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{product.tags.length > 3 && (
|
|
<span className="text-xs text-gray-400">+{product.tags.length - 3} more</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteConfirm && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Confirm Delete</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
Are you sure you want to delete this product? This action cannot be undone.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={cancelDelete}
|
|
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={confirmDeleteProduct}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Products; |