adamp 1ed2642e20 Add restock history tracking, payment tracking, and reports page
- New stock_adjustments table logs every stock change (restock, order
  create/update/delete) with reason and reference
- Orders now track payment_method and amount_paid with validation
- New /api/reports endpoint with 5 aggregation queries and date filtering
- Reports page with date range presets and sales, customer, revenue,
  status, and inventory sections
- Payment fields added to OrderNew and OrderDetail pages with balance due
- Girl Scouts trefoil logo added to header
- Vite dev server exposed on network for mobile access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:20:59 -06:00

206 lines
8.5 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
function getOrderWithItems(db, id) {
const order = db.prepare(`
SELECT o.*, c.name as customer_name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.id = ?
`).get(id);
if (!order) return null;
const items = db.prepare(`
SELECT oi.*, p.name as product_name
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ?
`).all(id);
return { ...order, items };
}
function atomicDeductStock(db, productId, quantity) {
const result = db.prepare(
'UPDATE products SET quantity_on_hand = quantity_on_hand - ? WHERE id = ? AND quantity_on_hand >= ?'
).run(quantity, productId, quantity);
if (result.changes === 0) {
const product = db.prepare('SELECT id FROM products WHERE id = ?').get(productId);
if (!product) throw new Error(`Product ${productId} not found`);
throw new Error(`Insufficient stock for product id ${productId}`);
}
}
function applyOrderItems(db, orderId, items) {
db.prepare('DELETE FROM order_items WHERE order_id = ?').run(orderId);
const productRows = db.prepare('SELECT id, price FROM products').all();
const priceByProduct = {};
for (const p of productRows) priceByProduct[p.id] = p.price;
for (const it of items) {
const price = it.price_at_sale != null ? it.price_at_sale : (priceByProduct[it.product_id] || 0);
db.prepare(
'INSERT INTO order_items (order_id, product_id, quantity, price_at_sale) VALUES (?, ?, ?, ?)'
).run(orderId, it.product_id, it.quantity, price);
}
}
router.get('/', (req, res) => {
try {
const db = getDb();
const rows = db.prepare(`
SELECT o.*, c.name as customer_name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC
`).all();
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/:id', (req, res) => {
try {
const db = getDb();
const order = getOrderWithItems(db, req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
res.json(order);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/', (req, res) => {
try {
const db = getDb();
const { customer_id, walk_in_name, status = 'pending', notes, items = [], payment_method, amount_paid } = req.body;
const validPaymentMethods = ['cash', 'card', 'venmo', 'check', 'other'];
if (payment_method && !validPaymentMethods.includes(payment_method)) {
return res.status(400).json({ error: `Invalid payment method. Must be one of: ${validPaymentMethods.join(', ')}` });
}
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'At least one order item is required' });
}
for (const it of items) {
const product = db.prepare('SELECT id FROM products WHERE id = ?').get(it.product_id);
if (!product) return res.status(400).json({ error: `Product ${it.product_id} not found` });
const qty = Number(it.quantity) || 0;
if (qty <= 0) return res.status(400).json({ error: 'Quantity must be positive' });
}
const createOrder = db.transaction(() => {
let resolvedCustomerId = customer_id || null;
if (!resolvedCustomerId && typeof walk_in_name === 'string' && walk_in_name.trim()) {
const custResult = db.prepare('INSERT INTO customers (name) VALUES (?)').run(walk_in_name.trim());
resolvedCustomerId = custResult.lastInsertRowid;
}
const result = db.prepare(
'INSERT INTO orders (customer_id, status, notes, payment_method, amount_paid) VALUES (?, ?, ?, ?, ?)'
).run(resolvedCustomerId, status, notes || null, payment_method || null, Number(amount_paid) || 0);
const orderId = result.lastInsertRowid;
for (const it of items) {
const qty = Number(it.quantity) || 0;
atomicDeductStock(db, it.product_id, qty);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(it.product_id, -qty, 'order_created', orderId);
}
applyOrderItems(db, orderId, items);
db.prepare('UPDATE orders SET updated_at = datetime(\'now\') WHERE id = ?').run(orderId);
return orderId;
});
const orderId = createOrder();
const order = getOrderWithItems(db, orderId);
res.status(201).json(order);
} catch (err) {
if (err.message.startsWith('Insufficient stock') || err.message.includes('not found')) {
return res.status(400).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
router.put('/:id', (req, res) => {
try {
const db = getDb();
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
const { customer_id, status, notes, items, payment_method, amount_paid } = req.body;
const validPaymentMethods = ['cash', 'card', 'venmo', 'check', 'other'];
if (payment_method && !validPaymentMethods.includes(payment_method)) {
return res.status(400).json({ error: `Invalid payment method. Must be one of: ${validPaymentMethods.join(', ')}` });
}
const updateOrder = db.transaction(() => {
if (items !== undefined) {
if (!Array.isArray(items)) throw new Error('items must be an array');
// Restore stock from existing items
const existingItems = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(req.params.id);
for (const ei of existingItems) {
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
.run(ei.quantity, ei.product_id);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(ei.product_id, ei.quantity, 'order_updated', req.params.id);
}
// Deduct stock for new items atomically
for (const it of items) {
const qty = Number(it.quantity) || 0;
if (qty <= 0) throw new Error('Quantity must be positive');
atomicDeductStock(db, it.product_id, qty);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(it.product_id, -qty, 'order_updated', req.params.id);
}
applyOrderItems(db, req.params.id, items);
}
const cid = customer_id !== undefined ? (customer_id || null) : order.customer_id;
const st = status !== undefined ? status : order.status;
const no = notes !== undefined ? notes : order.notes;
const pm = payment_method !== undefined ? (payment_method || null) : order.payment_method;
const ap = amount_paid !== undefined ? Number(amount_paid) : order.amount_paid;
db.prepare('UPDATE orders SET customer_id = ?, status = ?, notes = ?, payment_method = ?, amount_paid = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(cid, st, no, pm, ap, req.params.id);
});
updateOrder();
const updated = getOrderWithItems(db, req.params.id);
res.json(updated);
} catch (err) {
if (err.message.startsWith('Insufficient stock') || err.message.includes('not found') || err.message === 'items must be an array' || err.message === 'Quantity must be positive') {
return res.status(400).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
router.delete('/:id', (req, res) => {
try {
const db = getDb();
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id);
if (!order) return res.status(404).json({ error: 'Order not found' });
const deleteOrder = db.transaction(() => {
const items = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(req.params.id);
for (const it of items) {
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
.run(it.quantity, it.product_id);
db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason, reference_id) VALUES (?, ?, ?, ?)')
.run(it.product_id, it.quantity, 'order_deleted', req.params.id);
}
db.prepare('DELETE FROM order_items WHERE order_id = ?').run(req.params.id);
db.prepare('DELETE FROM orders WHERE id = ?').run(req.params.id);
});
deleteOrder();
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;