diff --git a/client/src/pages/Reports.jsx b/client/src/pages/Reports.jsx
new file mode 100644
index 0000000..87e5950
--- /dev/null
+++ b/client/src/pages/Reports.jsx
@@ -0,0 +1,288 @@
+import { useEffect, useState } from 'react';
+import { api } from '../api';
+
+function getWeekStart() {
+ const d = new Date();
+ d.setDate(d.getDate() - d.getDay());
+ return d.toISOString().slice(0, 10);
+}
+
+function getMonthStart() {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
+}
+
+function getToday() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+const statusColors = {
+ pending: '#ecc94b',
+ paid: '#48bb78',
+ delivered: '#4299e1',
+};
+
+export default function Reports() {
+ const [preset, setPreset] = useState('all');
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ function getDates() {
+ switch (preset) {
+ case 'week': return { start: getWeekStart(), end: getToday() };
+ case 'month': return { start: getMonthStart(), end: getToday() };
+ case 'custom': return { start: startDate, end: endDate };
+ default: return {};
+ }
+ }
+
+ useEffect(() => {
+ setLoading(true);
+ const { start, end } = getDates();
+ const params = new URLSearchParams();
+ if (start) params.set('start', start);
+ if (end) params.set('end', end);
+ const qs = params.toString();
+ api(`/reports${qs ? '?' + qs : ''}`)
+ .then((r) => {
+ if (!r.ok) return r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')));
+ return r.json();
+ })
+ .then(setData)
+ .catch((e) => setError(e.message))
+ .finally(() => setLoading(false));
+ }, [preset, startDate, endDate]);
+
+ return (
+ <>
+
Reports
+
+
+
+
+
+
+
+ {preset === 'custom' && (
+ <>
+
+
+ setStartDate(e.target.value)} />
+
+
+
+ setEndDate(e.target.value)} />
+
+ >
+ )}
+
+
+
+ {error &&
{error}
}
+ {loading &&
Loading reports...
}
+
+ {data && !loading && (
+ <>
+ {/* Sales by Product */}
+
+ Sales by Product
+
+
+
+
+ | Product |
+ Units sold |
+ Revenue |
+
+
+
+ {data.salesByProduct.map((row) => (
+
+ | {row.product_name} |
+ {row.units_sold} |
+ ${Number(row.revenue).toFixed(2)} |
+
+ ))}
+
+
+
+ | Total |
+ {data.salesByProduct.reduce((s, r) => s + r.units_sold, 0)} |
+ ${data.salesByProduct.reduce((s, r) => s + r.revenue, 0).toFixed(2)} |
+
+
+
+
+
+
+ {/* Top Customers */}
+
+ Top Customers
+
+
+
+
+ | Customer |
+ Orders |
+ Total spent |
+
+
+
+ {data.topCustomers.map((row) => (
+
+ | {row.customer_name} |
+ {row.order_count} |
+ ${Number(row.total_spent).toFixed(2)} |
+
+ ))}
+ {data.topCustomers.length === 0 && (
+ | No customer data |
+ )}
+
+
+
+
+
+ {/* Revenue Over Time */}
+
+ Revenue Over Time
+
+
+
+
+ | Date |
+ Orders |
+ Revenue |
+ |
+
+
+
+ {(() => {
+ const maxRevenue = Math.max(...data.revenueOverTime.map((r) => r.revenue), 1);
+ return data.revenueOverTime.map((row) => (
+
+ | {row.date} |
+ {row.order_count} |
+ ${Number(row.revenue).toFixed(2)} |
+
+ 0 ? 4 : 0,
+ }} />
+ |
+
+ ));
+ })()}
+ {data.revenueOverTime.length === 0 && (
+ | No revenue data |
+ )}
+
+
+
+
+
+ {/* Order Status Breakdown */}
+
+ Order Status Breakdown
+ {(() => {
+ const totalOrders = data.orderStatusBreakdown.reduce((s, r) => s + r.count, 0);
+ return (
+ <>
+ {totalOrders > 0 && (
+
+ {data.orderStatusBreakdown.map((row) => (
+
+ {(row.count / totalOrders) >= 0.1 ? row.status : ''}
+
+ ))}
+
+ )}
+
+
+
+
+ | Status |
+ Count |
+
+
+
+ {data.orderStatusBreakdown.map((row) => (
+
+ | {row.status} |
+ {row.count} |
+
+ ))}
+ {data.orderStatusBreakdown.length === 0 && (
+ | No orders |
+ )}
+
+
+
+ >
+ );
+ })()}
+
+
+ {/* Inventory Summary */}
+
+ Inventory Summary
+
+
+
+
+ | Product |
+ Current stock |
+ Total restocked |
+ Total sold |
+ Restock count |
+
+
+
+ {data.inventorySummary.map((row) => {
+ const lowStock = row.low_stock_threshold > 0 && row.current_stock <= row.low_stock_threshold;
+ return (
+
+ | {row.product_name} |
+
+ {row.current_stock}
+ {lowStock && ' (low)'}
+ |
+ {row.total_restocked} |
+ {row.total_sold} |
+ {row.restock_count} |
+
+ );
+ })}
+
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/client/vite.config.js b/client/vite.config.js
index 66477e8..b7d47fd 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
+ host: true,
port: 5173,
proxy: {
'/api': {
diff --git a/server/db.js b/server/db.js
index 5c470ac..4c3fe2a 100644
--- a/server/db.js
+++ b/server/db.js
@@ -57,7 +57,19 @@ function initSchema(database) {
price_at_sale REAL NOT NULL,
UNIQUE(order_id, product_id)
);
+
+ CREATE TABLE IF NOT EXISTS stock_adjustments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ product_id INTEGER NOT NULL REFERENCES products(id),
+ adjustment INTEGER NOT NULL,
+ reason TEXT NOT NULL,
+ reference_id INTEGER,
+ created_at TEXT DEFAULT (datetime('now'))
+ );
`);
+
+ try { database.exec("ALTER TABLE orders ADD COLUMN payment_method TEXT"); } catch (e) {}
+ try { database.exec("ALTER TABLE orders ADD COLUMN amount_paid REAL NOT NULL DEFAULT 0"); } catch (e) {}
}
module.exports = { getDb, initSchema };
diff --git a/server/index.js b/server/index.js
index bd8d2a3..8749059 100644
--- a/server/index.js
+++ b/server/index.js
@@ -9,6 +9,7 @@ const productsRouter = require('./routes/products');
const customersRouter = require('./routes/customers');
const ordersRouter = require('./routes/orders');
const dashboardRouter = require('./routes/dashboard');
+const reportsRouter = require('./routes/reports');
const app = express();
const PORT = process.env.PORT || 3002;
@@ -29,6 +30,7 @@ app.use('/api/products', productsRouter);
app.use('/api/customers', customersRouter);
app.use('/api/orders', ordersRouter);
app.use('/api/dashboard', dashboardRouter);
+app.use('/api/reports', reportsRouter);
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'client', 'dist')));
diff --git a/server/routes/orders.js b/server/routes/orders.js
index 8ef1e41..448fa28 100644
--- a/server/routes/orders.js
+++ b/server/routes/orders.js
@@ -72,7 +72,11 @@ router.get('/:id', (req, res) => {
router.post('/', (req, res) => {
try {
const db = getDb();
- const { customer_id, walk_in_name, status = 'pending', notes, items = [] } = req.body;
+ 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' });
}
@@ -90,13 +94,15 @@ router.post('/', (req, res) => {
resolvedCustomerId = custResult.lastInsertRowid;
}
const result = db.prepare(
- 'INSERT INTO orders (customer_id, status, notes) VALUES (?, ?, ?)'
- ).run(resolvedCustomerId, status, notes || null);
+ '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);
@@ -120,7 +126,11 @@ router.put('/:id', (req, res) => {
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 } = req.body;
+ 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) {
@@ -131,6 +141,8 @@ router.put('/:id', (req, res) => {
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
@@ -138,6 +150,8 @@ router.put('/:id', (req, res) => {
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);
@@ -146,8 +160,10 @@ router.put('/:id', (req, res) => {
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;
- db.prepare('UPDATE orders SET customer_id = ?, status = ?, notes = ?, updated_at = datetime(\'now\') WHERE id = ?')
- .run(cid, st, no, req.params.id);
+ 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();
@@ -172,6 +188,8 @@ router.delete('/:id', (req, res) => {
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);
diff --git a/server/routes/products.js b/server/routes/products.js
index 66922f1..0cd3cf5 100644
--- a/server/routes/products.js
+++ b/server/routes/products.js
@@ -12,6 +12,29 @@ router.get('/', (req, res) => {
}
});
+router.get('/stock-history', (req, res) => {
+ try {
+ const db = getDb();
+ const { product_id, start, end, limit } = req.query;
+ let sql = `
+ SELECT sa.*, p.name as product_name
+ FROM stock_adjustments sa
+ JOIN products p ON p.id = sa.product_id
+ WHERE 1=1
+ `;
+ const params = [];
+ if (product_id) { sql += ' AND sa.product_id = ?'; params.push(product_id); }
+ if (start) { sql += ' AND sa.created_at >= ?'; params.push(start); }
+ if (end) { sql += ' AND sa.created_at <= ?'; params.push(end + ' 23:59:59'); }
+ sql += ' ORDER BY sa.created_at DESC';
+ if (limit) { sql += ' LIMIT ?'; params.push(Number(limit)); }
+ const rows = db.prepare(sql).all(...params);
+ res.json(rows);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
router.get('/:id', (req, res) => {
try {
const db = getDb();
@@ -76,6 +99,7 @@ router.patch('/:id/stock', (req, res) => {
return res.status(400).json({ error: 'Quantity would go negative' });
}
db.prepare('UPDATE products SET quantity_on_hand = ? WHERE id = ?').run(newQty, req.params.id);
+ db.prepare('INSERT INTO stock_adjustments (product_id, adjustment, reason) VALUES (?, ?, ?)').run(req.params.id, delta, 'restock');
const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
res.json(updated);
} catch (err) {
diff --git a/server/routes/reports.js b/server/routes/reports.js
new file mode 100644
index 0000000..cc84c44
--- /dev/null
+++ b/server/routes/reports.js
@@ -0,0 +1,95 @@
+const express = require('express');
+const router = express.Router();
+const { getDb } = require('../db');
+
+router.get('/', (req, res) => {
+ try {
+ const db = getDb();
+ const { start, end } = req.query;
+
+ const endWithTime = end ? end + ' 23:59:59' : null;
+
+ // Build date filter fragments and positional params for order-based queries
+ let dateFilter = '';
+ const orderParams = [];
+ if (start) { dateFilter += ' AND o.created_at >= ?'; orderParams.push(start); }
+ if (end) { dateFilter += ' AND o.created_at <= ?'; orderParams.push(endWithTime); }
+
+ // For stock adjustments
+ let dateFilterSA = '';
+ const saParams = [];
+ if (start) { dateFilterSA += ' AND sa.created_at >= ?'; saParams.push(start); }
+ if (end) { dateFilterSA += ' AND sa.created_at <= ?'; saParams.push(endWithTime); }
+
+ // For inventory summary, the SA params repeat 3 times (3 subqueries)
+ const invParams = [...saParams, ...saParams, ...saParams];
+
+ const salesByProduct = db.prepare(`
+ SELECT p.name as product_name,
+ COALESCE(SUM(oi.quantity), 0) as units_sold,
+ COALESCE(SUM(oi.quantity * oi.price_at_sale), 0) as revenue
+ FROM products p
+ LEFT JOIN (
+ SELECT oi.product_id, oi.quantity, oi.price_at_sale
+ FROM order_items oi
+ JOIN orders o ON o.id = oi.order_id
+ WHERE 1=1 ${dateFilter}
+ ) oi ON oi.product_id = p.id
+ GROUP BY p.id, p.name
+ ORDER BY revenue DESC
+ `).all(...orderParams);
+
+ const topCustomers = db.prepare(`
+ SELECT c.name as customer_name,
+ COUNT(DISTINCT o.id) as order_count,
+ COALESCE(SUM(oi.quantity * oi.price_at_sale), 0) as total_spent
+ FROM customers c
+ JOIN orders o ON o.customer_id = c.id ${dateFilter ? 'AND 1=1' + dateFilter : ''}
+ JOIN order_items oi ON oi.order_id = o.id
+ GROUP BY c.id, c.name
+ ORDER BY total_spent DESC
+ `).all(...orderParams);
+
+ const revenueOverTime = db.prepare(`
+ SELECT date(o.created_at) as date,
+ COALESCE(SUM(oi.quantity * oi.price_at_sale), 0) as revenue,
+ COUNT(DISTINCT o.id) as order_count
+ FROM orders o
+ JOIN order_items oi ON oi.order_id = o.id
+ WHERE 1=1 ${dateFilter}
+ GROUP BY date(o.created_at)
+ ORDER BY date
+ `).all(...orderParams);
+
+ const orderStatusBreakdown = db.prepare(`
+ SELECT o.status, COUNT(*) as count
+ FROM orders o
+ WHERE 1=1 ${dateFilter}
+ GROUP BY o.status
+ ORDER BY count DESC
+ `).all(...orderParams);
+
+ const inventorySummary = db.prepare(`
+ SELECT p.name as product_name,
+ p.quantity_on_hand as current_stock,
+ p.low_stock_threshold,
+ COALESCE((SELECT SUM(sa.adjustment) FROM stock_adjustments sa WHERE sa.product_id = p.id AND sa.reason = 'restock' ${dateFilterSA}), 0) as total_restocked,
+ COALESCE((SELECT ABS(SUM(sa.adjustment)) FROM stock_adjustments sa WHERE sa.product_id = p.id AND sa.adjustment < 0 ${dateFilterSA}), 0) as total_sold,
+ COALESCE((SELECT COUNT(*) FROM stock_adjustments sa WHERE sa.product_id = p.id AND sa.reason = 'restock' ${dateFilterSA}), 0) as restock_count
+ FROM products p
+ ORDER BY p.name
+ `).all(...invParams);
+
+ res.json({
+ salesByProduct,
+ topCustomers,
+ revenueOverTime,
+ orderStatusBreakdown,
+ inventorySummary,
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+module.exports = router;