P0 fixes: - Fix OrderDetail product change overwriting product_id due to React state batching (single setItems call now updates both fields) - Validate all :id route params via parseId helper; return 400 for invalid IDs instead of passing raw strings to SQLite - Product/customer delete now checks for references first, returns 409 Conflict instead of letting FK constraint produce 500 P1 fixes: - Disallow quantity_on_hand in product PUT so all stock changes go through PATCH /stock (preserves audit trail) - Add global Express error handler and unhandledRejection listener P2 fixes: - Validate report date params (YYYY-MM-DD format) and stock-history limit (positive integer, capped at 1000) - Add jsonSafe() helper to api.js for safe 204 handling - OrderNew setSubmitting now runs in finally block - Login shows specific message for 429 rate limit, generic message for other auth failures P3 fixes: - Replace brittle try/catch ALTER TABLE with schema_version migration table and versioned migrations - Fix OrderDetail useEffect missing dependency (useCallback + [load]) Also: expanded README with full production deployment instructions (PM2, nginx, backups) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
9.4 KiB
JavaScript
235 lines
9.4 KiB
JavaScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { api } from '../api';
|
|
|
|
export default function OrderDetail() {
|
|
const { id } = useParams();
|
|
const [order, setOrder] = useState(null);
|
|
const [products, setProducts] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [editing, setEditing] = useState(false);
|
|
const [status, setStatus] = useState('pending');
|
|
const [notes, setNotes] = useState('');
|
|
const [paymentMethod, setPaymentMethod] = useState('');
|
|
const [amountPaid, setAmountPaid] = useState('');
|
|
const [items, setItems] = useState([]);
|
|
|
|
const load = useCallback(() => {
|
|
Promise.all([
|
|
api(`/orders/${id}`).then((r) => (r.ok ? r.json() : Promise.reject(new Error('Not found')))),
|
|
api('/products').then((r) => r.json()),
|
|
])
|
|
.then(([o, p]) => {
|
|
setOrder(o);
|
|
setProducts(p);
|
|
setStatus(o.status);
|
|
setNotes(o.notes || '');
|
|
setPaymentMethod(o.payment_method || '');
|
|
setAmountPaid(o.amount_paid != null ? String(o.amount_paid) : '0');
|
|
setItems(o.items?.map((i) => ({ product_id: i.product_id, quantity: i.quantity, price_at_sale: i.price_at_sale })) || []);
|
|
})
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
function handleUpdate(e) {
|
|
e.preventDefault();
|
|
const payload = {
|
|
status,
|
|
payment_method: paymentMethod || null,
|
|
amount_paid: amountPaid !== '' ? Number(amountPaid) : 0,
|
|
notes: notes.trim() || null,
|
|
items: items.map((line) => ({
|
|
product_id: line.product_id,
|
|
quantity: Number(line.quantity) || 0,
|
|
price_at_sale: line.price_at_sale,
|
|
})).filter((line) => line.quantity > 0),
|
|
};
|
|
if (payload.items.length === 0) {
|
|
setError('Order must have at least one item.');
|
|
return;
|
|
}
|
|
api(`/orders/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
.then((r) => {
|
|
if (!r.ok) return r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')));
|
|
return r.json();
|
|
})
|
|
.then(setOrder)
|
|
.then(() => setEditing(false))
|
|
.then(load)
|
|
.catch((e) => setError(e.message));
|
|
}
|
|
|
|
function addLine() {
|
|
const productId = products[0]?.id;
|
|
if (!productId) return;
|
|
const p = products.find((x) => x.id === productId);
|
|
setItems((prev) => [...prev, { product_id: productId, quantity: 1, price_at_sale: p?.price }]);
|
|
}
|
|
|
|
function updateLine(index, field, value) {
|
|
setItems((prev) => prev.map((line, i) => (i === index ? { ...line, [field]: value } : line)));
|
|
}
|
|
|
|
function removeLine(index) {
|
|
setItems((prev) => prev.filter((_, i) => i !== index));
|
|
}
|
|
|
|
if (loading) return <p className="text-muted">Loading...</p>;
|
|
if (error && !order) return <p className="error">{error}</p>;
|
|
if (!order) return null;
|
|
|
|
const productById = Object.fromEntries(products.map((p) => [p.id, p]));
|
|
let total = 0;
|
|
for (const line of items) {
|
|
const price = line.price_at_sale != null ? line.price_at_sale : productById[line.product_id]?.price;
|
|
total += (price || 0) * (Number(line.quantity) || 0);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h1 className="page-title">Order #{order.id}</h1>
|
|
{error && <p className="error">{error}</p>}
|
|
|
|
{!editing ? (
|
|
<div className="card">
|
|
<p><strong>Customer:</strong> {order.customer_id ? (order.customer_name || `#${order.customer_id}`) : 'Walk-in'}</p>
|
|
<p><strong>Status:</strong> {order.status}</p>
|
|
<p><strong>Created:</strong> {new Date(order.created_at).toLocaleString()}</p>
|
|
{order.notes && <p><strong>Notes:</strong> {order.notes}</p>}
|
|
<p><strong>Payment:</strong> {order.payment_method ? order.payment_method.charAt(0).toUpperCase() + order.payment_method.slice(1) : 'Not set'}</p>
|
|
<p><strong>Amount paid:</strong> ${Number(order.amount_paid || 0).toFixed(2)}</p>
|
|
{(() => {
|
|
const orderTotal = (order.items || []).reduce((s, i) => s + i.quantity * i.price_at_sale, 0);
|
|
const balance = orderTotal - (order.amount_paid || 0);
|
|
return balance > 0.005 ? <p style={{ color: 'var(--danger, #e53e3e)', fontWeight: 600 }}>Balance due: ${balance.toFixed(2)}</p> : null;
|
|
})()}
|
|
<div className="table-wrap" style={{ marginTop: '1rem' }}>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Product</th>
|
|
<th>Qty</th>
|
|
<th>Price</th>
|
|
<th>Subtotal</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(order.items || []).map((line) => (
|
|
<tr key={line.id}>
|
|
<td>{line.product_name}</td>
|
|
<td>{line.quantity}</td>
|
|
<td>${Number(line.price_at_sale).toFixed(2)}</td>
|
|
<td>${(line.quantity * line.price_at_sale).toFixed(2)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p style={{ fontWeight: 600, marginTop: '1rem' }}>
|
|
Total: ${(order.items || []).reduce((s, i) => s + i.quantity * i.price_at_sale, 0).toFixed(2)}
|
|
</p>
|
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
|
|
<button type="button" className="secondary" onClick={() => setEditing(true)}>Edit</button>
|
|
<Link to="/orders"><button type="button" className="secondary">Back to orders</button></Link>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleUpdate} className="card">
|
|
<div className="form-group">
|
|
<label>Status</label>
|
|
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
<option value="pending">Pending</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="delivered">Delivered</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Notes</label>
|
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
|
</div>
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label>Payment method</label>
|
|
<select value={paymentMethod} onChange={(e) => {
|
|
setPaymentMethod(e.target.value);
|
|
if (e.target.value && amountPaid === '0') setAmountPaid(total.toFixed(2));
|
|
}}>
|
|
<option value="">None</option>
|
|
<option value="cash">Cash</option>
|
|
<option value="card">Card</option>
|
|
<option value="venmo">Venmo</option>
|
|
<option value="check">Check</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Amount paid</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={amountPaid}
|
|
onChange={(e) => setAmountPaid(e.target.value)}
|
|
placeholder={total.toFixed(2)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="form-group">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
|
<label style={{ marginBottom: 0 }}>Items</label>
|
|
<button type="button" className="secondary" onClick={addLine}>Add line</button>
|
|
</div>
|
|
{items.map((line, i) => (
|
|
<div key={i} className="form-row" style={{ alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
|
<select
|
|
value={line.product_id}
|
|
onChange={(e) => {
|
|
const newId = Number(e.target.value);
|
|
const p = products.find((x) => x.id === newId);
|
|
setItems((prev) => prev.map((ln, idx) => idx === i ? { ...ln, product_id: newId, price_at_sale: p?.price ?? ln.price_at_sale } : ln));
|
|
}}
|
|
style={{ flex: 2, minWidth: 0 }}
|
|
>
|
|
{products.map((p) => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={line.quantity}
|
|
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={line.price_at_sale ?? ''}
|
|
onChange={(e) => updateLine(i, 'price_at_sale', parseFloat(e.target.value) || 0)}
|
|
style={{ width: 80 }}
|
|
placeholder="Price"
|
|
/>
|
|
<button type="button" className="secondary" onClick={() => removeLine(i)}>Remove</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p style={{ fontWeight: 600 }}>Total: ${total.toFixed(2)}</p>
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
|
<button type="submit">Save</button>
|
|
<button type="button" className="secondary" onClick={() => setEditing(false)}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</>
|
|
);
|
|
}
|