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>
This commit is contained in:
parent
596b0b6f03
commit
1ed2642e20
@ -10,6 +10,7 @@ import Orders from './pages/Orders';
|
|||||||
import OrderNew from './pages/OrderNew';
|
import OrderNew from './pages/OrderNew';
|
||||||
import OrderDetail from './pages/OrderDetail';
|
import OrderDetail from './pages/OrderDetail';
|
||||||
import Restock from './pages/Restock';
|
import Restock from './pages/Restock';
|
||||||
|
import Reports from './pages/Reports';
|
||||||
|
|
||||||
function AuthGate({ children }) {
|
function AuthGate({ children }) {
|
||||||
const [state, setState] = useState({ checked: false, authenticated: false });
|
const [state, setState] = useState({ checked: false, authenticated: false });
|
||||||
@ -53,6 +54,7 @@ export default function App() {
|
|||||||
<Route path="/orders/new" element={<OrderNew />} />
|
<Route path="/orders/new" element={<OrderNew />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/restock" element={<Restock />} />
|
<Route path="/restock" element={<Restock />} />
|
||||||
|
<Route path="/reports" element={<Reports />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
)}
|
)}
|
||||||
|
|||||||
1
client/src/assets/gs-trefoil.svg
Normal file
1
client/src/assets/gs-trefoil.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 11.7"><path d="M11.702 2.904c.005.118.009.247.009.39 0 .313-.072.496.426 1.143.112.146.213.303.022.415-.19.112-.31.188-.225.358.045.09.101.27-.381.438 0 0-.045.033.034.045.079.011.258 0 .258.179 0 .123-.336.146-.336.47 0 .427-.078.549-.448.572-.312.019-.771 0-.962 0-.37 0-.729.229-1.255.846-.224.261-1.857 2.308-2.478 3.92.069.007.139.011.207.011 1.166 0 2.461-1.069 2.999-2.167 1.21 0 3.552.084 3.552-3.535 0-1.574-.453-2.614-1.422-3.085z"/><path d="M8.032.789s-.197 2.402-2.223 4.284C3.783 6.956 3.191 8.032 2.42 9.484c0 0-2.42-.108-2.42-3.424S1.99 2.6 2.851 2.6c0 0 0-2.6 3.837-2.6 3.836 0 3.567 2.886 3.567 2.528 0 0-.144 1.201.502 1.757 0 0 .251.251-.054.376 0 0-.317.144-.197.395 0 0 .131.257-.376.389 0 0-.054.029.012.042.065.012.283.036.269.197-.006.066-.227.197-.227.197s-.125.089-.12.4c.006.311-.221.394-.382.4-.162.006-.998 0-1.129.006s-.611-.036-1.525 1.166-2.008 3.298-2.008 3.298-.753-.627-.95-.878-.162-.484.179-1.076C4.59 8.605 5.701 6.4 7.189 6.4c.842 0 1.219.089 1.272-.341 0 0 .018-.13.022-.233a.407.407 0 0 1 .202-.305c.15-.076.156-.192.071-.251-.083-.058-.204-.046-.235-.071-.018-.015-.01-.023.04-.045.026-.011.293-.072.355-.233.063-.161-.077-.233-.041-.332.035-.098.241-.224.304-.241.063-.018.099-.135.018-.26-.081-.126-.538-.628-.52-1.121.018-.493.027-1.613-.645-2.178z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
|
import trefoilLogo from '../assets/gs-trefoil.svg';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'Dashboard' },
|
{ path: '/', label: 'Dashboard' },
|
||||||
@ -8,6 +9,7 @@ const navItems = [
|
|||||||
{ path: '/customers', label: 'Customers' },
|
{ path: '/customers', label: 'Customers' },
|
||||||
{ path: '/orders', label: 'Orders' },
|
{ path: '/orders', label: 'Orders' },
|
||||||
{ path: '/restock', label: 'Restock' },
|
{ path: '/restock', label: 'Restock' },
|
||||||
|
{ path: '/reports', label: 'Reports' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout({ children, onLogout }) {
|
export default function Layout({ children, onLogout }) {
|
||||||
@ -23,7 +25,7 @@ export default function Layout({ children, onLogout }) {
|
|||||||
<div className="layout">
|
<div className="layout">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-inner container">
|
<div className="header-inner container">
|
||||||
<Link to="/" className="logo">Cookie Tracker</Link>
|
<Link to="/" className="logo"><img src={trefoilLogo} alt="Girl Scouts" style={{ height: '1.5em', verticalAlign: 'middle', marginRight: '0.4rem' }} />Cookie Tracker</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="menu-toggle"
|
className="menu-toggle"
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export default function OrderDetail() {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [status, setStatus] = useState('pending');
|
const [status, setStatus] = useState('pending');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState('');
|
||||||
|
const [amountPaid, setAmountPaid] = useState('');
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
@ -23,6 +25,8 @@ export default function OrderDetail() {
|
|||||||
setProducts(p);
|
setProducts(p);
|
||||||
setStatus(o.status);
|
setStatus(o.status);
|
||||||
setNotes(o.notes || '');
|
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 })) || []);
|
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))
|
.catch((e) => setError(e.message))
|
||||||
@ -37,6 +41,8 @@ export default function OrderDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = {
|
const payload = {
|
||||||
status,
|
status,
|
||||||
|
payment_method: paymentMethod || null,
|
||||||
|
amount_paid: amountPaid !== '' ? Number(amountPaid) : 0,
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
items: items.map((line) => ({
|
items: items.map((line) => ({
|
||||||
product_id: line.product_id,
|
product_id: line.product_id,
|
||||||
@ -99,6 +105,13 @@ export default function OrderDetail() {
|
|||||||
<p><strong>Status:</strong> {order.status}</p>
|
<p><strong>Status:</strong> {order.status}</p>
|
||||||
<p><strong>Created:</strong> {new Date(order.created_at).toLocaleString()}</p>
|
<p><strong>Created:</strong> {new Date(order.created_at).toLocaleString()}</p>
|
||||||
{order.notes && <p><strong>Notes:</strong> {order.notes}</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' }}>
|
<div className="table-wrap" style={{ marginTop: '1rem' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -143,6 +156,33 @@ export default function OrderDetail() {
|
|||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
||||||
</div>
|
</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 className="form-group">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
<label style={{ marginBottom: 0 }}>Items</label>
|
<label style={{ marginBottom: 0 }}>Items</label>
|
||||||
|
|||||||
@ -13,6 +13,8 @@ export default function OrderNew() {
|
|||||||
const [walkInName, setWalkInName] = useState('');
|
const [walkInName, setWalkInName] = useState('');
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [status, setStatus] = useState('pending');
|
const [status, setStatus] = useState('pending');
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState('');
|
||||||
|
const [amountPaid, setAmountPaid] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@ -63,6 +65,8 @@ export default function OrderNew() {
|
|||||||
customer_id: customerId ? Number(customerId) : null,
|
customer_id: customerId ? Number(customerId) : null,
|
||||||
walk_in_name: !customerId && walkInName.trim() ? walkInName.trim() : undefined,
|
walk_in_name: !customerId && walkInName.trim() ? walkInName.trim() : undefined,
|
||||||
status,
|
status,
|
||||||
|
payment_method: paymentMethod || null,
|
||||||
|
amount_paid: amountPaid !== '' ? Number(amountPaid) : 0,
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
items: items.map((line) => ({
|
items: items.map((line) => ({
|
||||||
product_id: line.product_id,
|
product_id: line.product_id,
|
||||||
@ -130,6 +134,32 @@ export default function OrderNew() {
|
|||||||
<option value="delivered">Delivered</option>
|
<option value="delivered">Delivered</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
|
|||||||
288
client/src/pages/Reports.jsx
Normal file
288
client/src/pages/Reports.jsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Reports</h1>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div className="form-row" style={{ alignItems: 'flex-end', gap: '1rem' }}>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Date range</label>
|
||||||
|
<select value={preset} onChange={(e) => setPreset(e.target.value)}>
|
||||||
|
<option value="all">All time</option>
|
||||||
|
<option value="week">This week</option>
|
||||||
|
<option value="month">This month</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Start</label>
|
||||||
|
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>End</label>
|
||||||
|
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{loading && <p className="text-muted">Loading reports...</p>}
|
||||||
|
|
||||||
|
{data && !loading && (
|
||||||
|
<>
|
||||||
|
{/* Sales by Product */}
|
||||||
|
<section className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Sales by Product</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Units sold</th>
|
||||||
|
<th>Revenue</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.salesByProduct.map((row) => (
|
||||||
|
<tr key={row.product_name}>
|
||||||
|
<td>{row.product_name}</td>
|
||||||
|
<td>{row.units_sold}</td>
|
||||||
|
<td>${Number(row.revenue).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ fontWeight: 600 }}>
|
||||||
|
<td>Total</td>
|
||||||
|
<td>{data.salesByProduct.reduce((s, r) => s + r.units_sold, 0)}</td>
|
||||||
|
<td>${data.salesByProduct.reduce((s, r) => s + r.revenue, 0).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Top Customers */}
|
||||||
|
<section className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Top Customers</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Orders</th>
|
||||||
|
<th>Total spent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.topCustomers.map((row) => (
|
||||||
|
<tr key={row.customer_name}>
|
||||||
|
<td>{row.customer_name}</td>
|
||||||
|
<td>{row.order_count}</td>
|
||||||
|
<td>${Number(row.total_spent).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.topCustomers.length === 0 && (
|
||||||
|
<tr><td colSpan={3} className="text-muted">No customer data</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Revenue Over Time */}
|
||||||
|
<section className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Revenue Over Time</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Orders</th>
|
||||||
|
<th>Revenue</th>
|
||||||
|
<th style={{ width: '40%' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(() => {
|
||||||
|
const maxRevenue = Math.max(...data.revenueOverTime.map((r) => r.revenue), 1);
|
||||||
|
return data.revenueOverTime.map((row) => (
|
||||||
|
<tr key={row.date}>
|
||||||
|
<td>{row.date}</td>
|
||||||
|
<td>{row.order_count}</td>
|
||||||
|
<td>${Number(row.revenue).toFixed(2)}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{
|
||||||
|
height: 18,
|
||||||
|
width: `${(row.revenue / maxRevenue) * 100}%`,
|
||||||
|
backgroundColor: 'var(--accent, #4299e1)',
|
||||||
|
borderRadius: 3,
|
||||||
|
minWidth: row.revenue > 0 ? 4 : 0,
|
||||||
|
}} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
{data.revenueOverTime.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="text-muted">No revenue data</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Order Status Breakdown */}
|
||||||
|
<section className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Order Status Breakdown</h2>
|
||||||
|
{(() => {
|
||||||
|
const totalOrders = data.orderStatusBreakdown.reduce((s, r) => s + r.count, 0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{totalOrders > 0 && (
|
||||||
|
<div style={{ display: 'flex', borderRadius: 6, overflow: 'hidden', height: 28, marginBottom: '1rem' }}>
|
||||||
|
{data.orderStatusBreakdown.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.status}
|
||||||
|
style={{
|
||||||
|
width: `${(row.count / totalOrders) * 100}%`,
|
||||||
|
backgroundColor: statusColors[row.status] || '#a0aec0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1a202c',
|
||||||
|
}}
|
||||||
|
title={`${row.status}: ${row.count}`}
|
||||||
|
>
|
||||||
|
{(row.count / totalOrders) >= 0.1 ? row.status : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.orderStatusBreakdown.map((row) => (
|
||||||
|
<tr key={row.status}>
|
||||||
|
<td style={{ textTransform: 'capitalize' }}>{row.status}</td>
|
||||||
|
<td>{row.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.orderStatusBreakdown.length === 0 && (
|
||||||
|
<tr><td colSpan={2} className="text-muted">No orders</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Inventory Summary */}
|
||||||
|
<section className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Inventory Summary</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Current stock</th>
|
||||||
|
<th>Total restocked</th>
|
||||||
|
<th>Total sold</th>
|
||||||
|
<th>Restock count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.inventorySummary.map((row) => {
|
||||||
|
const lowStock = row.low_stock_threshold > 0 && row.current_stock <= row.low_stock_threshold;
|
||||||
|
return (
|
||||||
|
<tr key={row.product_name} style={lowStock ? { backgroundColor: 'rgba(229, 62, 62, 0.1)' } : undefined}>
|
||||||
|
<td>{row.product_name}</td>
|
||||||
|
<td style={lowStock ? { color: 'var(--danger, #e53e3e)', fontWeight: 600 } : undefined}>
|
||||||
|
{row.current_stock}
|
||||||
|
{lowStock && ' (low)'}
|
||||||
|
</td>
|
||||||
|
<td>{row.total_restocked}</td>
|
||||||
|
<td>{row.total_sold}</td>
|
||||||
|
<td>{row.restock_count}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
12
server/db.js
12
server/db.js
@ -57,7 +57,19 @@ function initSchema(database) {
|
|||||||
price_at_sale REAL NOT NULL,
|
price_at_sale REAL NOT NULL,
|
||||||
UNIQUE(order_id, product_id)
|
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 };
|
module.exports = { getDb, initSchema };
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const productsRouter = require('./routes/products');
|
|||||||
const customersRouter = require('./routes/customers');
|
const customersRouter = require('./routes/customers');
|
||||||
const ordersRouter = require('./routes/orders');
|
const ordersRouter = require('./routes/orders');
|
||||||
const dashboardRouter = require('./routes/dashboard');
|
const dashboardRouter = require('./routes/dashboard');
|
||||||
|
const reportsRouter = require('./routes/reports');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3002;
|
const PORT = process.env.PORT || 3002;
|
||||||
@ -29,6 +30,7 @@ app.use('/api/products', productsRouter);
|
|||||||
app.use('/api/customers', customersRouter);
|
app.use('/api/customers', customersRouter);
|
||||||
app.use('/api/orders', ordersRouter);
|
app.use('/api/orders', ordersRouter);
|
||||||
app.use('/api/dashboard', dashboardRouter);
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
app.use('/api/reports', reportsRouter);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
app.use(express.static(path.join(__dirname, '..', 'client', 'dist')));
|
app.use(express.static(path.join(__dirname, '..', 'client', 'dist')));
|
||||||
|
|||||||
@ -72,7 +72,11 @@ router.get('/:id', (req, res) => {
|
|||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
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) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
return res.status(400).json({ error: 'At least one order item is required' });
|
return res.status(400).json({ error: 'At least one order item is required' });
|
||||||
}
|
}
|
||||||
@ -90,13 +94,15 @@ router.post('/', (req, res) => {
|
|||||||
resolvedCustomerId = custResult.lastInsertRowid;
|
resolvedCustomerId = custResult.lastInsertRowid;
|
||||||
}
|
}
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO orders (customer_id, status, notes) VALUES (?, ?, ?)'
|
'INSERT INTO orders (customer_id, status, notes, payment_method, amount_paid) VALUES (?, ?, ?, ?, ?)'
|
||||||
).run(resolvedCustomerId, status, notes || null);
|
).run(resolvedCustomerId, status, notes || null, payment_method || null, Number(amount_paid) || 0);
|
||||||
const orderId = result.lastInsertRowid;
|
const orderId = result.lastInsertRowid;
|
||||||
|
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const qty = Number(it.quantity) || 0;
|
const qty = Number(it.quantity) || 0;
|
||||||
atomicDeductStock(db, it.product_id, qty);
|
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);
|
applyOrderItems(db, orderId, items);
|
||||||
@ -120,7 +126,11 @@ router.put('/:id', (req, res) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id);
|
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id);
|
||||||
if (!order) return res.status(404).json({ error: 'Order not found' });
|
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(() => {
|
const updateOrder = db.transaction(() => {
|
||||||
if (items !== undefined) {
|
if (items !== undefined) {
|
||||||
@ -131,6 +141,8 @@ router.put('/:id', (req, res) => {
|
|||||||
for (const ei of existingItems) {
|
for (const ei of existingItems) {
|
||||||
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
|
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
|
||||||
.run(ei.quantity, ei.product_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
|
// Deduct stock for new items atomically
|
||||||
@ -138,6 +150,8 @@ router.put('/:id', (req, res) => {
|
|||||||
const qty = Number(it.quantity) || 0;
|
const qty = Number(it.quantity) || 0;
|
||||||
if (qty <= 0) throw new Error('Quantity must be positive');
|
if (qty <= 0) throw new Error('Quantity must be positive');
|
||||||
atomicDeductStock(db, it.product_id, qty);
|
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);
|
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 cid = customer_id !== undefined ? (customer_id || null) : order.customer_id;
|
||||||
const st = status !== undefined ? status : order.status;
|
const st = status !== undefined ? status : order.status;
|
||||||
const no = notes !== undefined ? notes : order.notes;
|
const no = notes !== undefined ? notes : order.notes;
|
||||||
db.prepare('UPDATE orders SET customer_id = ?, status = ?, notes = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
const pm = payment_method !== undefined ? (payment_method || null) : order.payment_method;
|
||||||
.run(cid, st, no, req.params.id);
|
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();
|
updateOrder();
|
||||||
@ -172,6 +188,8 @@ router.delete('/:id', (req, res) => {
|
|||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
|
db.prepare('UPDATE products SET quantity_on_hand = quantity_on_hand + ? WHERE id = ?')
|
||||||
.run(it.quantity, it.product_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 order_items WHERE order_id = ?').run(req.params.id);
|
||||||
db.prepare('DELETE FROM orders WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM orders WHERE id = ?').run(req.params.id);
|
||||||
|
|||||||
@ -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) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@ -76,6 +99,7 @@ router.patch('/:id/stock', (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Quantity would go negative' });
|
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('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);
|
const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
95
server/routes/reports.js
Normal file
95
server/routes/reports.js
Normal file
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user