- 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>
950 lines
No EOL
40 KiB
TypeScript
950 lines
No EOL
40 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useSelector, useDispatch } from 'react-redux';
|
||
import { RootState } from '../store';
|
||
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);
|
||
|
||
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [dateRange, setDateRange] = useState('all');
|
||
|
||
// Edit modal state
|
||
const [editingOrder, setEditingOrder] = useState<Order | null>(null);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
|
||
// Delete confirmation state
|
||
const [deletingOrderId, setDeletingOrderId] = useState<string | null>(null);
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||
|
||
// Add manual order state
|
||
const [showAddModal, setShowAddModal] = useState(false);
|
||
const [showNewProductModal, setShowNewProductModal] = useState(false);
|
||
const [newProductData, setNewProductData] = useState({
|
||
title: '',
|
||
price: 0,
|
||
printingCost: 0,
|
||
costOfGoods: 0
|
||
});
|
||
const [newOrder, setNewOrder] = useState({
|
||
orderNumber: '',
|
||
customer: { name: '', email: '' },
|
||
dateOrdered: new Date().toISOString().split('T')[0],
|
||
total: 0,
|
||
items: [{ title: '', quantity: 1, price: 0 }]
|
||
});
|
||
|
||
// Helper functions to use current product costs
|
||
const getUpdatedPrintingCost = (order: Order) => {
|
||
const updatedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
||
return calculateOrderPrintingCost(updatedItems);
|
||
};
|
||
|
||
const getUpdatedProfit = (order: Order) => {
|
||
const updatedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
||
return calculateOrderProfit(updatedItems, order.total || 0);
|
||
};
|
||
|
||
// Sort orders by newest first, then apply filters
|
||
const sortedOrders = [...orders].sort((a, b) =>
|
||
new Date(b.dateOrdered).getTime() - new Date(a.dateOrdered).getTime()
|
||
);
|
||
|
||
const filteredOrders = sortedOrders.filter(order => {
|
||
const matchesSearch = order.orderNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
order.customer?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
order.customer?.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
||
let matchesDate = true;
|
||
if (dateRange !== 'all') {
|
||
const orderDate = new Date(order.dateOrdered);
|
||
const now = new Date();
|
||
|
||
// Debug logging
|
||
console.log('Filtering order:', {
|
||
orderNumber: order.orderNumber,
|
||
dateOrdered: order.dateOrdered,
|
||
parsedDate: orderDate,
|
||
now: now,
|
||
dateRange: dateRange
|
||
});
|
||
|
||
const timeDiff = now.getTime() - orderDate.getTime();
|
||
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
|
||
|
||
switch (dateRange) {
|
||
case 'week':
|
||
matchesDate = daysDiff >= 0 && daysDiff <= 7;
|
||
break;
|
||
case 'month':
|
||
matchesDate = daysDiff >= 0 && daysDiff <= 30;
|
||
break;
|
||
case 'quarter':
|
||
matchesDate = daysDiff >= 0 && daysDiff <= 90;
|
||
break;
|
||
case 'year':
|
||
matchesDate = daysDiff >= 0 && daysDiff <= 365;
|
||
break;
|
||
}
|
||
|
||
console.log('Date filter result:', { daysDiff, matchesDate });
|
||
}
|
||
|
||
return matchesSearch && matchesDate;
|
||
});
|
||
|
||
// Financial calculations based on filtered results
|
||
const totalPrintingCosts = filteredOrders.reduce((sum, order) => sum + getUpdatedPrintingCost(order), 0);
|
||
|
||
const handleExportCSV = () => {
|
||
const headers = [
|
||
'Order Number', 'Customer Name', 'Date', 'Revenue', 'Printing Cost', 'Profit', 'Items'
|
||
];
|
||
|
||
const csvData = filteredOrders.map(order => {
|
||
const printingCost = getUpdatedPrintingCost(order);
|
||
const profit = getUpdatedProfit(order);
|
||
|
||
return [
|
||
order.orderNumber,
|
||
order.customer?.name || '',
|
||
formatAustralianDate(order.dateOrdered),
|
||
`$${order.total.toFixed(2)}`,
|
||
`$${printingCost.toFixed(2)}`,
|
||
`$${profit.toFixed(2)}`,
|
||
order.items?.map(item => `${item.title} (${item.quantity})`).join('; ') || ''
|
||
];
|
||
});
|
||
|
||
const csv = [headers, ...csvData].map((row: string[]) =>
|
||
row.map((field: string) => `"${field}"`).join(',')
|
||
).join('\n');
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `etsy-orders-${new Date().toISOString().split('T')[0]}.csv`;
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
|
||
toast.success('Financial data exported successfully');
|
||
};
|
||
|
||
// Handler functions for CRUD operations
|
||
const handleEditOrder = (order: Order) => {
|
||
setEditingOrder({ ...order });
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
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);
|
||
};
|
||
|
||
const handleDeleteClick = (orderId: string) => {
|
||
setDeletingOrderId(orderId);
|
||
setShowDeleteConfirm(true);
|
||
};
|
||
|
||
const handleConfirmDelete = async () => {
|
||
if (deletingOrderId && deletingOrderId !== 'undefined') {
|
||
try {
|
||
await api.delete(`/orders/${deletingOrderId}`);
|
||
dispatch(deleteOrder(deletingOrderId));
|
||
toast.success('Order deleted successfully');
|
||
} catch {
|
||
toast.error('Failed to delete order');
|
||
}
|
||
} else {
|
||
toast.error('Cannot delete order: Invalid order ID');
|
||
}
|
||
setShowDeleteConfirm(false);
|
||
setDeletingOrderId(null);
|
||
};
|
||
|
||
const handleCancelDelete = () => {
|
||
setShowDeleteConfirm(false);
|
||
setDeletingOrderId(null);
|
||
};
|
||
|
||
// New product creation handlers
|
||
const handleCreateNewProduct = (productTitle: string) => {
|
||
setNewProductData({
|
||
title: productTitle,
|
||
price: 0,
|
||
printingCost: 0,
|
||
costOfGoods: 0
|
||
});
|
||
setShowNewProductModal(true);
|
||
};
|
||
|
||
const handleSaveNewProduct = async () => {
|
||
if (!newProductData.title) {
|
||
toast.error('Please enter a product title');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api.post('/products', {
|
||
title: newProductData.title,
|
||
description: '',
|
||
price: newProductData.price,
|
||
costOfGoods: newProductData.costOfGoods,
|
||
printingCost: newProductData.printingCost,
|
||
sku: '',
|
||
category: 'Other',
|
||
tags: [],
|
||
inventory: { quantity: 0, lowStockAlert: 10 },
|
||
isActive: true
|
||
});
|
||
dispatch(addProduct(res.data));
|
||
setShowNewProductModal(false);
|
||
toast.success('Product created successfully');
|
||
} catch {
|
||
toast.error('Failed to create product');
|
||
}
|
||
};
|
||
|
||
// Manual order handlers
|
||
const handleAddOrder = () => {
|
||
setNewOrder({
|
||
orderNumber: '',
|
||
customer: { name: '', email: '' },
|
||
dateOrdered: new Date().toISOString().split('T')[0],
|
||
total: 0,
|
||
items: [{ title: '', quantity: 1, price: 0 }]
|
||
});
|
||
setShowAddModal(true);
|
||
};
|
||
|
||
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);
|
||
const enrichedItems = newOrder.items
|
||
.filter(item => item.title.trim() !== '')
|
||
.map(item => {
|
||
const matchingProduct = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
|
||
return {
|
||
...item,
|
||
printingCost: matchingProduct?.printingCost || 0,
|
||
costOfGoods: matchingProduct?.costOfGoods || 0
|
||
};
|
||
});
|
||
|
||
try {
|
||
const res = await api.post('/orders', {
|
||
orderNumber: `FB-${newOrder.orderNumber}`,
|
||
customer: newOrder.customer,
|
||
dateOrdered: newOrder.dateOrdered,
|
||
total: calculatedTotal,
|
||
status: 'delivered',
|
||
paymentStatus: 'paid',
|
||
items: enrichedItems
|
||
});
|
||
dispatch(addOrder(res.data));
|
||
setShowAddModal(false);
|
||
toast.success('Facebook Marketplace order added successfully');
|
||
} catch {
|
||
toast.error('Failed to save order');
|
||
}
|
||
};
|
||
|
||
const handleAddItem = () => {
|
||
setNewOrder(prev => ({
|
||
...prev,
|
||
items: [...prev.items, { title: '', quantity: 1, price: 0 }]
|
||
}));
|
||
};
|
||
|
||
const handleRemoveItem = (index: number) => {
|
||
if (newOrder.items.length > 1) {
|
||
setNewOrder(prev => ({
|
||
...prev,
|
||
items: prev.items.filter((_, i) => i !== index)
|
||
}));
|
||
}
|
||
};
|
||
|
||
const updateNewOrderItem = (index: number, field: string, value: any) => {
|
||
setNewOrder(prev => ({
|
||
...prev,
|
||
items: prev.items.map((item, i) =>
|
||
i === index ? { ...item, [field]: value } : item
|
||
)
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="p-6 max-w-7xl mx-auto">
|
||
<div className="flex justify-between items-center mb-6">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900">Financial Overview</h1>
|
||
<p className="text-gray-600 mt-1">Track revenue, costs, and profit from your Etsy orders</p>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleAddOrder}
|
||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add Manual Order
|
||
</button>
|
||
<button
|
||
onClick={handleExportCSV}
|
||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
Export Financial Data
|
||
</button>
|
||
<a
|
||
href="/data-import"
|
||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
>
|
||
<ArrowRight className="w-4 h-4" />
|
||
Import Data
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Financial Summary Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-1 max-w-md gap-6 mb-6">
|
||
<div className="bg-white p-6 rounded-lg shadow">
|
||
<div className="flex items-center">
|
||
<div className="p-3 rounded-full bg-orange-100">
|
||
<Package className="w-6 h-6 text-orange-600" />
|
||
</div>
|
||
<div className="ml-4">
|
||
<p className="text-sm font-medium text-gray-600">Total Printing Costs</p>
|
||
<p className="text-2xl font-bold text-gray-900">${totalPrintingCosts.toFixed(2)}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Import Instructions */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||
<h3 className="text-blue-800 font-semibold mb-2">💡 Need to import order data?</h3>
|
||
<p className="text-blue-700 text-sm">
|
||
Use the <a href="/data-import" className="font-semibold underline">Data Import</a> page to upload:
|
||
<span className="ml-2">• Etsy CSV statements</span>
|
||
<span className="ml-2">• Australia Post shipping data</span>
|
||
<span className="ml-2">• PDF packing slips</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Search and Filters */}
|
||
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||
<div className="flex flex-wrap gap-4 items-center">
|
||
<div className="relative flex-1 min-w-64">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search by order number or customer..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<select
|
||
value={dateRange}
|
||
onChange={(e) => setDateRange(e.target.value)}
|
||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
>
|
||
<option value="all">All Time</option>
|
||
<option value="week">Last Week</option>
|
||
<option value="month">Last Month</option>
|
||
<option value="quarter">Last Quarter</option>
|
||
<option value="year">Last Year</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Financial Data Table */}
|
||
<div className="grid gap-6">
|
||
{filteredOrders.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 mb-2">No orders found</h3>
|
||
<p className="text-gray-500 mb-4">Import your sales data using the Data Import page.</p>
|
||
<div className="flex gap-3 justify-center">
|
||
<a
|
||
href="/data-import"
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
>
|
||
<ArrowRight className="w-4 h-4" />
|
||
Go to Data Import
|
||
</a>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
<div className="flex items-center gap-1">
|
||
Order Details
|
||
<span className="text-xs text-gray-400">(↓ newest first)</span>
|
||
</div>
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Customer
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Items
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Revenue
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Printing Cost
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Profit
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Margin %
|
||
</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{filteredOrders.map((order) => {
|
||
const printingCost = getUpdatedPrintingCost(order);
|
||
const profit = getUpdatedProfit(order);
|
||
const margin = order.total > 0 ? ((profit / order.total) * 100) : 0;
|
||
|
||
// Use order number as fallback key if _id is undefined
|
||
const orderKey = order._id || `fallback-${order.orderNumber}`;
|
||
|
||
return (
|
||
<tr key={orderKey} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div>
|
||
<div className="text-sm font-medium text-gray-900">
|
||
#{order.orderNumber}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
{formatAustralianDate(order.dateOrdered)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm font-medium text-gray-900">
|
||
{order.customer?.name || 'Unknown'}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="text-sm text-gray-900">
|
||
{order.items && order.items.length > 0 ? (
|
||
order.items.map((item, index) => (
|
||
<div key={index} className="mb-1">
|
||
<span className="font-medium">{item.title}</span>
|
||
<span className="text-gray-500 ml-2">× {item.quantity}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<span className="text-gray-500 italic">No items listed</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||
${order.total.toFixed(2)}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||
${printingCost.toFixed(2)}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||
<span className={profit >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||
${profit.toFixed(2)}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||
<span className={`font-medium ${margin >= 20 ? 'text-green-600' : margin >= 10 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||
{margin.toFixed(1)}%
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<div className="flex justify-end space-x-2">
|
||
<button
|
||
onClick={() => handleEditOrder(order)}
|
||
className="text-indigo-600 hover:text-indigo-900 transition-colors"
|
||
title="Edit Order"
|
||
>
|
||
<Edit className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => order._id ? handleDeleteClick(order._id) : toast.error('Cannot delete order: Missing ID')}
|
||
className={`transition-colors ${
|
||
order._id
|
||
? 'text-red-600 hover:text-red-900'
|
||
: 'text-gray-400 cursor-not-allowed'
|
||
}`}
|
||
title={order._id ? "Delete Order" : "Cannot delete: Missing ID"}
|
||
disabled={!order._id}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit Order Modal */}
|
||
{showEditModal && editingOrder && (
|
||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||
<div className="mt-3">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-medium text-gray-900">Edit Order</h3>
|
||
<button
|
||
onClick={() => setShowEditModal(false)}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Order Number</label>
|
||
<input
|
||
type="text"
|
||
value={editingOrder.orderNumber}
|
||
onChange={(e) => setEditingOrder({...editingOrder, orderNumber: e.target.value})}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Customer Name</label>
|
||
<input
|
||
type="text"
|
||
value={editingOrder.customer?.name || ''}
|
||
onChange={(e) => setEditingOrder({
|
||
...editingOrder,
|
||
customer: { ...editingOrder.customer!, name: e.target.value }
|
||
})}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Total Amount</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={editingOrder.total}
|
||
onChange={(e) => setEditingOrder({...editingOrder, total: parseFloat(e.target.value) || 0})}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Status</label>
|
||
<select
|
||
value={editingOrder.status}
|
||
onChange={(e) => setEditingOrder({...editingOrder, status: e.target.value as Order['status']})}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||
>
|
||
<option value="pending">Pending</option>
|
||
<option value="processing">Processing</option>
|
||
<option value="shipped">Shipped</option>
|
||
<option value="delivered">Delivered</option>
|
||
<option value="cancelled">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Order Date</label>
|
||
<input
|
||
type="date"
|
||
value={editingOrder.dateOrdered ? new Date(editingOrder.dateOrdered).toISOString().split('T')[0] : ''}
|
||
onChange={(e) => setEditingOrder({...editingOrder, dateOrdered: new Date(e.target.value).toISOString()})}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end space-x-3 mt-6">
|
||
<button
|
||
onClick={() => setShowEditModal(false)}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => handleSaveOrder(editingOrder)}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||
>
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete Confirmation Modal */}
|
||
{showDeleteConfirm && (
|
||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||
<div className="mt-3 text-center">
|
||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||
<Trash2 className="h-6 w-6 text-red-600" />
|
||
</div>
|
||
<h3 className="text-lg font-medium text-gray-900 mt-2">Delete Order</h3>
|
||
<div className="mt-2 px-7 py-3">
|
||
<p className="text-sm text-gray-500">
|
||
Are you sure you want to delete this order? This action cannot be undone.
|
||
</p>
|
||
</div>
|
||
<div className="flex justify-center space-x-3 mt-4">
|
||
<button
|
||
onClick={handleCancelDelete}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleConfirmDelete}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Manual Order Creation Modal */}
|
||
{showAddModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white p-6 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||
<div className="flex justify-between items-center mb-6">
|
||
<h2 className="text-2xl font-bold text-gray-900">Add Facebook Marketplace Order</h2>
|
||
<button
|
||
onClick={() => setShowAddModal(false)}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||
{/* Order Details */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Order Number</label>
|
||
<input
|
||
type="text"
|
||
value={newOrder.orderNumber}
|
||
onChange={(e) => setNewOrder({...newOrder, orderNumber: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="FB-12345"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Order Date</label>
|
||
<input
|
||
type="date"
|
||
value={newOrder.dateOrdered}
|
||
onChange={(e) => setNewOrder({...newOrder, dateOrdered: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Customer Name</label>
|
||
<input
|
||
type="text"
|
||
value={newOrder.customer.name}
|
||
onChange={(e) => setNewOrder({...newOrder, customer: {...newOrder.customer, name: e.target.value}})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Customer Name"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Customer Email (optional)</label>
|
||
<input
|
||
type="email"
|
||
value={newOrder.customer.email}
|
||
onChange={(e) => setNewOrder({...newOrder, customer: {...newOrder.customer, email: e.target.value}})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="customer@email.com"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Order Items */}
|
||
<div className="mb-6">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h3 className="text-lg font-semibold text-gray-900">Order Items</h3>
|
||
<button
|
||
onClick={handleAddItem}
|
||
className="flex items-center gap-2 px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add Item
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{newOrder.items.map((item, index) => {
|
||
const matchingProduct = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
|
||
|
||
return (
|
||
<div key={`item-${index}-${item.title || 'empty'}`} className="p-3 bg-gray-50 rounded-lg">
|
||
<div className="flex gap-3 items-center mb-2">
|
||
<div className="flex-1">
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">Product</label>
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={item.title}
|
||
onChange={(e) => {
|
||
if (e.target.value === '__CREATE_NEW__') {
|
||
handleCreateNewProduct('');
|
||
} else {
|
||
const selectedProduct = products?.find(p => p.title === e.target.value);
|
||
updateNewOrderItem(index, 'title', e.target.value);
|
||
if (selectedProduct) {
|
||
// Auto-fill price if available
|
||
if (selectedProduct.price && selectedProduct.price > 0) {
|
||
updateNewOrderItem(index, 'price', selectedProduct.price);
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="">Select a product...</option>
|
||
{products?.map(product => (
|
||
<option key={product._id} value={product.title}>
|
||
{product.title} {product.price ? `($${product.price.toFixed(2)})` : ''}
|
||
</option>
|
||
))}
|
||
<option value="__CREATE_NEW__">+ Create New Product</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="w-24">
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">Qty</label>
|
||
<input
|
||
type="number"
|
||
value={item.quantity}
|
||
onChange={(e) => updateNewOrderItem(index, 'quantity', parseInt(e.target.value) || 1)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-center"
|
||
min="1"
|
||
/>
|
||
</div>
|
||
<div className="w-28">
|
||
<label className="block text-xs font-medium text-gray-600 mb-1">Sale Price</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={item.price}
|
||
onChange={(e) => updateNewOrderItem(index, 'price', parseFloat(e.target.value) || 0)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="0.00"
|
||
/>
|
||
</div>
|
||
{newOrder.items.length > 1 && (
|
||
<button
|
||
onClick={() => handleRemoveItem(index)}
|
||
className="text-red-600 hover:text-red-700 ml-2"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Show printing cost info if product is selected */}
|
||
{matchingProduct && (
|
||
<div className="mt-2 p-2 bg-blue-50 rounded text-sm text-blue-700">
|
||
<span className="font-medium">Printing Cost: ${matchingProduct.printingCost?.toFixed(2) || '0.00'}</span>
|
||
<span className="ml-4">Profit per item: ${(item.price - (matchingProduct.printingCost || 0)).toFixed(2)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Total Display with Printing Cost Breakdown */}
|
||
<div className="mt-4 space-y-3">
|
||
<div className="p-3 bg-blue-50 rounded-lg">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-semibold text-gray-700">Order Total (Revenue):</span>
|
||
<span className="text-xl font-bold text-blue-600">
|
||
${newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-3 bg-orange-50 rounded-lg">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-semibold text-gray-700">Total Printing Cost:</span>
|
||
<span className="text-lg font-bold text-orange-600">
|
||
${newOrder.items.reduce((sum, item) => {
|
||
const product = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
|
||
return sum + (item.quantity * (product?.printingCost || 0));
|
||
}, 0).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-3 bg-green-50 rounded-lg">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-semibold text-gray-700">Estimated Profit:</span>
|
||
<span className="text-xl font-bold text-green-600">
|
||
${(newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0) -
|
||
newOrder.items.reduce((sum, item) => {
|
||
const product = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
|
||
return sum + (item.quantity * (product?.printingCost || 0));
|
||
}, 0)).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setShowAddModal(false)}
|
||
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSaveNewOrder}
|
||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||
>
|
||
Save Order
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* New Product Creation Modal */}
|
||
{showNewProductModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||
<div className="flex justify-between items-center mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">Create New Product</h2>
|
||
<button
|
||
onClick={() => setShowNewProductModal(false)}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Product Name</label>
|
||
<input
|
||
type="text"
|
||
value={newProductData.title}
|
||
onChange={(e) => setNewProductData({...newProductData, title: e.target.value})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Enter product name"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Sale Price</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={newProductData.price}
|
||
onChange={(e) => setNewProductData({...newProductData, price: parseFloat(e.target.value) || 0})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="0.00"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Printing Cost</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={newProductData.printingCost}
|
||
onChange={(e) => setNewProductData({...newProductData, printingCost: parseFloat(e.target.value) || 0})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="0.00"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Cost of Goods (optional)</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={newProductData.costOfGoods}
|
||
onChange={(e) => setNewProductData({...newProductData, costOfGoods: parseFloat(e.target.value) || 0})}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
placeholder="0.00"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 mt-6">
|
||
<button
|
||
onClick={() => setShowNewProductModal(false)}
|
||
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSaveNewProduct}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||
>
|
||
Create Product
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Orders; |