etsy-finance-tracker/client/src/pages/Products.tsx
dlawler489 1a3bd33be8 Migrate frontend from localStorage to MongoDB API
- 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>
2026-04-22 08:48:05 +10:00

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;