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>
220 lines
8.0 KiB
JavaScript
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>
|
|
</>
|
|
);
|
|
}
|