cookie-tracker/client/src/pages/OrderDetail.jsx
adamp 7068ea354e Fix bugs, harden validation, and improve robustness
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>
2026-02-09 21:35:53 -06:00

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>
)}
</>
);
}