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:
parent
0d42d97d70
commit
1a3bd33be8
15 changed files with 408 additions and 314 deletions
|
|
@ -1,7 +1,60 @@
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
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 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<nav className="bg-white shadow-sm border-b">
|
<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>
|
<h1 className="text-xl font-bold text-gray-900">Etsy Tracker</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
<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">
|
{navLink('/analytics', 'Analytics')}
|
||||||
Analytics
|
{navLink('/profit-analysis', 'Profit Analysis')}
|
||||||
</a>
|
{navLink('/products', 'Products')}
|
||||||
<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">
|
{navLink('/orders', 'Orders')}
|
||||||
Profit Analysis
|
{navLink('/expenses', 'Expenses')}
|
||||||
</a>
|
{navLink('/data-import', 'Data Import')}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -42,4 +94,4 @@ const Layout: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux';
|
||||||
import { addProduct } from '../store/slices/productSlice';
|
import { addProduct } from '../store/slices/productSlice';
|
||||||
import { X, Plus } from 'lucide-react';
|
import { X, Plus } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
export interface MissingProduct {
|
export interface MissingProduct {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -39,39 +40,50 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProducts = () => {
|
const handleAddProducts = async () => {
|
||||||
const newProducts: any[] = [];
|
const newProducts: any[] = [];
|
||||||
|
|
||||||
for (const product of missingProducts) {
|
for (const product of missingProducts) {
|
||||||
const data = productData[product.title] || {};
|
const data = productData[product.title] || {};
|
||||||
const printingCost = data.printingCost || 0;
|
const printingCost = data.printingCost || 0;
|
||||||
const category = data.category || 'Imported Items';
|
const category = data.category || 'Imported Items';
|
||||||
|
|
||||||
// Extract size from title if present
|
try {
|
||||||
const sizeMatch = product.title.match(/Size:\s*([^,\s]+(?:\s+[^,\s]+)*)/i);
|
const res = await api.post('/products', {
|
||||||
const extractedSize = sizeMatch ? sizeMatch[1].trim() : '';
|
title: product.title,
|
||||||
|
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
|
||||||
const newProduct = {
|
price: product.price || 0,
|
||||||
_id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
costOfGoods: 0,
|
||||||
title: product.title,
|
printingCost,
|
||||||
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
|
sku: '',
|
||||||
price: product.price || 0, // Default to 0 if price not available (e.g., from packing slip only)
|
category,
|
||||||
costOfGoods: 0, // Set to 0 since printing cost includes materials
|
tags: [],
|
||||||
printingCost: printingCost,
|
inventory: { quantity: 0, lowStockAlert: 5 },
|
||||||
sku: `IMP_${Date.now()}_${newProducts.length + 1}`,
|
isActive: true
|
||||||
stockLevel: 0, // Set to 0 since we don't know stock
|
});
|
||||||
category: category,
|
dispatch(addProduct(res.data));
|
||||||
size: extractedSize,
|
newProducts.push(res.data);
|
||||||
tags: [],
|
} catch {
|
||||||
inventory: { quantity: 0, lowStockAlert: 5 },
|
// Fall back to local-only if API fails
|
||||||
isActive: true
|
const fallback = {
|
||||||
};
|
_id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
title: product.title,
|
||||||
dispatch(addProduct(newProduct));
|
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
|
||||||
newProducts.push(newProduct);
|
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);
|
onComplete(newProducts);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Order } from '../store/slices/orderSlice';
|
||||||
import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react';
|
import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { dateTestResults } from '../utils/testDateParsing';
|
import { dateTestResults } from '../utils/testDateParsing';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
interface ImportResults {
|
interface ImportResults {
|
||||||
etsyOrders: ParsedEtsyOrder[];
|
etsyOrders: ParsedEtsyOrder[];
|
||||||
|
|
@ -153,58 +154,36 @@ export default function DataImport() {
|
||||||
summary
|
summary
|
||||||
});
|
});
|
||||||
|
|
||||||
// Automatically create orders from CSV data
|
// Upsert all orders via bulk API endpoint
|
||||||
const csvOrders = etsyOrders.map(csvOrder => {
|
const csvOrders = etsyOrders.map(csvOrder => {
|
||||||
// Check if order already exists
|
|
||||||
const existingOrder = orders.find(order => order.orderNumber === csvOrder.orderNumber);
|
const existingOrder = orders.find(order => order.orderNumber === csvOrder.orderNumber);
|
||||||
|
return {
|
||||||
if (existingOrder) {
|
...(existingOrder || {}),
|
||||||
// Update existing order with CSV revenue data
|
orderNumber: csvOrder.orderNumber,
|
||||||
return {
|
total: csvOrder.saleAmount,
|
||||||
...existingOrder,
|
status: 'delivered' as const,
|
||||||
total: csvOrder.saleAmount,
|
paymentStatus: 'paid',
|
||||||
fees: {
|
dateOrdered: csvOrder.date,
|
||||||
etsy: csvOrder.totalFees || 0,
|
customer: existingOrder?.customer || { name: 'Etsy Customer', email: '' },
|
||||||
processing: 0,
|
items: existingOrder?.items || [{
|
||||||
shipping: 0
|
title: csvOrder.productName || 'Product from Etsy',
|
||||||
}
|
quantity: 1,
|
||||||
};
|
price: csvOrder.saleAmount,
|
||||||
} else {
|
printingCost: 0
|
||||||
// Create new order from CSV data
|
}],
|
||||||
return {
|
fees: { etsy: csvOrder.totalFees || 0, processing: 0, shipping: 0 }
|
||||||
_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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update existing orders and add new ones
|
try {
|
||||||
const existingOrderNumbers = new Set(orders.map(o => o.orderNumber));
|
const res = await api.post('/orders/bulk', csvOrders);
|
||||||
const ordersToUpdate = csvOrders.filter(o => existingOrderNumbers.has(o.orderNumber));
|
// Reload orders from API after bulk upsert
|
||||||
const ordersToAdd = csvOrders.filter(o => !existingOrderNumbers.has(o.orderNumber));
|
const ordersRes = await api.get('/orders?limit=1000');
|
||||||
|
dispatch(setOrders(ordersRes.data.orders));
|
||||||
ordersToUpdate.forEach(order => dispatch(updateOrder(order)));
|
toast.success(`CSV imported! Created ${res.data.created} new orders and updated ${res.data.updated} existing orders.`);
|
||||||
ordersToAdd.forEach(order => dispatch(addOrder(order)));
|
} catch {
|
||||||
|
toast.error('Failed to save orders to database');
|
||||||
toast.success(`CSV imported! Created ${ordersToAdd.length} new orders and updated ${ordersToUpdate.length} existing orders.`);
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error processing CSV files:', err);
|
console.error('Error processing CSV files:', err);
|
||||||
|
|
@ -291,11 +270,11 @@ export default function DataImport() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderData: Order = {
|
const orderData = {
|
||||||
_id: existingOrder?._id || Date.now().toString(),
|
|
||||||
orderNumber: slip.orderNumber,
|
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',
|
status: existingOrder?.status || 'processing',
|
||||||
|
paymentStatus: 'paid',
|
||||||
dateOrdered: formattedOrderDate,
|
dateOrdered: formattedOrderDate,
|
||||||
customer: existingOrder?.customer || {
|
customer: existingOrder?.customer || {
|
||||||
name: slip.customerName,
|
name: slip.customerName,
|
||||||
|
|
@ -309,10 +288,17 @@ export default function DataImport() {
|
||||||
} : existingOrder?.fees
|
} : existingOrder?.fees
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingOrder) {
|
try {
|
||||||
dispatch(updateOrder(orderData));
|
if (existingOrder) {
|
||||||
} else {
|
const res = await api.put(`/orders/${existingOrder._id}`, orderData);
|
||||||
dispatch(addOrder(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;
|
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([]));
|
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) {
|
if (!results || !results.etsyOrders || results.etsyOrders.length === 0) {
|
||||||
toast.error('No CSV data available. Please import Etsy CSV first.');
|
toast.error('No CSV data available. Please import Etsy CSV first.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -393,15 +386,14 @@ export default function DataImport() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update existing orders and add new ones
|
try {
|
||||||
const existingOrderNumbers = new Set(orders.map(o => o.orderNumber));
|
const res = await api.post('/orders/bulk', csvOrders);
|
||||||
const ordersToUpdate = csvOrders.filter(o => existingOrderNumbers.has(o.orderNumber));
|
const ordersRes = await api.get('/orders?limit=1000');
|
||||||
const ordersToAdd = csvOrders.filter(o => !existingOrderNumbers.has(o.orderNumber));
|
dispatch(setOrders(ordersRes.data.orders));
|
||||||
|
toast.success(`Re-synced: created ${res.data.created}, updated ${res.data.updated} orders.`);
|
||||||
ordersToUpdate.forEach(order => dispatch(updateOrder(order)));
|
} catch {
|
||||||
ordersToAdd.forEach(order => dispatch(addOrder(order)));
|
toast.error('Failed to sync orders');
|
||||||
|
}
|
||||||
toast.success(`Updated ${ordersToUpdate.length} existing orders and created ${ordersToAdd.length} new orders from CSV data.`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const debugDataState = () => {
|
const debugDataState = () => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { addExpenses, addExpense, updateExpense, deleteExpense } from '../store/
|
||||||
import { formatAustralianDate } from '../utils/dateFormatter';
|
import { formatAustralianDate } from '../utils/dateFormatter';
|
||||||
import { Upload, Plus, Search, Edit, Trash2, Receipt, Download, DollarSign } from 'lucide-react';
|
import { Upload, Plus, Search, Edit, Trash2, Receipt, Download, DollarSign } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
interface ExpenseFormData {
|
interface ExpenseFormData {
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -136,25 +137,28 @@ const Expenses = () => {
|
||||||
|
|
||||||
if (expenseData.amount > 0) {
|
if (expenseData.amount > 0) {
|
||||||
importedExpenses.push({
|
importedExpenses.push({
|
||||||
_id: `temp-${Date.now()}-${i}`,
|
|
||||||
description: expenseData.description || 'Imported Expense',
|
description: expenseData.description || 'Imported Expense',
|
||||||
amount: expenseData.amount,
|
amount: expenseData.amount,
|
||||||
category: expenseData.category || 'Other',
|
category: expenseData.category || 'Shipping & Postage',
|
||||||
date: expenseData.date || new Date().toISOString().split('T')[0],
|
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',
|
vendor: expenseData.vendor || expenseData.description || 'Unknown',
|
||||||
reference: expenseData.reference
|
reference: expenseData.reference
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate API call - replace with actual API call
|
let imported = 0;
|
||||||
console.log('Imported expenses:', importedExpenses);
|
const saved = [];
|
||||||
|
for (const e of importedExpenses) {
|
||||||
// Add the imported expenses to the store
|
try {
|
||||||
dispatch(addExpenses(importedExpenses));
|
const res = await api.post('/expenses', e);
|
||||||
|
saved.push(res.data);
|
||||||
toast.success(`Successfully imported ${importedExpenses.length} expenses`);
|
imported++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
dispatch(addExpenses(saved));
|
||||||
|
toast.success(`Successfully imported ${imported} expenses`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error('Import error:', error);
|
||||||
|
|
@ -220,22 +224,15 @@ const Expenses = () => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
if (editingExpense) {
|
if (editingExpense) {
|
||||||
// Update existing expense
|
const res = await api.put(`/expenses/${editingExpense}`, formData);
|
||||||
dispatch(updateExpense({
|
dispatch(updateExpense(res.data));
|
||||||
_id: editingExpense,
|
|
||||||
...formData
|
|
||||||
}));
|
|
||||||
toast.success('Expense updated successfully!');
|
toast.success('Expense updated successfully!');
|
||||||
setEditingExpense(null);
|
setEditingExpense(null);
|
||||||
} else {
|
} else {
|
||||||
// Add new expense
|
const res = await api.post('/expenses', formData);
|
||||||
dispatch(addExpense({
|
dispatch(addExpense(res.data));
|
||||||
_id: `expense-${Date.now()}`,
|
|
||||||
...formData
|
|
||||||
}));
|
|
||||||
toast.success('Expense added successfully!');
|
toast.success('Expense added successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
description: '',
|
description: '',
|
||||||
|
|
@ -263,10 +260,15 @@ const Expenses = () => {
|
||||||
setShowAddForm(true);
|
setShowAddForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (expenseId: string) => {
|
const handleDelete = async (expenseId: string) => {
|
||||||
if (window.confirm('Are you sure you want to delete this expense?')) {
|
if (window.confirm('Are you sure you want to delete this expense?')) {
|
||||||
dispatch(deleteExpense(expenseId));
|
try {
|
||||||
toast.success('Expense deleted successfully!');
|
await api.delete(`/expenses/${expenseId}`);
|
||||||
|
dispatch(deleteExpense(expenseId));
|
||||||
|
toast.success('Expense deleted successfully!');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete expense');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,19 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { RootState } from '../store';
|
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 { addProduct } from '../store/slices/productSlice';
|
||||||
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from '../utils/orderCalculations';
|
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from '../utils/orderCalculations';
|
||||||
import { formatAustralianDate } from '../utils/dateFormatter';
|
import { formatAustralianDate } from '../utils/dateFormatter';
|
||||||
import { Search, Package, Download, ArrowRight, Edit, Trash2, X, Plus } from 'lucide-react';
|
import { Search, Package, Download, ArrowRight, Edit, Trash2, X, Plus } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
const Orders = () => {
|
const Orders = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { orders } = useSelector((state: RootState) => state.orders);
|
const { orders } = useSelector((state: RootState) => state.orders);
|
||||||
const { products } = useSelector((state: RootState) => state.products);
|
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 [searchTerm, setSearchTerm] = useState('');
|
||||||
const [dateRange, setDateRange] = useState('all');
|
const [dateRange, setDateRange] = useState('all');
|
||||||
|
|
@ -161,11 +146,16 @@ const Orders = () => {
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveOrder = (updatedOrder: Order) => {
|
const handleSaveOrder = async (updatedOrder: Order) => {
|
||||||
dispatch(updateOrder(updatedOrder));
|
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);
|
setShowEditModal(false);
|
||||||
setEditingOrder(null);
|
setEditingOrder(null);
|
||||||
toast.success('Order updated successfully');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (orderId: string) => {
|
const handleDeleteClick = (orderId: string) => {
|
||||||
|
|
@ -173,17 +163,20 @@ const Orders = () => {
|
||||||
setShowDeleteConfirm(true);
|
setShowDeleteConfirm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmDelete = () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (deletingOrderId && deletingOrderId !== 'undefined') {
|
if (deletingOrderId && deletingOrderId !== 'undefined') {
|
||||||
dispatch(deleteOrder(deletingOrderId));
|
try {
|
||||||
setShowDeleteConfirm(false);
|
await api.delete(`/orders/${deletingOrderId}`);
|
||||||
setDeletingOrderId(null);
|
dispatch(deleteOrder(deletingOrderId));
|
||||||
toast.success('Order deleted successfully');
|
toast.success('Order deleted successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete order');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error('Cannot delete order: Invalid order ID');
|
toast.error('Cannot delete order: Invalid order ID');
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
setDeletingOrderId(null);
|
|
||||||
}
|
}
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setDeletingOrderId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelDelete = () => {
|
const handleCancelDelete = () => {
|
||||||
|
|
@ -202,29 +195,30 @@ const Orders = () => {
|
||||||
setShowNewProductModal(true);
|
setShowNewProductModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveNewProduct = () => {
|
const handleSaveNewProduct = async () => {
|
||||||
if (!newProductData.title) {
|
if (!newProductData.title) {
|
||||||
toast.error('Please enter a product title');
|
toast.error('Please enter a product title');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const newProduct = {
|
const res = await api.post('/products', {
|
||||||
_id: `product-${Date.now()}`,
|
title: newProductData.title,
|
||||||
title: newProductData.title,
|
description: '',
|
||||||
description: '',
|
price: newProductData.price,
|
||||||
price: newProductData.price,
|
costOfGoods: newProductData.costOfGoods,
|
||||||
costOfGoods: newProductData.costOfGoods,
|
printingCost: newProductData.printingCost,
|
||||||
printingCost: newProductData.printingCost,
|
sku: '',
|
||||||
sku: '',
|
category: 'Other',
|
||||||
category: '',
|
tags: [],
|
||||||
tags: [],
|
inventory: { quantity: 0, lowStockAlert: 10 },
|
||||||
inventory: { quantity: 0, lowStockAlert: 10 },
|
isActive: true
|
||||||
isActive: true
|
});
|
||||||
};
|
dispatch(addProduct(res.data));
|
||||||
|
setShowNewProductModal(false);
|
||||||
dispatch(addProduct(newProduct));
|
toast.success('Product created successfully');
|
||||||
setShowNewProductModal(false);
|
} catch {
|
||||||
toast.success('Product created successfully');
|
toast.error('Failed to create product');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Manual order handlers
|
// Manual order handlers
|
||||||
|
|
@ -239,15 +233,13 @@ const Orders = () => {
|
||||||
setShowAddModal(true);
|
setShowAddModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveNewOrder = () => {
|
const handleSaveNewOrder = async () => {
|
||||||
if (!newOrder.orderNumber || !newOrder.items[0]?.title) {
|
if (!newOrder.orderNumber || !newOrder.items[0]?.title) {
|
||||||
toast.error('Please fill in order number and at least one item');
|
toast.error('Please fill in order number and at least one item');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculatedTotal = newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0);
|
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
|
const enrichedItems = newOrder.items
|
||||||
.filter(item => item.title.trim() !== '')
|
.filter(item => item.title.trim() !== '')
|
||||||
.map(item => {
|
.map(item => {
|
||||||
|
|
@ -259,19 +251,22 @@ const Orders = () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderToSave: Order = {
|
try {
|
||||||
_id: `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // More unique ID
|
const res = await api.post('/orders', {
|
||||||
orderNumber: `FB-${newOrder.orderNumber}`, // Prefix to identify Facebook orders
|
orderNumber: `FB-${newOrder.orderNumber}`,
|
||||||
customer: newOrder.customer,
|
customer: newOrder.customer,
|
||||||
dateOrdered: newOrder.dateOrdered,
|
dateOrdered: newOrder.dateOrdered,
|
||||||
total: calculatedTotal,
|
total: calculatedTotal,
|
||||||
status: 'delivered' as const,
|
status: 'delivered',
|
||||||
items: enrichedItems
|
paymentStatus: 'paid',
|
||||||
};
|
items: enrichedItems
|
||||||
|
});
|
||||||
dispatch(addOrder(orderToSave));
|
dispatch(addOrder(res.data));
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
toast.success('Facebook Marketplace order added successfully');
|
toast.success('Facebook Marketplace order added successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save order');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { RootState } from '../store';
|
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 { Upload, Plus, Search, Edit, Trash2, Package } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -68,12 +69,12 @@ const Products = () => {
|
||||||
const importedProducts = [];
|
const importedProducts = [];
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
if (lines[i].trim() === '') continue;
|
if (lines[i].trim() === '') continue;
|
||||||
|
|
||||||
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
||||||
const productData: any = {
|
const productData: any = {
|
||||||
printingCost: 0 // Default printing cost
|
printingCost: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
headers.forEach((header, index) => {
|
headers.forEach((header, index) => {
|
||||||
const value = values[index] || '';
|
const value = values[index] || '';
|
||||||
switch (header.toLowerCase()) {
|
switch (header.toLowerCase()) {
|
||||||
|
|
@ -119,19 +120,22 @@ const Products = () => {
|
||||||
if (productData.title) {
|
if (productData.title) {
|
||||||
importedProducts.push({
|
importedProducts.push({
|
||||||
...productData,
|
...productData,
|
||||||
inventory: {
|
inventory: { quantity: productData.quantity || 0, lowStockAlert: 5 },
|
||||||
quantity: productData.quantity || 0,
|
|
||||||
lowStockAlert: 5
|
|
||||||
},
|
|
||||||
tags: productData.tags || [],
|
tags: productData.tags || [],
|
||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate API call - replace with actual API call
|
let imported = 0;
|
||||||
console.log('Imported products:', importedProducts);
|
for (const p of importedProducts) {
|
||||||
toast.success(`Successfully imported ${importedProducts.length} products`);
|
try {
|
||||||
|
const res = await api.post('/products', p);
|
||||||
|
dispatch(addProduct(res.data));
|
||||||
|
imported++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
toast.success(`Successfully imported ${imported} products`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error('Import error:', error);
|
||||||
|
|
@ -157,15 +161,13 @@ const Products = () => {
|
||||||
|
|
||||||
const handleAddProduct = async () => {
|
const handleAddProduct = async () => {
|
||||||
try {
|
try {
|
||||||
// Simulate API call - replace with actual API call
|
const payload = {
|
||||||
const newProduct = {
|
|
||||||
...formData,
|
...formData,
|
||||||
_id: `temp-${Date.now()}`,
|
|
||||||
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
||||||
isActive: true
|
isActive: true
|
||||||
};
|
};
|
||||||
|
const res = await api.post('/products', payload);
|
||||||
console.log('New product:', newProduct);
|
dispatch(addProduct(res.data));
|
||||||
toast.success('Product added successfully');
|
toast.success('Product added successfully');
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
|
|
@ -208,16 +210,15 @@ const Products = () => {
|
||||||
|
|
||||||
const handleUpdateProduct = async () => {
|
const handleUpdateProduct = async () => {
|
||||||
if (!editingProduct) return;
|
if (!editingProduct) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedProduct = {
|
const payload = {
|
||||||
_id: editingProduct,
|
|
||||||
...formData,
|
...formData,
|
||||||
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
|
||||||
isActive: true
|
isActive: true
|
||||||
};
|
};
|
||||||
|
const res = await api.put(`/products/${editingProduct}`, payload);
|
||||||
dispatch(updateProduct(updatedProduct));
|
dispatch(updateProduct(res.data));
|
||||||
toast.success('Product updated successfully');
|
toast.success('Product updated successfully');
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
|
|
@ -241,10 +242,15 @@ const Products = () => {
|
||||||
setShowDeleteConfirm(productId);
|
setShowDeleteConfirm(productId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDeleteProduct = () => {
|
const confirmDeleteProduct = async () => {
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
dispatch(deleteProduct(showDeleteConfirm));
|
try {
|
||||||
toast.success('Product deleted successfully');
|
await api.delete(`/products/${showDeleteConfirm}`);
|
||||||
|
dispatch(deleteProduct(showDeleteConfirm));
|
||||||
|
toast.success('Product deleted successfully');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete product');
|
||||||
|
}
|
||||||
setShowDeleteConfirm(null);
|
setShowDeleteConfirm(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ interface CustomerState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: CustomerState = {
|
const initialState: CustomerState = {
|
||||||
customers: JSON.parse(localStorage.getItem('customers') || '[]'),
|
customers: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -26,18 +26,13 @@ const customerSlice = createSlice({
|
||||||
reducers: {
|
reducers: {
|
||||||
setCustomers: (state, action: PayloadAction<Customer[]>) => {
|
setCustomers: (state, action: PayloadAction<Customer[]>) => {
|
||||||
state.customers = action.payload;
|
state.customers = action.payload;
|
||||||
localStorage.setItem('customers', JSON.stringify(state.customers));
|
|
||||||
},
|
},
|
||||||
addCustomer: (state, action: PayloadAction<Customer>) => {
|
addCustomer: (state, action: PayloadAction<Customer>) => {
|
||||||
state.customers.push(action.payload);
|
state.customers.push(action.payload);
|
||||||
localStorage.setItem('customers', JSON.stringify(state.customers));
|
|
||||||
},
|
},
|
||||||
updateCustomer: (state, action: PayloadAction<Customer>) => {
|
updateCustomer: (state, action: PayloadAction<Customer>) => {
|
||||||
const index = state.customers.findIndex(c => c._id === action.payload._id);
|
const index = state.customers.findIndex(c => c._id === action.payload._id);
|
||||||
if (index !== -1) {
|
if (index !== -1) state.customers[index] = action.payload;
|
||||||
state.customers[index] = action.payload;
|
|
||||||
localStorage.setItem('customers', JSON.stringify(state.customers));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
|
|
@ -49,4 +44,4 @@ const customerSlice = createSlice({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setCustomers, addCustomer, updateCustomer, setLoading, setError } = customerSlice.actions;
|
export const { setCustomers, addCustomer, updateCustomer, setLoading, setError } = customerSlice.actions;
|
||||||
export default customerSlice.reducer;
|
export default customerSlice.reducer;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ interface ExpenseState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ExpenseState = {
|
const initialState: ExpenseState = {
|
||||||
expenses: JSON.parse(localStorage.getItem('expenses') || '[]'),
|
expenses: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -29,26 +29,19 @@ const expenseSlice = createSlice({
|
||||||
reducers: {
|
reducers: {
|
||||||
setExpenses: (state, action: PayloadAction<Expense[]>) => {
|
setExpenses: (state, action: PayloadAction<Expense[]>) => {
|
||||||
state.expenses = action.payload;
|
state.expenses = action.payload;
|
||||||
localStorage.setItem('expenses', JSON.stringify(state.expenses));
|
|
||||||
},
|
},
|
||||||
addExpenses: (state, action: PayloadAction<Expense[]>) => {
|
addExpenses: (state, action: PayloadAction<Expense[]>) => {
|
||||||
state.expenses.push(...action.payload);
|
state.expenses.push(...action.payload);
|
||||||
localStorage.setItem('expenses', JSON.stringify(state.expenses));
|
|
||||||
},
|
},
|
||||||
addExpense: (state, action: PayloadAction<Expense>) => {
|
addExpense: (state, action: PayloadAction<Expense>) => {
|
||||||
state.expenses.push(action.payload);
|
state.expenses.push(action.payload);
|
||||||
localStorage.setItem('expenses', JSON.stringify(state.expenses));
|
|
||||||
},
|
},
|
||||||
updateExpense: (state, action: PayloadAction<Expense>) => {
|
updateExpense: (state, action: PayloadAction<Expense>) => {
|
||||||
const index = state.expenses.findIndex(e => e._id === action.payload._id);
|
const index = state.expenses.findIndex(e => e._id === action.payload._id);
|
||||||
if (index !== -1) {
|
if (index !== -1) state.expenses[index] = action.payload;
|
||||||
state.expenses[index] = action.payload;
|
|
||||||
localStorage.setItem('expenses', JSON.stringify(state.expenses));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
deleteExpense: (state, action: PayloadAction<string>) => {
|
deleteExpense: (state, action: PayloadAction<string>) => {
|
||||||
state.expenses = state.expenses.filter(e => e._id !== action.payload);
|
state.expenses = state.expenses.filter(e => e._id !== action.payload);
|
||||||
localStorage.setItem('expenses', JSON.stringify(state.expenses));
|
|
||||||
},
|
},
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
|
|
@ -60,4 +53,4 @@ const expenseSlice = createSlice({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setExpenses, addExpenses, addExpense, updateExpense, deleteExpense, setLoading, setError } = expenseSlice.actions;
|
export const { setExpenses, addExpenses, addExpense, updateExpense, deleteExpense, setLoading, setError } = expenseSlice.actions;
|
||||||
export default expenseSlice.reducer;
|
export default expenseSlice.reducer;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ interface OrderState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: OrderState = {
|
const initialState: OrderState = {
|
||||||
orders: JSON.parse(localStorage.getItem('orders') || '[]'),
|
orders: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -49,26 +49,19 @@ const orderSlice = createSlice({
|
||||||
reducers: {
|
reducers: {
|
||||||
setOrders: (state, action: PayloadAction<Order[]>) => {
|
setOrders: (state, action: PayloadAction<Order[]>) => {
|
||||||
state.orders = action.payload;
|
state.orders = action.payload;
|
||||||
localStorage.setItem('orders', JSON.stringify(state.orders));
|
|
||||||
},
|
},
|
||||||
addOrder: (state, action: PayloadAction<Order>) => {
|
addOrder: (state, action: PayloadAction<Order>) => {
|
||||||
state.orders.push(action.payload);
|
state.orders.push(action.payload);
|
||||||
localStorage.setItem('orders', JSON.stringify(state.orders));
|
|
||||||
},
|
},
|
||||||
addOrders: (state, action: PayloadAction<Order[]>) => {
|
addOrders: (state, action: PayloadAction<Order[]>) => {
|
||||||
state.orders.push(...action.payload);
|
state.orders.push(...action.payload);
|
||||||
localStorage.setItem('orders', JSON.stringify(state.orders));
|
|
||||||
},
|
},
|
||||||
updateOrder: (state, action: PayloadAction<Order>) => {
|
updateOrder: (state, action: PayloadAction<Order>) => {
|
||||||
const index = state.orders.findIndex(o => o._id === action.payload._id);
|
const index = state.orders.findIndex(o => o._id === action.payload._id);
|
||||||
if (index !== -1) {
|
if (index !== -1) state.orders[index] = action.payload;
|
||||||
state.orders[index] = action.payload;
|
|
||||||
localStorage.setItem('orders', JSON.stringify(state.orders));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
deleteOrder: (state, action: PayloadAction<string>) => {
|
deleteOrder: (state, action: PayloadAction<string>) => {
|
||||||
state.orders = state.orders.filter(o => o._id !== action.payload);
|
state.orders = state.orders.filter(o => o._id !== action.payload);
|
||||||
localStorage.setItem('orders', JSON.stringify(state.orders));
|
|
||||||
},
|
},
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
|
|
@ -80,4 +73,4 @@ const orderSlice = createSlice({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setOrders, addOrder, addOrders, updateOrder, deleteOrder, setLoading, setError } = orderSlice.actions;
|
export const { setOrders, addOrder, addOrders, updateOrder, deleteOrder, setLoading, setError } = orderSlice.actions;
|
||||||
export default orderSlice.reducer;
|
export default orderSlice.reducer;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ interface ProductState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ProductState = {
|
const initialState: ProductState = {
|
||||||
products: JSON.parse(localStorage.getItem('products') || '[]'),
|
products: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -35,22 +35,16 @@ const productSlice = createSlice({
|
||||||
reducers: {
|
reducers: {
|
||||||
setProducts: (state, action: PayloadAction<Product[]>) => {
|
setProducts: (state, action: PayloadAction<Product[]>) => {
|
||||||
state.products = action.payload;
|
state.products = action.payload;
|
||||||
localStorage.setItem('products', JSON.stringify(state.products));
|
|
||||||
},
|
},
|
||||||
addProduct: (state, action: PayloadAction<Product>) => {
|
addProduct: (state, action: PayloadAction<Product>) => {
|
||||||
state.products.push(action.payload);
|
state.products.push(action.payload);
|
||||||
localStorage.setItem('products', JSON.stringify(state.products));
|
|
||||||
},
|
},
|
||||||
updateProduct: (state, action: PayloadAction<Product>) => {
|
updateProduct: (state, action: PayloadAction<Product>) => {
|
||||||
const index = state.products.findIndex(p => p._id === action.payload._id);
|
const index = state.products.findIndex(p => p._id === action.payload._id);
|
||||||
if (index !== -1) {
|
if (index !== -1) state.products[index] = action.payload;
|
||||||
state.products[index] = action.payload;
|
|
||||||
localStorage.setItem('products', JSON.stringify(state.products));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
deleteProduct: (state, action: PayloadAction<string>) => {
|
deleteProduct: (state, action: PayloadAction<string>) => {
|
||||||
state.products = state.products.filter(p => p._id !== action.payload);
|
state.products = state.products.filter(p => p._id !== action.payload);
|
||||||
localStorage.setItem('products', JSON.stringify(state.products));
|
|
||||||
},
|
},
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
|
|
@ -62,4 +56,4 @@ const productSlice = createSlice({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setProducts, addProduct, updateProduct, deleteProduct, setLoading, setError } = productSlice.actions;
|
export const { setProducts, addProduct, updateProduct, deleteProduct, setLoading, setError } = productSlice.actions;
|
||||||
export default productSlice.reducer;
|
export default productSlice.reducer;
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ app.use(cors({
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 100, // limit each IP to 100 requests per windowMs
|
max: 1000,
|
||||||
message: 'Too many requests from this IP, please try again later.',
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
});
|
});
|
||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import mongoose, { Document, Schema } from 'mongoose';
|
import mongoose, { Document, Schema } from 'mongoose';
|
||||||
|
|
||||||
export interface IExpense extends Document {
|
export interface IExpense extends Document {
|
||||||
category: 'materials' | 'shipping' | 'fees' | 'advertising' | 'tools' | 'other';
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
date: Date;
|
date: Date;
|
||||||
|
taxDeductible: boolean;
|
||||||
|
vendor?: string;
|
||||||
|
reference?: string;
|
||||||
receiptUrl?: string;
|
receiptUrl?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
userId: mongoose.Types.ObjectId;
|
userId: mongoose.Types.ObjectId;
|
||||||
|
|
@ -13,14 +16,13 @@ export interface IExpense extends Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpenseSchema: Schema = new Schema({
|
const ExpenseSchema: Schema = new Schema({
|
||||||
category: {
|
category: { type: String, required: true },
|
||||||
type: String,
|
|
||||||
enum: ['materials', 'shipping', 'fees', 'advertising', 'tools', 'other'],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: { type: String, required: true, trim: true },
|
description: { type: String, required: true, trim: true },
|
||||||
amount: { type: Number, required: true, min: 0 },
|
amount: { type: Number, required: true, min: 0 },
|
||||||
date: { type: Date, required: true },
|
date: { type: Date, required: true },
|
||||||
|
taxDeductible: { type: Boolean, default: false },
|
||||||
|
vendor: { type: String },
|
||||||
|
reference: { type: String },
|
||||||
receiptUrl: { type: String },
|
receiptUrl: { type: String },
|
||||||
notes: { type: String },
|
notes: { type: String },
|
||||||
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,33 @@ import mongoose, { Document, Schema } from 'mongoose';
|
||||||
export interface IOrder extends Document {
|
export interface IOrder extends Document {
|
||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
etsyOrderId?: string;
|
etsyOrderId?: string;
|
||||||
customerId: mongoose.Types.ObjectId;
|
customerId?: mongoose.Types.ObjectId;
|
||||||
|
customer?: { name: string; email: string };
|
||||||
items: {
|
items: {
|
||||||
productId: mongoose.Types.ObjectId;
|
productId?: mongoose.Types.ObjectId;
|
||||||
sku: string;
|
sku?: string;
|
||||||
title: string;
|
title: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
printingCost?: number;
|
||||||
|
costOfGoods?: number;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
}[];
|
}[];
|
||||||
|
fees?: { etsy: number; processing: number; shipping: number };
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
shipping: number;
|
shipping: number;
|
||||||
tax: number;
|
tax: number;
|
||||||
total: number;
|
total: number;
|
||||||
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||||
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
|
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
|
||||||
shippingAddress: {
|
shippingAddress?: {
|
||||||
name: string;
|
name?: string;
|
||||||
street1: string;
|
street1?: string;
|
||||||
street2?: string;
|
street2?: string;
|
||||||
city: string;
|
city?: string;
|
||||||
state: string;
|
state?: string;
|
||||||
zipCode: string;
|
zipCode?: string;
|
||||||
country: string;
|
country?: string;
|
||||||
};
|
};
|
||||||
trackingNumber?: string;
|
trackingNumber?: string;
|
||||||
shippingCarrier?: string;
|
shippingCarrier?: string;
|
||||||
|
|
@ -39,39 +43,50 @@ export interface IOrder extends Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrderSchema: Schema = new Schema({
|
const OrderSchema: Schema = new Schema({
|
||||||
orderNumber: { type: String, required: true, unique: true },
|
orderNumber: { type: String, required: true },
|
||||||
etsyOrderId: { type: String },
|
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: [{
|
items: [{
|
||||||
productId: { type: Schema.Types.ObjectId, ref: 'Product', required: true },
|
productId: { type: Schema.Types.ObjectId, ref: 'Product' },
|
||||||
sku: { type: String, required: true },
|
sku: { type: String },
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
quantity: { type: Number, required: true, min: 1 },
|
quantity: { type: Number, required: true, min: 1 },
|
||||||
price: { type: Number, required: true, min: 0 },
|
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 },
|
fees: {
|
||||||
shipping: { type: Number, required: true, min: 0 },
|
etsy: { type: Number, default: 0 },
|
||||||
tax: { type: Number, required: true, min: 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 },
|
total: { type: Number, required: true, min: 0 },
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'],
|
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'],
|
||||||
default: 'pending'
|
default: 'pending',
|
||||||
},
|
},
|
||||||
paymentStatus: {
|
paymentStatus: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['pending', 'paid', 'refunded', 'failed'],
|
enum: ['pending', 'paid', 'refunded', 'failed'],
|
||||||
default: 'pending'
|
default: 'paid',
|
||||||
},
|
},
|
||||||
shippingAddress: {
|
shippingAddress: {
|
||||||
name: { type: String, required: true },
|
name: { type: String },
|
||||||
street1: { type: String, required: true },
|
street1: { type: String },
|
||||||
street2: { type: String },
|
street2: { type: String },
|
||||||
city: { type: String, required: true },
|
city: { type: String },
|
||||||
state: { type: String, required: true },
|
state: { type: String },
|
||||||
zipCode: { type: String, required: true },
|
zipCode: { type: String },
|
||||||
country: { type: String, required: true }
|
country: { type: String },
|
||||||
},
|
},
|
||||||
trackingNumber: { type: String },
|
trackingNumber: { type: String },
|
||||||
shippingCarrier: { type: String },
|
shippingCarrier: { type: String },
|
||||||
|
|
@ -81,12 +96,14 @@ const OrderSchema: Schema = new Schema({
|
||||||
dateDelivered: { type: Date },
|
dateDelivered: { type: Date },
|
||||||
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
dateCreated: { type: Date, default: Date.now },
|
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();
|
this.dateUpdated = new Date();
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default mongoose.model<IOrder>('Order', OrderSchema);
|
export default mongoose.model<IOrder>('Order', OrderSchema);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export interface IProduct extends Document {
|
||||||
description: string;
|
description: string;
|
||||||
price: number;
|
price: number;
|
||||||
costOfGoods: number;
|
costOfGoods: number;
|
||||||
|
printingCost: number;
|
||||||
sku: string;
|
sku: string;
|
||||||
category: string;
|
category: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|
@ -36,11 +37,12 @@ export interface IProduct extends Document {
|
||||||
|
|
||||||
const ProductSchema: Schema = new Schema({
|
const ProductSchema: Schema = new Schema({
|
||||||
title: { type: String, required: true, trim: true },
|
title: { type: String, required: true, trim: true },
|
||||||
description: { type: String, required: true },
|
description: { type: String, default: '' },
|
||||||
price: { type: Number, required: true, min: 0 },
|
price: { type: Number, required: true, min: 0 },
|
||||||
costOfGoods: { type: Number, required: true, min: 0 },
|
costOfGoods: { type: Number, default: 0, min: 0 },
|
||||||
sku: { type: String, required: true, unique: true, trim: true },
|
printingCost: { type: Number, default: 0, min: 0 },
|
||||||
category: { type: String, required: true },
|
sku: { type: String, trim: true, default: '' },
|
||||||
|
category: { type: String, default: 'Other' },
|
||||||
tags: [{ type: String, trim: true }],
|
tags: [{ type: String, trim: true }],
|
||||||
images: [{ type: String }],
|
images: [{ type: String }],
|
||||||
variants: [{
|
variants: [{
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,12 @@ const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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 };
|
const filter: any = { userId: req.userId };
|
||||||
if (status) filter.status = status;
|
if (status) filter.status = status;
|
||||||
if (paymentStatus) filter.paymentStatus = paymentStatus;
|
if (paymentStatus) filter.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
const orders = await Order.find(filter)
|
const orders = await Order.find(filter)
|
||||||
.populate('customerId', 'name email')
|
|
||||||
.sort({ dateOrdered: -1 })
|
.sort({ dateOrdered: -1 })
|
||||||
.limit(Number(limit))
|
.limit(Number(limit))
|
||||||
.skip((Number(page) - 1) * 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) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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 order.save();
|
||||||
|
|
||||||
await Customer.findOneAndUpdate(
|
if (order.customerId) {
|
||||||
{ _id: order.customerId, userId: req.userId },
|
await Customer.findOneAndUpdate(
|
||||||
{ $inc: { totalOrders: 1, totalSpent: order.total } }
|
{ _id: order.customerId, userId: req.userId },
|
||||||
);
|
{ $inc: { totalOrders: 1, totalSpent: order.total } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json(order);
|
res.status(201).json(order);
|
||||||
} catch (err) {
|
} 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) => {
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const order = await Order.findOne({ _id: req.params.id, userId: req.userId })
|
const order = await Order.findOne({ _id: req.params.id, userId: req.userId });
|
||||||
.populate('customerId', 'name email');
|
|
||||||
if (!order) return res.status(404).json({ message: 'Order not found' });
|
if (!order) return res.status(404).json({ message: 'Order not found' });
|
||||||
res.json(order);
|
res.json(order);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -54,9 +82,10 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
|
||||||
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const { _id, ...body } = req.body;
|
||||||
const order = await Order.findOneAndUpdate(
|
const order = await Order.findOneAndUpdate(
|
||||||
{ _id: req.params.id, userId: req.userId },
|
{ _id: req.params.id, userId: req.userId },
|
||||||
req.body,
|
body,
|
||||||
{ new: true, runValidators: true }
|
{ new: true, runValidators: true }
|
||||||
);
|
);
|
||||||
if (!order) return res.status(404).json({ message: 'Order not found' });
|
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;
|
export default router;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue