From 1a3bd33be81138f69dbb1d88a7c63e62cf4a80d5 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Wed, 22 Apr 2026 08:48:05 +1000 Subject: [PATCH] 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 --- client/src/components/Layout.tsx | 94 ++++++++++--- .../src/components/MissingProductsModal.tsx | 66 +++++---- client/src/pages/DataImport.tsx | 128 ++++++++---------- client/src/pages/Expenses.tsx | 50 +++---- client/src/pages/Orders.tsx | 119 ++++++++-------- client/src/pages/Products.tsx | 54 ++++---- client/src/store/slices/customerSlice.ts | 11 +- client/src/store/slices/expenseSlice.ts | 13 +- client/src/store/slices/orderSlice.ts | 13 +- client/src/store/slices/productSlice.ts | 12 +- server/src/index.ts | 4 +- server/src/models/Expense.ts | 14 +- server/src/models/Order.ts | 75 ++++++---- server/src/models/Product.ts | 10 +- server/src/routes/orders.ts | 59 ++++++-- 15 files changed, 408 insertions(+), 314 deletions(-) diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index a21c7e9..621f4d2 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,7 +1,60 @@ -import React from 'react'; -import { Outlet } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '../store'; +import { logout } from '../store/slices/authSlice'; +import { setProducts } from '../store/slices/productSlice'; +import { setOrders } from '../store/slices/orderSlice'; +import { setExpenses } from '../store/slices/expenseSlice'; +import { setCustomers } from '../store/slices/customerSlice'; +import api from '../utils/api'; const Layout: React.FC = () => { + const dispatch = useDispatch(); + const location = useLocation(); + const user = useSelector((state: RootState) => state.auth.user); + + useEffect(() => { + const fetchAll = async () => { + try { + const [productsRes, ordersRes, expensesRes, customersRes] = await Promise.all([ + api.get('/products?limit=1000'), + api.get('/orders?limit=1000'), + api.get('/expenses?limit=1000'), + api.get('/customers?limit=1000'), + ]); + dispatch(setProducts(productsRes.data.products)); + dispatch(setOrders(ordersRes.data.orders)); + dispatch(setExpenses(expensesRes.data.expenses)); + dispatch(setCustomers(customersRes.data.customers)); + } catch (err) { + console.error('Failed to fetch app data', err); + } + }; + fetchAll(); + }, []); + + const handleLogout = () => { + dispatch(logout()); + window.location.href = '/login'; + }; + + const navLink = (href: string, label: string) => { + const active = location.pathname === href || (href !== '/' && location.pathname.startsWith(href)); + return ( + + {label} + + ); + }; + return (
- - Analytics - - - Profit Analysis - - - Products - - - Orders - - - Expenses - - - Data Import - + {navLink('/analytics', 'Analytics')} + {navLink('/profit-analysis', 'Profit Analysis')} + {navLink('/products', 'Products')} + {navLink('/orders', 'Orders')} + {navLink('/expenses', 'Expenses')} + {navLink('/data-import', 'Data Import')}
+
+ {user && ( + {user.email} + )} + +
@@ -42,4 +94,4 @@ const Layout: React.FC = () => { ); }; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/client/src/components/MissingProductsModal.tsx b/client/src/components/MissingProductsModal.tsx index a5d8fe9..8011d0f 100644 --- a/client/src/components/MissingProductsModal.tsx +++ b/client/src/components/MissingProductsModal.tsx @@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux'; import { addProduct } from '../store/slices/productSlice'; import { X, Plus } from 'lucide-react'; import toast from 'react-hot-toast'; +import api from '../utils/api'; export interface MissingProduct { title: string; @@ -39,39 +40,50 @@ export const MissingProductsModal: React.FC = ({ })); }; - const handleAddProducts = () => { + const handleAddProducts = async () => { const newProducts: any[] = []; - + for (const product of missingProducts) { const data = productData[product.title] || {}; const printingCost = data.printingCost || 0; const category = data.category || 'Imported Items'; - - // Extract size from title if present - const sizeMatch = product.title.match(/Size:\s*([^,\s]+(?:\s+[^,\s]+)*)/i); - const extractedSize = sizeMatch ? sizeMatch[1].trim() : ''; - - const newProduct = { - _id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - title: product.title, - description: `Imported from order ${product.orderNumber || 'Unknown'}`, - price: product.price || 0, // Default to 0 if price not available (e.g., from packing slip only) - costOfGoods: 0, // Set to 0 since printing cost includes materials - printingCost: printingCost, - sku: `IMP_${Date.now()}_${newProducts.length + 1}`, - stockLevel: 0, // Set to 0 since we don't know stock - category: category, - size: extractedSize, - tags: [], - inventory: { quantity: 0, lowStockAlert: 5 }, - isActive: true - }; - - dispatch(addProduct(newProduct)); - newProducts.push(newProduct); + + try { + const res = await api.post('/products', { + title: product.title, + description: `Imported from order ${product.orderNumber || 'Unknown'}`, + price: product.price || 0, + costOfGoods: 0, + printingCost, + sku: '', + category, + tags: [], + inventory: { quantity: 0, lowStockAlert: 5 }, + isActive: true + }); + dispatch(addProduct(res.data)); + newProducts.push(res.data); + } catch { + // Fall back to local-only if API fails + const fallback = { + _id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + title: product.title, + description: `Imported from order ${product.orderNumber || 'Unknown'}`, + price: product.price || 0, + costOfGoods: 0, + printingCost, + sku: '', + category, + tags: [], + inventory: { quantity: 0, lowStockAlert: 5 }, + isActive: true + }; + dispatch(addProduct(fallback)); + newProducts.push(fallback); + } } - - toast.success(`Added ${newProducts.length} new products with printing costs (inc. materials)`); + + toast.success(`Added ${newProducts.length} new products with printing costs`); onComplete(newProducts); }; diff --git a/client/src/pages/DataImport.tsx b/client/src/pages/DataImport.tsx index ed032cf..7a876f6 100644 --- a/client/src/pages/DataImport.tsx +++ b/client/src/pages/DataImport.tsx @@ -11,6 +11,7 @@ import { Order } from '../store/slices/orderSlice'; import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react'; import toast from 'react-hot-toast'; import { dateTestResults } from '../utils/testDateParsing'; +import api from '../utils/api'; interface ImportResults { etsyOrders: ParsedEtsyOrder[]; @@ -153,58 +154,36 @@ export default function DataImport() { summary }); - // Automatically create orders from CSV data + // Upsert all orders via bulk API endpoint const csvOrders = etsyOrders.map(csvOrder => { - // Check if order already exists const existingOrder = orders.find(order => order.orderNumber === csvOrder.orderNumber); - - if (existingOrder) { - // Update existing order with CSV revenue data - return { - ...existingOrder, - total: csvOrder.saleAmount, - fees: { - etsy: csvOrder.totalFees || 0, - processing: 0, - shipping: 0 - } - }; - } else { - // Create new order from CSV data - return { - _id: `csv-${csvOrder.orderNumber}`, - orderNumber: csvOrder.orderNumber, - total: csvOrder.saleAmount, - status: 'delivered' as const, - dateOrdered: csvOrder.date, - customer: { - name: 'Etsy Customer', - email: '' - }, - items: [{ - title: csvOrder.productName || 'Product from Etsy', - quantity: 1, - price: csvOrder.saleAmount, - printingCost: 0 - }], - fees: { - etsy: csvOrder.totalFees || 0, - processing: 0, - shipping: 0 - } - }; - } + return { + ...(existingOrder || {}), + orderNumber: csvOrder.orderNumber, + total: csvOrder.saleAmount, + status: 'delivered' as const, + paymentStatus: 'paid', + dateOrdered: csvOrder.date, + customer: existingOrder?.customer || { name: 'Etsy Customer', email: '' }, + items: existingOrder?.items || [{ + title: csvOrder.productName || 'Product from Etsy', + quantity: 1, + price: csvOrder.saleAmount, + printingCost: 0 + }], + fees: { etsy: csvOrder.totalFees || 0, processing: 0, shipping: 0 } + }; }); - // Update existing orders and add new ones - const existingOrderNumbers = new Set(orders.map(o => o.orderNumber)); - const ordersToUpdate = csvOrders.filter(o => existingOrderNumbers.has(o.orderNumber)); - const ordersToAdd = csvOrders.filter(o => !existingOrderNumbers.has(o.orderNumber)); - - ordersToUpdate.forEach(order => dispatch(updateOrder(order))); - ordersToAdd.forEach(order => dispatch(addOrder(order))); - - toast.success(`CSV imported! Created ${ordersToAdd.length} new orders and updated ${ordersToUpdate.length} existing orders.`); + try { + const res = await api.post('/orders/bulk', csvOrders); + // Reload orders from API after bulk upsert + const ordersRes = await api.get('/orders?limit=1000'); + dispatch(setOrders(ordersRes.data.orders)); + toast.success(`CSV imported! Created ${res.data.created} new orders and updated ${res.data.updated} existing orders.`); + } catch { + toast.error('Failed to save orders to database'); + } } catch (err) { console.error('Error processing CSV files:', err); @@ -291,11 +270,11 @@ export default function DataImport() { } } - const orderData: Order = { - _id: existingOrder?._id || Date.now().toString(), + const orderData = { orderNumber: slip.orderNumber, - total: csvOrderData?.saleAmount || existingOrder?.total || 0, // Use CSV revenue data if available + total: csvOrderData?.saleAmount || existingOrder?.total || 0, status: existingOrder?.status || 'processing', + paymentStatus: 'paid', dateOrdered: formattedOrderDate, customer: existingOrder?.customer || { name: slip.customerName, @@ -309,10 +288,17 @@ export default function DataImport() { } : existingOrder?.fees }; - if (existingOrder) { - dispatch(updateOrder(orderData)); - } else { - dispatch(addOrder(orderData)); + try { + if (existingOrder) { + const res = await api.put(`/orders/${existingOrder._id}`, orderData); + dispatch(updateOrder(res.data)); + } else { + const res = await api.post('/orders', orderData); + dispatch(addOrder(res.data)); + } + } catch (err) { + console.error('Failed to save order from PDF slip:', err); + toast.error(`Failed to save order #${slip.orderNumber}`); } }; @@ -337,15 +323,22 @@ export default function DataImport() { } }; - const handleClearAllOrders = () => { + const handleClearAllOrders = async () => { const orderCount = orders.length; - if (window.confirm(`This will delete all ${orderCount} existing orders. You'll need to re-upload your packing slips to get the correct dates. Are you sure?`)) { + if (window.confirm(`This will permanently delete all ${orderCount} orders from the database. Are you sure?`)) { + let deleted = 0; + for (const order of orders) { + try { + await api.delete(`/orders/${order._id}`); + deleted++; + } catch {} + } dispatch(setOrders([])); - toast.success(`All ${orderCount} orders cleared! Now re-upload your packing slips with fixed date parsing.`); + toast.success(`Deleted ${deleted} orders. Re-upload your packing slips to restore.`); } }; - const createOrdersFromCSV = () => { + const createOrdersFromCSV = async () => { if (!results || !results.etsyOrders || results.etsyOrders.length === 0) { toast.error('No CSV data available. Please import Etsy CSV first.'); return; @@ -393,15 +386,14 @@ export default function DataImport() { } }); - // Update existing orders and add new ones - const existingOrderNumbers = new Set(orders.map(o => o.orderNumber)); - const ordersToUpdate = csvOrders.filter(o => existingOrderNumbers.has(o.orderNumber)); - const ordersToAdd = csvOrders.filter(o => !existingOrderNumbers.has(o.orderNumber)); - - ordersToUpdate.forEach(order => dispatch(updateOrder(order))); - ordersToAdd.forEach(order => dispatch(addOrder(order))); - - toast.success(`Updated ${ordersToUpdate.length} existing orders and created ${ordersToAdd.length} new orders from CSV data.`); + try { + const res = await api.post('/orders/bulk', csvOrders); + const ordersRes = await api.get('/orders?limit=1000'); + dispatch(setOrders(ordersRes.data.orders)); + toast.success(`Re-synced: created ${res.data.created}, updated ${res.data.updated} orders.`); + } catch { + toast.error('Failed to sync orders'); + } }; const debugDataState = () => { diff --git a/client/src/pages/Expenses.tsx b/client/src/pages/Expenses.tsx index bde5e74..363c4d4 100644 --- a/client/src/pages/Expenses.tsx +++ b/client/src/pages/Expenses.tsx @@ -5,6 +5,7 @@ import { addExpenses, addExpense, updateExpense, deleteExpense } from '../store/ import { formatAustralianDate } from '../utils/dateFormatter'; import { Upload, Plus, Search, Edit, Trash2, Receipt, Download, DollarSign } from 'lucide-react'; import toast from 'react-hot-toast'; +import api from '../utils/api'; interface ExpenseFormData { description: string; @@ -136,25 +137,28 @@ const Expenses = () => { if (expenseData.amount > 0) { importedExpenses.push({ - _id: `temp-${Date.now()}-${i}`, description: expenseData.description || 'Imported Expense', amount: expenseData.amount, - category: expenseData.category || 'Other', + category: expenseData.category || 'Shipping & Postage', date: expenseData.date || new Date().toISOString().split('T')[0], - taxDeductible: true, // Assume business expenses are tax deductible + taxDeductible: true, vendor: expenseData.vendor || expenseData.description || 'Unknown', reference: expenseData.reference }); } } - // Simulate API call - replace with actual API call - console.log('Imported expenses:', importedExpenses); - - // Add the imported expenses to the store - dispatch(addExpenses(importedExpenses)); - - toast.success(`Successfully imported ${importedExpenses.length} expenses`); + let imported = 0; + const saved = []; + for (const e of importedExpenses) { + try { + const res = await api.post('/expenses', e); + saved.push(res.data); + imported++; + } catch {} + } + dispatch(addExpenses(saved)); + toast.success(`Successfully imported ${imported} expenses`); } catch (error) { console.error('Import error:', error); @@ -220,22 +224,15 @@ const Expenses = () => { e.preventDefault(); try { if (editingExpense) { - // Update existing expense - dispatch(updateExpense({ - _id: editingExpense, - ...formData - })); + const res = await api.put(`/expenses/${editingExpense}`, formData); + dispatch(updateExpense(res.data)); toast.success('Expense updated successfully!'); setEditingExpense(null); } else { - // Add new expense - dispatch(addExpense({ - _id: `expense-${Date.now()}`, - ...formData - })); + const res = await api.post('/expenses', formData); + dispatch(addExpense(res.data)); toast.success('Expense added successfully!'); } - setShowAddForm(false); setFormData({ description: '', @@ -263,10 +260,15 @@ const Expenses = () => { setShowAddForm(true); }; - const handleDelete = (expenseId: string) => { + const handleDelete = async (expenseId: string) => { if (window.confirm('Are you sure you want to delete this expense?')) { - dispatch(deleteExpense(expenseId)); - toast.success('Expense deleted successfully!'); + try { + await api.delete(`/expenses/${expenseId}`); + dispatch(deleteExpense(expenseId)); + toast.success('Expense deleted successfully!'); + } catch { + toast.error('Failed to delete expense'); + } } }; diff --git a/client/src/pages/Orders.tsx b/client/src/pages/Orders.tsx index cc66a5b..aed2eb1 100644 --- a/client/src/pages/Orders.tsx +++ b/client/src/pages/Orders.tsx @@ -1,34 +1,19 @@ import { useState, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { RootState } from '../store'; -import { updateOrder, deleteOrder, addOrder, Order, setOrders } from '../store/slices/orderSlice'; +import { updateOrder, deleteOrder, addOrder, Order } from '../store/slices/orderSlice'; import { addProduct } from '../store/slices/productSlice'; import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from '../utils/orderCalculations'; import { formatAustralianDate } from '../utils/dateFormatter'; import { Search, Package, Download, ArrowRight, Edit, Trash2, X, Plus } from 'lucide-react'; import toast from 'react-hot-toast'; +import api from '../utils/api'; const Orders = () => { const dispatch = useDispatch(); const { orders } = useSelector((state: RootState) => state.orders); const { products } = useSelector((state: RootState) => state.products); - // Debug: Monitor order changes - useEffect(() => { - // Clean up orders with invalid IDs on component mount - const cleanupInvalidOrders = () => { - const validOrders = orders?.filter(order => order._id && order._id !== 'undefined') || []; - if (validOrders.length !== orders?.length) { - console.log(`Removing ${(orders?.length || 0) - validOrders.length} orders with invalid IDs`); - dispatch(setOrders(validOrders)); - toast.success('Cleaned up orders with invalid IDs'); - } - }; - - if (orders && orders.length > 0) { - cleanupInvalidOrders(); - } - }, []); // Only run on mount const [searchTerm, setSearchTerm] = useState(''); const [dateRange, setDateRange] = useState('all'); @@ -161,11 +146,16 @@ const Orders = () => { setShowEditModal(true); }; - const handleSaveOrder = (updatedOrder: Order) => { - dispatch(updateOrder(updatedOrder)); + const handleSaveOrder = async (updatedOrder: Order) => { + try { + const res = await api.put(`/orders/${updatedOrder._id}`, updatedOrder); + dispatch(updateOrder(res.data)); + toast.success('Order updated successfully'); + } catch { + toast.error('Failed to update order'); + } setShowEditModal(false); setEditingOrder(null); - toast.success('Order updated successfully'); }; const handleDeleteClick = (orderId: string) => { @@ -173,17 +163,20 @@ const Orders = () => { setShowDeleteConfirm(true); }; - const handleConfirmDelete = () => { + const handleConfirmDelete = async () => { if (deletingOrderId && deletingOrderId !== 'undefined') { - dispatch(deleteOrder(deletingOrderId)); - setShowDeleteConfirm(false); - setDeletingOrderId(null); - toast.success('Order deleted successfully'); + try { + await api.delete(`/orders/${deletingOrderId}`); + dispatch(deleteOrder(deletingOrderId)); + toast.success('Order deleted successfully'); + } catch { + toast.error('Failed to delete order'); + } } else { toast.error('Cannot delete order: Invalid order ID'); - setShowDeleteConfirm(false); - setDeletingOrderId(null); } + setShowDeleteConfirm(false); + setDeletingOrderId(null); }; const handleCancelDelete = () => { @@ -202,29 +195,30 @@ const Orders = () => { setShowNewProductModal(true); }; - const handleSaveNewProduct = () => { + const handleSaveNewProduct = async () => { if (!newProductData.title) { toast.error('Please enter a product title'); return; } - - const newProduct = { - _id: `product-${Date.now()}`, - title: newProductData.title, - description: '', - price: newProductData.price, - costOfGoods: newProductData.costOfGoods, - printingCost: newProductData.printingCost, - sku: '', - category: '', - tags: [], - inventory: { quantity: 0, lowStockAlert: 10 }, - isActive: true - }; - - dispatch(addProduct(newProduct)); - setShowNewProductModal(false); - toast.success('Product created successfully'); + try { + const res = await api.post('/products', { + title: newProductData.title, + description: '', + price: newProductData.price, + costOfGoods: newProductData.costOfGoods, + printingCost: newProductData.printingCost, + sku: '', + category: 'Other', + tags: [], + inventory: { quantity: 0, lowStockAlert: 10 }, + isActive: true + }); + dispatch(addProduct(res.data)); + setShowNewProductModal(false); + toast.success('Product created successfully'); + } catch { + toast.error('Failed to create product'); + } }; // Manual order handlers @@ -239,15 +233,13 @@ const Orders = () => { setShowAddModal(true); }; - const handleSaveNewOrder = () => { + const handleSaveNewOrder = async () => { if (!newOrder.orderNumber || !newOrder.items[0]?.title) { toast.error('Please fill in order number and at least one item'); return; } const calculatedTotal = newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0); - - // Enrich items with printing costs from product database const enrichedItems = newOrder.items .filter(item => item.title.trim() !== '') .map(item => { @@ -259,19 +251,22 @@ const Orders = () => { }; }); - const orderToSave: Order = { - _id: `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // More unique ID - orderNumber: `FB-${newOrder.orderNumber}`, // Prefix to identify Facebook orders - customer: newOrder.customer, - dateOrdered: newOrder.dateOrdered, - total: calculatedTotal, - status: 'delivered' as const, - items: enrichedItems - }; - - dispatch(addOrder(orderToSave)); - setShowAddModal(false); - toast.success('Facebook Marketplace order added successfully'); + try { + const res = await api.post('/orders', { + orderNumber: `FB-${newOrder.orderNumber}`, + customer: newOrder.customer, + dateOrdered: newOrder.dateOrdered, + total: calculatedTotal, + status: 'delivered', + paymentStatus: 'paid', + items: enrichedItems + }); + dispatch(addOrder(res.data)); + setShowAddModal(false); + toast.success('Facebook Marketplace order added successfully'); + } catch { + toast.error('Failed to save order'); + } }; const handleAddItem = () => { diff --git a/client/src/pages/Products.tsx b/client/src/pages/Products.tsx index 6dde56e..dfcf388 100644 --- a/client/src/pages/Products.tsx +++ b/client/src/pages/Products.tsx @@ -1,9 +1,10 @@ import React, { useState, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { RootState } from '../store'; -import { updateProduct, deleteProduct } from '../store/slices/productSlice'; +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; @@ -68,12 +69,12 @@ const Products = () => { 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 // Default printing cost + printingCost: 0 }; - + headers.forEach((header, index) => { const value = values[index] || ''; switch (header.toLowerCase()) { @@ -119,19 +120,22 @@ const Products = () => { if (productData.title) { importedProducts.push({ ...productData, - inventory: { - quantity: productData.quantity || 0, - lowStockAlert: 5 - }, + inventory: { quantity: productData.quantity || 0, lowStockAlert: 5 }, tags: productData.tags || [], isActive: true }); } } - // Simulate API call - replace with actual API call - console.log('Imported products:', importedProducts); - toast.success(`Successfully imported ${importedProducts.length} products`); + 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); @@ -157,15 +161,13 @@ const Products = () => { const handleAddProduct = async () => { try { - // Simulate API call - replace with actual API call - const newProduct = { + const payload = { ...formData, - _id: `temp-${Date.now()}`, tags: formData.tags.split(',').map(t => t.trim()).filter(t => t), isActive: true }; - - console.log('New product:', newProduct); + const res = await api.post('/products', payload); + dispatch(addProduct(res.data)); toast.success('Product added successfully'); setShowAddForm(false); setFormData({ @@ -208,16 +210,15 @@ const Products = () => { const handleUpdateProduct = async () => { if (!editingProduct) return; - + try { - const updatedProduct = { - _id: editingProduct, + const payload = { ...formData, tags: formData.tags.split(',').map(t => t.trim()).filter(t => t), isActive: true }; - - dispatch(updateProduct(updatedProduct)); + const res = await api.put(`/products/${editingProduct}`, payload); + dispatch(updateProduct(res.data)); toast.success('Product updated successfully'); setShowAddForm(false); setEditingProduct(null); @@ -241,10 +242,15 @@ const Products = () => { setShowDeleteConfirm(productId); }; - const confirmDeleteProduct = () => { + const confirmDeleteProduct = async () => { if (showDeleteConfirm) { - dispatch(deleteProduct(showDeleteConfirm)); - toast.success('Product deleted successfully'); + try { + await api.delete(`/products/${showDeleteConfirm}`); + dispatch(deleteProduct(showDeleteConfirm)); + toast.success('Product deleted successfully'); + } catch { + toast.error('Failed to delete product'); + } setShowDeleteConfirm(null); } }; diff --git a/client/src/store/slices/customerSlice.ts b/client/src/store/slices/customerSlice.ts index e7a8c7f..dfa6fc6 100644 --- a/client/src/store/slices/customerSlice.ts +++ b/client/src/store/slices/customerSlice.ts @@ -15,7 +15,7 @@ interface CustomerState { } const initialState: CustomerState = { - customers: JSON.parse(localStorage.getItem('customers') || '[]'), + customers: [], loading: false, error: null, }; @@ -26,18 +26,13 @@ const customerSlice = createSlice({ reducers: { setCustomers: (state, action: PayloadAction) => { state.customers = action.payload; - localStorage.setItem('customers', JSON.stringify(state.customers)); }, addCustomer: (state, action: PayloadAction) => { state.customers.push(action.payload); - localStorage.setItem('customers', JSON.stringify(state.customers)); }, updateCustomer: (state, action: PayloadAction) => { const index = state.customers.findIndex(c => c._id === action.payload._id); - if (index !== -1) { - state.customers[index] = action.payload; - localStorage.setItem('customers', JSON.stringify(state.customers)); - } + if (index !== -1) state.customers[index] = action.payload; }, setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -49,4 +44,4 @@ const customerSlice = createSlice({ }); export const { setCustomers, addCustomer, updateCustomer, setLoading, setError } = customerSlice.actions; -export default customerSlice.reducer; \ No newline at end of file +export default customerSlice.reducer; diff --git a/client/src/store/slices/expenseSlice.ts b/client/src/store/slices/expenseSlice.ts index 56d0c78..27b7b27 100644 --- a/client/src/store/slices/expenseSlice.ts +++ b/client/src/store/slices/expenseSlice.ts @@ -18,7 +18,7 @@ interface ExpenseState { } const initialState: ExpenseState = { - expenses: JSON.parse(localStorage.getItem('expenses') || '[]'), + expenses: [], loading: false, error: null, }; @@ -29,26 +29,19 @@ const expenseSlice = createSlice({ reducers: { setExpenses: (state, action: PayloadAction) => { state.expenses = action.payload; - localStorage.setItem('expenses', JSON.stringify(state.expenses)); }, addExpenses: (state, action: PayloadAction) => { state.expenses.push(...action.payload); - localStorage.setItem('expenses', JSON.stringify(state.expenses)); }, addExpense: (state, action: PayloadAction) => { state.expenses.push(action.payload); - localStorage.setItem('expenses', JSON.stringify(state.expenses)); }, updateExpense: (state, action: PayloadAction) => { const index = state.expenses.findIndex(e => e._id === action.payload._id); - if (index !== -1) { - state.expenses[index] = action.payload; - localStorage.setItem('expenses', JSON.stringify(state.expenses)); - } + if (index !== -1) state.expenses[index] = action.payload; }, deleteExpense: (state, action: PayloadAction) => { state.expenses = state.expenses.filter(e => e._id !== action.payload); - localStorage.setItem('expenses', JSON.stringify(state.expenses)); }, setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -60,4 +53,4 @@ const expenseSlice = createSlice({ }); export const { setExpenses, addExpenses, addExpense, updateExpense, deleteExpense, setLoading, setError } = expenseSlice.actions; -export default expenseSlice.reducer; \ No newline at end of file +export default expenseSlice.reducer; diff --git a/client/src/store/slices/orderSlice.ts b/client/src/store/slices/orderSlice.ts index 507048c..b583f1c 100644 --- a/client/src/store/slices/orderSlice.ts +++ b/client/src/store/slices/orderSlice.ts @@ -38,7 +38,7 @@ interface OrderState { } const initialState: OrderState = { - orders: JSON.parse(localStorage.getItem('orders') || '[]'), + orders: [], loading: false, error: null, }; @@ -49,26 +49,19 @@ const orderSlice = createSlice({ reducers: { setOrders: (state, action: PayloadAction) => { state.orders = action.payload; - localStorage.setItem('orders', JSON.stringify(state.orders)); }, addOrder: (state, action: PayloadAction) => { state.orders.push(action.payload); - localStorage.setItem('orders', JSON.stringify(state.orders)); }, addOrders: (state, action: PayloadAction) => { state.orders.push(...action.payload); - localStorage.setItem('orders', JSON.stringify(state.orders)); }, updateOrder: (state, action: PayloadAction) => { const index = state.orders.findIndex(o => o._id === action.payload._id); - if (index !== -1) { - state.orders[index] = action.payload; - localStorage.setItem('orders', JSON.stringify(state.orders)); - } + if (index !== -1) state.orders[index] = action.payload; }, deleteOrder: (state, action: PayloadAction) => { state.orders = state.orders.filter(o => o._id !== action.payload); - localStorage.setItem('orders', JSON.stringify(state.orders)); }, setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -80,4 +73,4 @@ const orderSlice = createSlice({ }); export const { setOrders, addOrder, addOrders, updateOrder, deleteOrder, setLoading, setError } = orderSlice.actions; -export default orderSlice.reducer; \ No newline at end of file +export default orderSlice.reducer; diff --git a/client/src/store/slices/productSlice.ts b/client/src/store/slices/productSlice.ts index 2cc44c2..53bc0ce 100644 --- a/client/src/store/slices/productSlice.ts +++ b/client/src/store/slices/productSlice.ts @@ -24,7 +24,7 @@ interface ProductState { } const initialState: ProductState = { - products: JSON.parse(localStorage.getItem('products') || '[]'), + products: [], loading: false, error: null, }; @@ -35,22 +35,16 @@ const productSlice = createSlice({ reducers: { setProducts: (state, action: PayloadAction) => { state.products = action.payload; - localStorage.setItem('products', JSON.stringify(state.products)); }, addProduct: (state, action: PayloadAction) => { state.products.push(action.payload); - localStorage.setItem('products', JSON.stringify(state.products)); }, updateProduct: (state, action: PayloadAction) => { const index = state.products.findIndex(p => p._id === action.payload._id); - if (index !== -1) { - state.products[index] = action.payload; - localStorage.setItem('products', JSON.stringify(state.products)); - } + if (index !== -1) state.products[index] = action.payload; }, deleteProduct: (state, action: PayloadAction) => { state.products = state.products.filter(p => p._id !== action.payload); - localStorage.setItem('products', JSON.stringify(state.products)); }, setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -62,4 +56,4 @@ const productSlice = createSlice({ }); export const { setProducts, addProduct, updateProduct, deleteProduct, setLoading, setError } = productSlice.actions; -export default productSlice.reducer; \ No newline at end of file +export default productSlice.reducer; diff --git a/server/src/index.ts b/server/src/index.ts index 02e15f1..419cdd2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,8 +31,8 @@ app.use(cors({ // Rate limiting const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs + windowMs: 15 * 60 * 1000, + max: 1000, message: 'Too many requests from this IP, please try again later.', }); app.use(limiter); diff --git a/server/src/models/Expense.ts b/server/src/models/Expense.ts index 4a95142..31c0dfa 100644 --- a/server/src/models/Expense.ts +++ b/server/src/models/Expense.ts @@ -1,10 +1,13 @@ import mongoose, { Document, Schema } from 'mongoose'; export interface IExpense extends Document { - category: 'materials' | 'shipping' | 'fees' | 'advertising' | 'tools' | 'other'; + category: string; description: string; amount: number; date: Date; + taxDeductible: boolean; + vendor?: string; + reference?: string; receiptUrl?: string; notes?: string; userId: mongoose.Types.ObjectId; @@ -13,14 +16,13 @@ export interface IExpense extends Document { } const ExpenseSchema: Schema = new Schema({ - category: { - type: String, - enum: ['materials', 'shipping', 'fees', 'advertising', 'tools', 'other'], - required: true, - }, + category: { type: String, required: true }, description: { type: String, required: true, trim: true }, amount: { type: Number, required: true, min: 0 }, date: { type: Date, required: true }, + taxDeductible: { type: Boolean, default: false }, + vendor: { type: String }, + reference: { type: String }, receiptUrl: { type: String }, notes: { type: String }, userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, diff --git a/server/src/models/Order.ts b/server/src/models/Order.ts index 66ec14f..d766d67 100644 --- a/server/src/models/Order.ts +++ b/server/src/models/Order.ts @@ -3,29 +3,33 @@ import mongoose, { Document, Schema } from 'mongoose'; export interface IOrder extends Document { orderNumber: string; etsyOrderId?: string; - customerId: mongoose.Types.ObjectId; + customerId?: mongoose.Types.ObjectId; + customer?: { name: string; email: string }; items: { - productId: mongoose.Types.ObjectId; - sku: string; + productId?: mongoose.Types.ObjectId; + sku?: string; title: string; quantity: number; price: number; + printingCost?: number; + costOfGoods?: number; variant?: string; }[]; + fees?: { etsy: number; processing: number; shipping: number }; subtotal: number; shipping: number; tax: number; total: number; status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'; paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed'; - shippingAddress: { - name: string; - street1: string; + shippingAddress?: { + name?: string; + street1?: string; street2?: string; - city: string; - state: string; - zipCode: string; - country: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; }; trackingNumber?: string; shippingCarrier?: string; @@ -39,39 +43,50 @@ export interface IOrder extends Document { } const OrderSchema: Schema = new Schema({ - orderNumber: { type: String, required: true, unique: true }, + orderNumber: { type: String, required: true }, etsyOrderId: { type: String }, - customerId: { type: Schema.Types.ObjectId, ref: 'Customer', required: true }, + customerId: { type: Schema.Types.ObjectId, ref: 'Customer' }, + customer: { + name: { type: String }, + email: { type: String }, + }, items: [{ - productId: { type: Schema.Types.ObjectId, ref: 'Product', required: true }, - sku: { type: String, required: true }, + productId: { type: Schema.Types.ObjectId, ref: 'Product' }, + sku: { type: String }, title: { type: String, required: true }, quantity: { type: Number, required: true, min: 1 }, price: { type: Number, required: true, min: 0 }, - variant: { type: String } + printingCost: { type: Number, default: 0 }, + costOfGoods: { type: Number, default: 0 }, + variant: { type: String }, }], - subtotal: { type: Number, required: true, min: 0 }, - shipping: { type: Number, required: true, min: 0 }, - tax: { type: Number, required: true, min: 0 }, + fees: { + etsy: { type: Number, default: 0 }, + processing: { type: Number, default: 0 }, + shipping: { type: Number, default: 0 }, + }, + subtotal: { type: Number, default: 0 }, + shipping: { type: Number, default: 0 }, + tax: { type: Number, default: 0 }, total: { type: Number, required: true, min: 0 }, status: { type: String, enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'], - default: 'pending' + default: 'pending', }, paymentStatus: { type: String, enum: ['pending', 'paid', 'refunded', 'failed'], - default: 'pending' + default: 'paid', }, shippingAddress: { - name: { type: String, required: true }, - street1: { type: String, required: true }, + name: { type: String }, + street1: { type: String }, street2: { type: String }, - city: { type: String, required: true }, - state: { type: String, required: true }, - zipCode: { type: String, required: true }, - country: { type: String, required: true } + city: { type: String }, + state: { type: String }, + zipCode: { type: String }, + country: { type: String }, }, trackingNumber: { type: String }, shippingCarrier: { type: String }, @@ -81,12 +96,14 @@ const OrderSchema: Schema = new Schema({ dateDelivered: { type: Date }, userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, dateCreated: { type: Date, default: Date.now }, - dateUpdated: { type: Date, default: Date.now } + dateUpdated: { type: Date, default: Date.now }, }); -OrderSchema.pre('save', function(next) { +OrderSchema.index({ orderNumber: 1, userId: 1 }, { unique: true }); + +OrderSchema.pre('save', function (next) { this.dateUpdated = new Date(); next(); }); -export default mongoose.model('Order', OrderSchema); \ No newline at end of file +export default mongoose.model('Order', OrderSchema); diff --git a/server/src/models/Product.ts b/server/src/models/Product.ts index 02b2997..e0b916e 100644 --- a/server/src/models/Product.ts +++ b/server/src/models/Product.ts @@ -5,6 +5,7 @@ export interface IProduct extends Document { description: string; price: number; costOfGoods: number; + printingCost: number; sku: string; category: string; tags: string[]; @@ -36,11 +37,12 @@ export interface IProduct extends Document { const ProductSchema: Schema = new Schema({ title: { type: String, required: true, trim: true }, - description: { type: String, required: true }, + description: { type: String, default: '' }, price: { type: Number, required: true, min: 0 }, - costOfGoods: { type: Number, required: true, min: 0 }, - sku: { type: String, required: true, unique: true, trim: true }, - category: { type: String, required: true }, + costOfGoods: { type: Number, default: 0, min: 0 }, + printingCost: { type: Number, default: 0, min: 0 }, + sku: { type: String, trim: true, default: '' }, + category: { type: String, default: 'Other' }, tags: [{ type: String, trim: true }], images: [{ type: String }], variants: [{ diff --git a/server/src/routes/orders.ts b/server/src/routes/orders.ts index ec5ff79..2551d82 100644 --- a/server/src/routes/orders.ts +++ b/server/src/routes/orders.ts @@ -7,13 +7,12 @@ const router = Router(); router.get('/', async (req: AuthRequest, res: Response) => { try { - const { page = 1, limit = 20, status, paymentStatus } = req.query; + const { page = 1, limit = 200, status, paymentStatus } = req.query; const filter: any = { userId: req.userId }; if (status) filter.status = status; if (paymentStatus) filter.paymentStatus = paymentStatus; const orders = await Order.find(filter) - .populate('customerId', 'name email') .sort({ dateOrdered: -1 }) .limit(Number(limit)) .skip((Number(page) - 1) * Number(limit)); @@ -27,13 +26,16 @@ router.get('/', async (req: AuthRequest, res: Response) => { router.post('/', async (req: AuthRequest, res: Response) => { try { - const order = new Order({ ...req.body, userId: req.userId }); + const { _id, ...body } = req.body; + const order = new Order({ ...body, userId: req.userId }); await order.save(); - await Customer.findOneAndUpdate( - { _id: order.customerId, userId: req.userId }, - { $inc: { totalOrders: 1, totalSpent: order.total } } - ); + if (order.customerId) { + await Customer.findOneAndUpdate( + { _id: order.customerId, userId: req.userId }, + { $inc: { totalOrders: 1, totalSpent: order.total } } + ); + } res.status(201).json(order); } catch (err) { @@ -41,10 +43,36 @@ router.post('/', async (req: AuthRequest, res: Response) => { } }); +router.post('/bulk', async (req: AuthRequest, res: Response) => { + try { + const orders: any[] = req.body; + const results = { created: 0, updated: 0, errors: 0 }; + + for (const orderData of orders) { + try { + const { _id, ...body } = orderData; + const existing = await Order.findOne({ orderNumber: body.orderNumber, userId: req.userId }); + if (existing) { + await Order.findByIdAndUpdate(existing._id, { ...body, userId: req.userId }, { new: true, runValidators: true }); + results.updated++; + } else { + await Order.create({ ...body, userId: req.userId }); + results.created++; + } + } catch { + results.errors++; + } + } + + res.json(results); + } catch (err) { + res.status(400).json({ message: 'Failed to bulk import orders', error: err }); + } +}); + router.get('/:id', async (req: AuthRequest, res: Response) => { try { - const order = await Order.findOne({ _id: req.params.id, userId: req.userId }) - .populate('customerId', 'name email'); + const order = await Order.findOne({ _id: req.params.id, userId: req.userId }); if (!order) return res.status(404).json({ message: 'Order not found' }); res.json(order); } catch (err) { @@ -54,9 +82,10 @@ router.get('/:id', async (req: AuthRequest, res: Response) => { router.put('/:id', async (req: AuthRequest, res: Response) => { try { + const { _id, ...body } = req.body; const order = await Order.findOneAndUpdate( { _id: req.params.id, userId: req.userId }, - req.body, + body, { new: true, runValidators: true } ); if (!order) return res.status(404).json({ message: 'Order not found' }); @@ -66,4 +95,14 @@ router.put('/:id', async (req: AuthRequest, res: Response) => { } }); +router.delete('/:id', async (req: AuthRequest, res: Response) => { + try { + const order = await Order.findOneAndDelete({ _id: req.params.id, userId: req.userId }); + if (!order) return res.status(404).json({ message: 'Order not found' }); + res.json({ message: 'Order deleted' }); + } catch (err) { + res.status(500).json({ message: 'Failed to delete order', error: err }); + } +}); + export default router;