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

220 lines
8.0 KiB
JavaScript

import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api';
export default function OrderNew() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedCustomerId = searchParams.get('customer');
const [customers, setCustomers] = useState([]);
const [products, setProducts] = useState([]);
const [customerId, setCustomerId] = useState(preselectedCustomerId || '');
const [walkInName, setWalkInName] = useState('');
const [items, setItems] = useState([]);
const [status, setStatus] = useState('pending');
const [paymentMethod, setPaymentMethod] = useState('');
const [amountPaid, setAmountPaid] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
Promise.all([
api('/customers').then((r) => r.json()),
api('/products').then((r) => r.json()),
])
.then(([c, p]) => {
setCustomers(c);
setProducts(p);
if (preselectedCustomerId) setCustomerId(preselectedCustomerId);
})
.catch(() => setError('Failed to load data'))
.finally(() => setLoading(false));
}, [preselectedCustomerId]);
function addLine() {
const productId = products[0]?.id;
if (!productId) return;
setItems((prev) => [...prev, { product_id: productId, quantity: 1 }]);
}
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));
}
const productById = Object.fromEntries(products.map((p) => [p.id, p]));
let total = 0;
for (const line of items) {
const p = productById[line.product_id];
if (p) total += p.price * (Number(line.quantity) || 0);
}
function handleSubmit(e) {
e.preventDefault();
if (items.length === 0 || products.length === 0) {
setError(items.length === 0 ? 'Add at least one item.' : 'No products in inventory. Add products first.');
return;
}
const payload = {
customer_id: customerId ? Number(customerId) : null,
walk_in_name: !customerId && walkInName.trim() ? walkInName.trim() : undefined,
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,
})).filter((line) => line.quantity > 0),
};
if (payload.items.length === 0) {
setError('Add at least one item with quantity > 0.');
return;
}
setSubmitting(true);
setError(null);
api('/orders', {
method: 'POST',
body: payload,
})
.then((r) => {
if (!r.ok) return r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')));
return r.json();
})
.then((order) => navigate(`/orders/${order.id}`))
.catch((e) => setError(e.message))
.finally(() => setSubmitting(false));
}
if (loading) return <p className="text-muted">Loading...</p>;
return (
<>
<h1 className="page-title">New order</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit} className="card" style={{ maxWidth: 640 }}>
<div className="form-group">
<label>Customer</label>
<select
value={customerId}
onChange={(e) => { setCustomerId(e.target.value); setWalkInName(''); }}
>
<option value="">Walk-in</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{customerId === '' && (
<div className="form-group">
<label>Name (optional)</label>
<input
type="text"
value={walkInName}
onChange={(e) => setWalkInName(e.target.value)}
placeholder="Walk-in customer name"
/>
</div>
)}
<div className="form-row">
<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>Payment method</label>
<select value={paymentMethod} onChange={(e) => {
setPaymentMethod(e.target.value);
if (e.target.value && amountPaid === '') 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>
<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)}
style={{ maxWidth: 200 }}
/>
</div>
<div className="form-group">
<label>Notes</label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
</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} disabled={products.length === 0}>Add line</button>
</div>
{items.length === 0 ? (
<p className="text-muted">No items. Click Add line to add products.</p>
) : (
<div className="order-lines">
{items.map((line, i) => (
<div key={i} className="order-line form-row" style={{ alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<select
value={line.product_id}
onChange={(e) => updateLine(i, 'product_id', Number(e.target.value))}
style={{ flex: 2, minWidth: 0 }}
>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ${Number(p.price).toFixed(2)} (stock: {p.quantity_on_hand})
</option>
))}
</select>
<input
type="number"
min="1"
value={line.quantity}
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
style={{ width: 80 }}
/>
<span className="text-muted">
${(productById[line.product_id]?.price || 0) * (Number(line.quantity) || 0)}
</span>
<button type="button" className="secondary" onClick={() => removeLine(i)}>Remove</button>
</div>
))}
</div>
)}
</div>
<p style={{ fontWeight: 600 }}>Total: ${total.toFixed(2)}</p>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit" disabled={submitting || items.length === 0}>
{submitting ? 'Creating…' : 'Create order'}
</button>
<button type="button" className="secondary" onClick={() => navigate('/orders')}>
Cancel
</button>
</div>
</form>
</>
);
}