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>
This commit is contained in:
dlawler489 2026-04-22 08:48:05 +10:00
parent 0d42d97d70
commit 1a3bd33be8
15 changed files with 408 additions and 314 deletions

View file

@ -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 (
<a
href={href}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
active
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{label}
</a>
);
};
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
@ -12,26 +65,25 @@ const Layout: React.FC = () => {
<h1 className="text-xl font-bold text-gray-900">Etsy Tracker</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/analytics" className="border-indigo-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Analytics
</a>
<a href="/profit-analysis" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Profit Analysis
</a>
<a href="/products" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Products
</a>
<a href="/orders" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Orders
</a>
<a href="/expenses" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Expenses
</a>
<a href="/data-import" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Data Import
</a>
{navLink('/analytics', 'Analytics')}
{navLink('/profit-analysis', 'Profit Analysis')}
{navLink('/products', 'Products')}
{navLink('/orders', 'Orders')}
{navLink('/expenses', 'Expenses')}
{navLink('/data-import', 'Data Import')}
</div>
</div>
<div className="flex items-center gap-4">
{user && (
<span className="text-sm text-gray-600">{user.email}</span>
)}
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-700 px-3 py-1 border border-gray-300 rounded-md hover:bg-gray-50"
>
Logout
</button>
</div>
</div>
</div>
</nav>

View file

@ -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,7 +40,7 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
}));
};
const handleAddProducts = () => {
const handleAddProducts = async () => {
const newProducts: any[] = [];
for (const product of missingProducts) {
@ -47,31 +48,42 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
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 = {
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, // 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,
price: product.price || 0,
costOfGoods: 0,
printingCost,
sku: '',
category,
tags: [],
inventory: { quantity: 0, lowStockAlert: 5 },
isActive: true
};
dispatch(addProduct(newProduct));
newProducts.push(newProduct);
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);
};

View file

@ -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}`,
...(existingOrder || {}),
orderNumber: csvOrder.orderNumber,
total: csvOrder.saleAmount,
status: 'delivered' as const,
paymentStatus: 'paid',
dateOrdered: csvOrder.date,
customer: {
name: 'Etsy Customer',
email: ''
},
items: [{
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
}
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
};
try {
if (existingOrder) {
dispatch(updateOrder(orderData));
const res = await api.put(`/orders/${existingOrder._id}`, orderData);
dispatch(updateOrder(res.data));
} else {
dispatch(addOrder(orderData));
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 = () => {

View file

@ -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?')) {
try {
await api.delete(`/expenses/${expenseId}`);
dispatch(deleteExpense(expenseId));
toast.success('Expense deleted successfully!');
} catch {
toast.error('Failed to delete expense');
}
}
};

View file

@ -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') {
try {
await api.delete(`/orders/${deletingOrderId}`);
dispatch(deleteOrder(deletingOrderId));
setShowDeleteConfirm(false);
setDeletingOrderId(null);
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);
}
};
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()}`,
try {
const res = await api.post('/products', {
title: newProductData.title,
description: '',
price: newProductData.price,
costOfGoods: newProductData.costOfGoods,
printingCost: newProductData.printingCost,
sku: '',
category: '',
category: 'Other',
tags: [],
inventory: { quantity: 0, lowStockAlert: 10 },
isActive: true
};
dispatch(addProduct(newProduct));
});
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
try {
const res = await api.post('/orders', {
orderNumber: `FB-${newOrder.orderNumber}`,
customer: newOrder.customer,
dateOrdered: newOrder.dateOrdered,
total: calculatedTotal,
status: 'delivered' as const,
status: 'delivered',
paymentStatus: 'paid',
items: enrichedItems
};
dispatch(addOrder(orderToSave));
});
dispatch(addOrder(res.data));
setShowAddModal(false);
toast.success('Facebook Marketplace order added successfully');
} catch {
toast.error('Failed to save order');
}
};
const handleAddItem = () => {

View file

@ -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;
@ -71,7 +72,7 @@ const Products = () => {
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) => {
@ -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({
@ -210,14 +212,13 @@ const Products = () => {
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) {
try {
await api.delete(`/products/${showDeleteConfirm}`);
dispatch(deleteProduct(showDeleteConfirm));
toast.success('Product deleted successfully');
} catch {
toast.error('Failed to delete product');
}
setShowDeleteConfirm(null);
}
};

View file

@ -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<Customer[]>) => {
state.customers = action.payload;
localStorage.setItem('customers', JSON.stringify(state.customers));
},
addCustomer: (state, action: PayloadAction<Customer>) => {
state.customers.push(action.payload);
localStorage.setItem('customers', JSON.stringify(state.customers));
},
updateCustomer: (state, action: PayloadAction<Customer>) => {
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<boolean>) => {
state.loading = action.payload;

View file

@ -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<Expense[]>) => {
state.expenses = action.payload;
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
addExpenses: (state, action: PayloadAction<Expense[]>) => {
state.expenses.push(...action.payload);
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
addExpense: (state, action: PayloadAction<Expense>) => {
state.expenses.push(action.payload);
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
updateExpense: (state, action: PayloadAction<Expense>) => {
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<string>) => {
state.expenses = state.expenses.filter(e => e._id !== action.payload);
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;

View file

@ -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<Order[]>) => {
state.orders = action.payload;
localStorage.setItem('orders', JSON.stringify(state.orders));
},
addOrder: (state, action: PayloadAction<Order>) => {
state.orders.push(action.payload);
localStorage.setItem('orders', JSON.stringify(state.orders));
},
addOrders: (state, action: PayloadAction<Order[]>) => {
state.orders.push(...action.payload);
localStorage.setItem('orders', JSON.stringify(state.orders));
},
updateOrder: (state, action: PayloadAction<Order>) => {
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<string>) => {
state.orders = state.orders.filter(o => o._id !== action.payload);
localStorage.setItem('orders', JSON.stringify(state.orders));
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;

View file

@ -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<Product[]>) => {
state.products = action.payload;
localStorage.setItem('products', JSON.stringify(state.products));
},
addProduct: (state, action: PayloadAction<Product>) => {
state.products.push(action.payload);
localStorage.setItem('products', JSON.stringify(state.products));
},
updateProduct: (state, action: PayloadAction<Product>) => {
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<string>) => {
state.products = state.products.filter(p => p._id !== action.payload);
localStorage.setItem('products', JSON.stringify(state.products));
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;

View file

@ -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);

View file

@ -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 },

View file

@ -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,9 +96,11 @@ 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.index({ orderNumber: 1, userId: 1 }, { unique: true });
OrderSchema.pre('save', function (next) {
this.dateUpdated = new Date();
next();

View file

@ -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: [{

View file

@ -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();
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;