Initial commit: cookie-tracker

Girl Scout Cookie tracking app with Express/SQLite API and React/Vite client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adamp 2026-02-09 17:48:42 -06:00
commit b0e4e977c1
32 changed files with 5908 additions and 0 deletions

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git init:*)",
"Bash(git remote add:*)",
"Bash(git config:*)"
]
}
}

20
.cursor/debug.log Normal file
View File

@ -0,0 +1,20 @@
{"location":"api.js:api","message":"fetch result","data":{"url":"/api/auth/me","status":401,"ok":false},"timestamp":1770486391737,"hypothesisId":"C"}
{"location":"api.js:api","message":"fetch result","data":{"url":"/api/auth/me","status":401,"ok":false},"timestamp":1770486391755,"hypothesisId":"C"}
{"location":"api.js:api","message":"fetch result","data":{"url":"/api/auth/login","status":200,"ok":true},"timestamp":1770486394512,"hypothesisId":"C"}
{"location":"Login.jsx:handleSubmit","message":"login ok before onLogin","data":{"status":200},"timestamp":1770486394513,"hypothesisId":"E"}
{"location":"server/routes/auth.js:login","message":"login success cookie set","data":{"cookieSet":true},"timestamp":1770486394474,"hypothesisId":"D"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/dashboard","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486394540,"hypothesisId":"B"}
{"location":"api.js:api","message":"fetch result","data":{"url":"/api/dashboard","status":200,"ok":true},"timestamp":1770486394549,"hypothesisId":"C"}
{"location":"Dashboard.jsx:useEffect","message":"dashboard response","data":{"status":200,"ok":true},"timestamp":1770486394549,"hypothesisId":"C"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/dashboard","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486394551,"hypothesisId":"B"}
{"location":"api.js:api","message":"fetch result","data":{"url":"/api/dashboard","status":200,"ok":true},"timestamp":1770486394561,"hypothesisId":"C"}
{"location":"Dashboard.jsx:useEffect","message":"dashboard response","data":{"status":200,"ok":true},"timestamp":1770486394561,"hypothesisId":"C"}
{"location":"server/routes/auth.js:login","message":"login success cookie set","data":{"cookieSet":true,"secure":false},"timestamp":1770486506948,"hypothesisId":"D"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/dashboard","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486507008,"hypothesisId":"B"}
{"location":"server/routes/auth.js:login","message":"login success cookie set","data":{"cookieSet":true,"secure":false},"timestamp":1770486534194,"hypothesisId":"D"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/dashboard","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486534207,"hypothesisId":"B"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/orders/3","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486580171,"hypothesisId":"B"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/products","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486580177,"hypothesisId":"B"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/dashboard","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486616031,"hypothesisId":"B"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/products","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486618485,"hypothesisId":"B"}
{"location":"server/middleware/auth.js:authMiddleware","message":"auth check","data":{"path":"/customers","hasToken":true,"verifyResult":true,"will401":false},"timestamp":1770486665498,"hypothesisId":"B"}

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
PORT=3002
DATABASE_PATH=./data/cookies.db
# Authentication (optional). If APP_PASSWORD is not set, the app runs without login.
APP_PASSWORD=your_password_here
APP_SECRET=long_random_string_for_signing_sessions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
client/node_modules/
data/
.env
*.db
client/dist/

57
CLAUDE.md Normal file
View File

@ -0,0 +1,57 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Install dependencies (both root and client)
npm install && cd client && npm install && cd ..
# Development (runs API server + Vite dev server concurrently)
npm run dev
# API: http://localhost:3002
# Client: http://localhost:5173 (proxies /api to :3002)
# Run only the API server
npm run server
# Run only the client dev server
npm run client
# Production build and run
npm run build # Builds client into client/dist/
npm start # Serves API + static client from client/dist/
```
No test runner or linter is currently configured.
## Architecture
Full-stack monorepo: Express.js API + React 18 SPA (Vite + React Router v7).
**Server** (`server/`):
- `index.js` — Express entry point. Mounts all route groups under `/api`, applies auth middleware to non-auth routes. In production, serves the built client as static files with SPA fallback.
- `db.js` — SQLite via `better-sqlite3` (synchronous). Auto-creates the `data/` directory and tables on first run. Foreign keys are enabled.
- `middleware/auth.js` — Optional HMAC-SHA256 session cookie auth. If `APP_PASSWORD` is not set in `.env`, authentication is disabled entirely (middleware passes through).
- `routes/` — CRUD for products, customers, orders, plus a dashboard summary endpoint.
**Client** (`client/`):
- `src/App.jsx` — Top-level `AuthGate` checks `/api/auth/me` on mount; renders `<Login>` or the authenticated `<Layout>` with routes.
- `src/api.js` — Thin fetch wrapper that prefixes `/api`, includes credentials, and handles JSON serialization.
- `src/pages/` — One component per route: Dashboard, Inventory, Customers, Orders, OrderNew, OrderDetail, Login.
- Vite dev server proxies `/api` requests to the Express backend (configured in `vite.config.js`).
**Key data flow**: Orders deduct product stock on creation, restore stock on deletion, and recalculate stock differences on update. This logic lives in `server/routes/orders.js` with helper functions `deductStockForOrder()` and `applyOrderItems()`.
## Database Schema
Four tables: `products`, `customers`, `orders`, `order_items`. Orders reference customers (nullable for walk-ins). Order items have a `UNIQUE(order_id, product_id)` constraint and `ON DELETE CASCADE` from orders. Prices are snapshot at time of sale (`price_at_sale`).
## Environment
Configured via `.env` (see `.env.example`):
- `APP_PASSWORD` — If set, enables login gate. If unset, app runs without authentication.
- `APP_SECRET` — Signs session cookies (HMAC-SHA256).
- `PORT` — API server port (default 3002).
- `DATABASE_PATH` — SQLite file path (default `./data/cookies.db`).

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Girl Scout Cookie Tracker
A self-hosted web app for tracking cookie inventory and customers for a single troop. Works on desktop and mobile.
## Features
- **Inventory**: Add cookie products (name, price, quantity, low-stock threshold). Adjust stock (restock or deduct). Low-stock highlighting.
- **Customers**: Store name, phone, email, address, notes. Search by name, email, or phone.
- **Orders**: Create orders with customer (or walk-in), line items (product + quantity), status (pending, paid, delivered). Inventory is deducted automatically. Edit or delete orders (stock is restored on delete).
- **Dashboard**: Summary counts, low-stock list, recent orders.
## Requirements
- Node.js 18+
- npm
## Setup
1. Clone or download this repo.
2. Install dependencies:
```bash
npm install
cd client && npm install && cd ..
```
3. Optional: copy `.env.example` to `.env` and set:
- `PORT` — API port (default 3002)
- `DATABASE_PATH` — path to SQLite file (default `./data/cookies.db`)
- **Authentication (optional):** Set `APP_PASSWORD` to require a password to log in. Set `APP_SECRET` to a long random string (used to sign session cookies). If `APP_PASSWORD` is not set, the app runs without login; if set, users must enter the password to access the app. Use "Log out" in the nav to sign out.
## Development
Run the API server and the Vite dev server together:
```bash
npm run dev
```
- API: http://localhost:3002
- Frontend: http://localhost:5173 (proxies `/api` to the server)
Use the frontend URL in your browser. The database file is created automatically on first request.
## Production build and run
1. Build the client:
```bash
npm run build
```
2. Run the server in production mode (serves the built client and API):
```bash
npm start
```
Or set `NODE_ENV=production` and run `node server/index.js`. The app will serve static files from `client/dist` and handle the SPA fallback.
3. Open http://localhost:3002 (or your `PORT`).
## Deployment (self-hosted)
- Run the app behind a reverse proxy (e.g. nginx) if you want HTTPS or a different port.
- Set `DATABASE_PATH` to a persistent volume path so the SQLite file survives restarts.
- Set `APP_PASSWORD` and `APP_SECRET` in production so only people with the password can access the app. Session cookies are httpOnly and (in production) Secure when served over HTTPS.
- Back up the SQLite file regularly (e.g. cron job copying `data/cookies.db`).
## Project structure
- `server/` — Express API and SQLite (products, customers, orders, dashboard).
- `client/` — Vite + React SPA (Dashboard, Inventory, Customers, Orders, New order, Order detail).
- `data/` — Created at runtime; contains `cookies.db` by default.

12
client/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Girl Scout Cookie Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1849
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
client/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "girl-scout-cookies-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.3"
}
}

60
client/src/App.jsx Normal file
View File

@ -0,0 +1,60 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { api } from './api';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Inventory from './pages/Inventory';
import Customers from './pages/Customers';
import Orders from './pages/Orders';
import OrderNew from './pages/OrderNew';
import OrderDetail from './pages/OrderDetail';
function AuthGate({ children }) {
const [state, setState] = useState({ checked: false, authenticated: false });
useEffect(() => {
api('/auth/me')
.then((res) => {
setState({ checked: true, authenticated: res.ok });
})
.catch(() => {
setState({ checked: true, authenticated: false });
});
}, []);
if (!state.checked) {
return <p className="text-muted" style={{ padding: '2rem' }}>Loading</p>;
}
if (!state.authenticated) {
return (
<Login
onLogin={() => setState((s) => ({ ...s, authenticated: true }))}
/>
);
}
return children({ onLogout: () => setState((s) => ({ ...s, authenticated: false })) });
}
export default function App() {
return (
<BrowserRouter>
<AuthGate>
{({ onLogout }) => (
<Layout onLogout={onLogout}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/customers" element={<Customers />} />
<Route path="/orders" element={<Orders />} />
<Route path="/orders/new" element={<OrderNew />} />
<Route path="/orders/:id" element={<OrderDetail />} />
</Routes>
</Layout>
)}
</AuthGate>
</BrowserRouter>
);
}

22
client/src/api.js Normal file
View File

@ -0,0 +1,22 @@
const API_BASE = '/api';
export async function api(path, options = {}) {
const url = path.startsWith('http') ? path : `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`;
const { body, headers, ...rest } = options;
const fetchOptions = {
...rest,
credentials: 'include',
headers: {
...(body != null && typeof body === 'object' && !(body instanceof FormData)
? { 'Content-Type': 'application/json' }
: {}),
...headers,
},
};
if (body != null) {
fetchOptions.body = typeof body === 'object' && !(body instanceof FormData)
? JSON.stringify(body)
: body;
}
return fetch(url, fetchOptions);
}

View File

@ -0,0 +1,72 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { api } from '../api';
const navItems = [
{ path: '/', label: 'Dashboard' },
{ path: '/inventory', label: 'Inventory' },
{ path: '/customers', label: 'Customers' },
{ path: '/orders', label: 'Orders' },
];
export default function Layout({ children, onLogout }) {
const [menuOpen, setMenuOpen] = useState(false);
const location = useLocation();
async function handleLogout() {
await api('/auth/logout', { method: 'POST' });
onLogout?.();
}
return (
<div className="layout">
<header className="header">
<div className="header-inner container">
<Link to="/" className="logo">Cookie Tracker</Link>
<button
type="button"
className="menu-toggle"
onClick={() => setMenuOpen(!menuOpen)}
aria-expanded={menuOpen}
aria-label="Toggle menu"
>
<span className="menu-icon" />
</button>
<nav className={`nav ${menuOpen ? 'nav-open' : ''}`}>
{navItems.map(({ path, label }) => (
<Link
key={path}
to={path}
className={location.pathname === path ? 'active' : ''}
onClick={() => setMenuOpen(false)}
>
{label}
</Link>
))}
<Link
to="/orders/new"
className="nav-cta"
onClick={() => setMenuOpen(false)}
>
New order
</Link>
{onLogout && (
<button
type="button"
className="nav-logout secondary"
onClick={() => { setMenuOpen(false); handleLogout(); }}
>
Log out
</button>
)}
</nav>
</div>
</header>
<main className="main">
<div className="container">
{children}
</div>
</main>
</div>
);
}

415
client/src/index.css Normal file
View File

@ -0,0 +1,415 @@
:root {
--bg: #f5f0eb;
--surface: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #c41e3a;
--accent-hover: #a01830;
--border: #e0d9d2;
--low-stock: #c41e3a;
--success: #2d6a2d;
--touch-min: 44px;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
line-height: 1.4;
color: var(--text);
background: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font: inherit;
cursor: pointer;
min-height: var(--touch-min);
min-width: var(--touch-min);
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: white;
}
button:hover {
background: var(--accent-hover);
}
button.secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover {
background: var(--border);
}
input, select, textarea {
font: inherit;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
min-height: var(--touch-min);
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
}
@media (min-width: 768px) {
.container {
padding: 1.5rem;
}
}
.card-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.card-list.table-layout {
display: block;
}
}
.card {
background: var(--surface);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
th {
font-weight: 600;
color: var(--text-muted);
}
.low-stock {
color: var(--low-stock);
font-weight: 600;
}
.error {
color: var(--low-stock);
margin-top: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
min-width: 0;
}
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.logo {
font-weight: 700;
font-size: 1.25rem;
color: var(--text);
}
.logo:hover {
text-decoration: none;
color: var(--accent);
}
.menu-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
color: var(--text);
}
@media (min-width: 768px) {
.menu-toggle {
display: none;
}
}
.menu-icon {
width: 24px;
height: 2px;
background: currentColor;
box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor;
}
.nav {
display: none;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 1rem;
gap: 0.25rem;
}
.nav.nav-open {
display: flex;
}
@media (min-width: 768px) {
.nav {
display: flex;
position: static;
flex-direction: row;
border: none;
padding: 0;
}
}
.nav a {
padding: 0.75rem;
min-height: var(--touch-min);
display: flex;
align-items: center;
color: var(--text);
border-radius: 6px;
}
.nav a:hover {
background: var(--bg);
color: var(--accent);
text-decoration: none;
}
.nav a.active {
background: var(--bg);
font-weight: 600;
color: var(--accent);
}
.nav-cta {
background: var(--accent);
color: white !important;
justify-content: center;
margin-top: 0.5rem;
}
.nav-cta:hover {
background: var(--accent-hover);
color: white !important;
}
@media (min-width: 768px) {
.nav-cta {
margin-top: 0;
margin-left: 0.5rem;
}
}
.nav-logout {
margin-top: 0.5rem;
}
@media (min-width: 768px) {
.nav-logout {
margin-top: 0;
margin-left: 0.5rem;
}
}
.main {
flex: 1;
padding-bottom: 2rem;
}
.page-title {
margin: 0 0 1rem;
font-size: 1.5rem;
}
@media (min-width: 768px) {
.page-title {
font-size: 1.75rem;
}
}
.text-muted {
color: var(--text-muted);
}
.dashboard-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
text-align: center;
text-decoration: none;
color: inherit;
}
.stat-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
text-decoration: none;
color: inherit;
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-muted);
}
.dashboard-actions {
margin-bottom: 1.5rem;
}
.card-link {
display: inline-block;
color: var(--accent);
font-weight: 600;
}
.card-link:hover {
text-decoration: none;
color: var(--accent-hover);
}
.dashboard-section {
margin-top: 1.5rem;
}
.dashboard-section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
color: var(--text-muted);
}
.low-stock-list {
list-style: none;
padding: 0;
margin: 0;
}
@media (max-width: 639px) {
.table-wrap table {
display: block;
}
.table-wrap thead {
display: none;
}
.table-wrap tr {
display: flex;
flex-direction: column;
padding: 1rem;
border-bottom: 1px solid var(--border);
gap: 0.25rem;
}
.table-wrap td {
padding: 0.25rem 0;
border: none;
}
.table-wrap td::before {
content: attr(data-label);
font-weight: 600;
color: var(--text-muted);
margin-right: 0.5rem;
}
.table-wrap td:last-child {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
}
.inventory-card-mobile {
display: none;
}
@media (max-width: 639px) {
.inventory-table-wrap {
display: none;
}
.inventory-card-mobile {
display: flex;
flex-direction: column;
gap: 1rem;
}
.inventory-card-mobile .card {
padding: 1rem;
}
}

10
client/src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,203 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api';
export default function Customers() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(null);
const [adding, setAdding] = useState(false);
const [search, setSearch] = useState('');
function load() {
api('/customers')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to load'))))
.then(setCustomers)
.catch(setError)
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, []);
const filtered = search.trim()
? customers.filter(
(c) =>
c.name?.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase()) ||
c.phone?.includes(search)
)
: customers;
function handleAdd(form) {
const name = form.name.value?.trim();
const phone = form.phone.value?.trim() || null;
const email = form.email.value?.trim() || null;
const address = form.address.value?.trim() || null;
const notes = form.notes.value?.trim() || null;
if (!name) return;
api('/customers', {
method: 'POST',
body: { name, phone, email, address, notes },
})
.then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')))))
.then(() => {
setAdding(false);
setError(null);
load();
})
.catch((e) => setError(e.message));
}
function handleUpdate(id, form) {
const name = form.name.value?.trim();
const phone = form.phone.value?.trim() || null;
const email = form.email.value?.trim() || null;
const address = form.address.value?.trim() || null;
const notes = form.notes.value?.trim() || null;
if (!name) return;
api(`/customers/${id}`, {
method: 'PUT',
body: { name, phone, email, address, notes },
})
.then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')))))
.then(() => {
setEditing(null);
setError(null);
load();
})
.catch((e) => setError(e.message));
}
function handleDelete(id) {
if (!confirm('Delete this customer?')) return;
api(`/customers/${id}`, { method: 'DELETE' })
.then((r) => {
if (!r.ok) return r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')));
setError(null);
load();
})
.catch((e) => setError(e.message));
}
if (loading) return <p className="text-muted">Loading...</p>;
return (
<>
<h1 className="page-title">Customers</h1>
{error && <p className="error">{error}</p>}
<div className="toolbar" style={{ marginBottom: '1rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<input
type="search"
placeholder="Search by name, email, phone"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ minWidth: 200, flex: 1 }}
/>
<button type="button" onClick={() => setAdding(true)}>Add customer</button>
</div>
{adding && (
<div className="card" style={{ marginBottom: '1rem' }}>
<h3>New customer</h3>
<form
onSubmit={(e) => {
e.preventDefault();
handleAdd(e.target);
}}
>
<div className="form-group">
<label>Name</label>
<input name="name" required />
</div>
<div className="form-row">
<div className="form-group">
<label>Phone</label>
<input name="phone" type="tel" />
</div>
<div className="form-group">
<label>Email</label>
<input name="email" type="email" />
</div>
</div>
<div className="form-group">
<label>Address</label>
<input name="address" />
</div>
<div className="form-group">
<label>Notes</label>
<textarea name="notes" rows={2} />
</div>
<button type="submit">Save</button>
<button type="button" className="secondary" onClick={() => setAdding(false)}>Cancel</button>
</form>
</div>
)}
<div className="card-list">
{filtered.map((c) => (
<div key={c.id} className="card">
{editing === c.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleUpdate(c.id, e.target);
}}
>
<div className="form-group">
<label>Name</label>
<input name="name" defaultValue={c.name} required />
</div>
<div className="form-row">
<div className="form-group">
<label>Phone</label>
<input name="phone" defaultValue={c.phone || ''} />
</div>
<div className="form-group">
<label>Email</label>
<input name="email" defaultValue={c.email || ''} />
</div>
</div>
<div className="form-group">
<label>Address</label>
<input name="address" defaultValue={c.address || ''} />
</div>
<div className="form-group">
<label>Notes</label>
<textarea name="notes" rows={2} defaultValue={c.notes || ''} />
</div>
<button type="submit">Save</button>
<button type="button" className="secondary" onClick={() => setEditing(null)}>Cancel</button>
</form>
) : (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '0.5rem' }}>
<div>
<strong>{c.name}</strong>
{c.phone && <div className="text-muted">{c.phone}</div>}
{c.email && <div className="text-muted">{c.email}</div>}
{c.address && <div className="text-muted">{c.address}</div>}
{c.notes && <div style={{ marginTop: 4 }}>{c.notes}</div>}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Link to={`/orders/new?customer=${c.id}`}>New order</Link>
<button type="button" className="secondary" onClick={() => setEditing(c.id)}>Edit</button>
<button type="button" className="secondary" onClick={() => handleDelete(c.id)}>Delete</button>
</div>
</div>
</>
)}
</div>
))}
</div>
{filtered.length === 0 && (
<p className="text-muted">
{search.trim() ? 'No customers match your search.' : 'No customers yet. Add one to get started.'}
</p>
)}
</>
);
}

View File

@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api';
export default function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
api('/dashboard')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to load'))))
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
if (loading) return <p className="text-muted">Loading...</p>;
if (error) return <p className="error">Error: {error.message}</p>;
if (!data) return null;
return (
<>
<h1 className="page-title">Dashboard</h1>
<div className="dashboard-cards">
<Link to="/inventory" className="stat-card card">
<span className="stat-value">{data.productCount}</span>
<span className="stat-label">Products</span>
</Link>
<Link to="/customers" className="stat-card card">
<span className="stat-value">{data.customerCount}</span>
<span className="stat-label">Customers</span>
</Link>
<Link to="/orders" className="stat-card card">
<span className="stat-value">{data.orderCount}</span>
<span className="stat-label">Orders</span>
</Link>
</div>
<div className="dashboard-actions">
<Link to="/orders/new" className="card card-link">
New order
</Link>
</div>
{data.lowStock && data.lowStock.length > 0 && (
<section className="dashboard-section">
<h2>Low stock</h2>
<ul className="low-stock-list card-list">
{data.lowStock.map((p) => (
<li key={p.id} className="card">
<Link to="/inventory">{p.name}</Link> {p.quantity_on_hand} left
{p.low_stock_threshold > 0 && (
<span> (threshold: {p.low_stock_threshold})</span>
)}
</li>
))}
</ul>
</section>
)}
{data.recentOrders && data.recentOrders.length > 0 && (
<section className="dashboard-section">
<h2>Recent orders</h2>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{data.recentOrders.map((o) => (
<tr key={o.id}>
<td><Link to={`/orders/${o.id}`}>#{o.id}</Link></td>
<td>{o.customer_name || 'Walk-in'}</td>
<td>{o.status}</td>
<td>{new Date(o.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</>
);
}

View File

@ -0,0 +1,219 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
export default function Inventory() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(null);
const [adding, setAdding] = useState(false);
const [adjusting, setAdjusting] = useState(null);
function load() {
api('/products')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to load'))))
.then(setProducts)
.catch(setError)
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, []);
function handleAdd(form) {
const name = form.name.value?.trim();
const price = Number(form.price.value) || 0;
const quantity_on_hand = Number(form.quantity_on_hand.value) || 0;
const low_stock_threshold = Number(form.low_stock_threshold.value) || 0;
if (!name) return;
api('/products', {
method: 'POST',
body: { name, price, quantity_on_hand, low_stock_threshold },
})
.then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')))))
.then(() => {
setAdding(false);
setError(null);
load();
})
.catch((e) => setError(e.message));
}
function handleUpdate(id, form) {
const name = form.name.value?.trim();
const price = Number(form.price.value) || 0;
const quantity_on_hand = Number(form.quantity_on_hand.value) || 0;
const low_stock_threshold = Number(form.low_stock_threshold.value) || 0;
if (!name) return;
api(`/products/${id}`, {
method: 'PUT',
body: { name, price, quantity_on_hand, low_stock_threshold },
})
.then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')))))
.then(() => {
setEditing(null);
setError(null);
load();
})
.catch((e) => setError(e.message));
}
function handleAdjustStock(id, adjustment) {
api(`/products/${id}/stock`, {
method: 'PATCH',
body: { adjustment: Number(adjustment) },
})
.then((r) => (r.ok ? r.json() : r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')))))
.then(() => {
setAdjusting(null);
setError(null);
load();
})
.catch((e) => setError(e.message));
}
function handleDelete(id) {
if (!confirm('Delete this product?')) return;
api(`/products/${id}`, { method: 'DELETE' })
.then((r) => {
if (!r.ok) return r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')));
setError(null);
load();
})
.catch((e) => setError(e.message));
}
if (loading) return <p className="text-muted">Loading...</p>;
return (
<>
<h1 className="page-title">Inventory</h1>
{error && <p className="error">{error}</p>}
<p style={{ marginBottom: '1rem' }}>
<button type="button" onClick={() => setAdding(true)}>Add product</button>
</p>
{adding && (
<div className="card" style={{ marginBottom: '1rem' }}>
<h3>New product</h3>
<form
onSubmit={(e) => {
e.preventDefault();
handleAdd(e.target);
}}
>
<div className="form-group">
<label>Name</label>
<input name="name" required />
</div>
<div className="form-row">
<div className="form-group">
<label>Price ($)</label>
<input name="price" type="number" step="0.01" defaultValue="0" />
</div>
<div className="form-group">
<label>Quantity on hand</label>
<input name="quantity_on_hand" type="number" min="0" defaultValue="0" />
</div>
<div className="form-group">
<label>Low stock threshold</label>
<input name="low_stock_threshold" type="number" min="0" defaultValue="0" />
</div>
</div>
<button type="submit">Save</button>
<button type="button" className="secondary" onClick={() => setAdding(false)}>Cancel</button>
</form>
</div>
)}
<div className="card-list table-layout">
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
<th>Qty on hand</th>
<th>Low stock</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id}>
{editing === p.id ? (
<>
<td colSpan={5}>
<form
onSubmit={(e) => {
e.preventDefault();
handleUpdate(p.id, e.target);
}}
className="form-row"
style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}
>
<div className="form-group" style={{ minWidth: '120px' }}>
<label>Name</label>
<input name="name" defaultValue={p.name} required />
</div>
<div className="form-group" style={{ minWidth: '80px' }}>
<label>Price</label>
<input name="price" type="number" step="0.01" defaultValue={p.price} />
</div>
<div className="form-group" style={{ minWidth: '80px' }}>
<label>Qty</label>
<input name="quantity_on_hand" type="number" min="0" defaultValue={p.quantity_on_hand} />
</div>
<div className="form-group" style={{ minWidth: '80px' }}>
<label>Low threshold</label>
<input name="low_stock_threshold" type="number" min="0" defaultValue={p.low_stock_threshold} />
</div>
<div className="form-group">
<button type="submit">Save</button>
<button type="button" className="secondary" onClick={() => setEditing(null)}>Cancel</button>
</div>
</form>
</td>
</>
) : (
<>
<td data-label="Name">{p.name}</td>
<td data-label="Price">${Number(p.price).toFixed(2)}</td>
<td data-label="Qty" className={p.low_stock_threshold > 0 && p.quantity_on_hand <= p.low_stock_threshold ? 'low-stock' : ''}>
{p.quantity_on_hand}
</td>
<td data-label="Low stock">{p.low_stock_threshold || '-'}</td>
<td data-label="Actions">
<button type="button" className="secondary" onClick={() => setEditing(p.id)}>Edit</button>
{adjusting === p.id ? (
<span style={{ marginLeft: 8 }}>
<input
type="number"
id={`adj-${p.id}`}
placeholder="+/-"
style={{ width: 60 }}
/>
<button type="button" onClick={() => handleAdjustStock(p.id, document.getElementById(`adj-${p.id}`).value)}>Apply</button>
<button type="button" className="secondary" onClick={() => setAdjusting(null)}>Cancel</button>
</span>
) : (
<button type="button" className="secondary" style={{ marginLeft: 8 }} onClick={() => setAdjusting(p.id)}>Adjust stock</button>
)}
<button type="button" className="secondary" style={{ marginLeft: 8 }} onClick={() => handleDelete(p.id)}>Delete</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{products.length === 0 && !adding && (
<p className="text-muted">No products yet. Add one to get started.</p>
)}
</>
);
}

View File

@ -0,0 +1,57 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { api } from '../api';
export default function Login({ onLogin }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || location.pathname || '/';
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await api('/auth/login', { method: 'POST', body: { password } });
if (!res.ok) {
setError('Invalid password');
setLoading(false);
return;
}
onLogin?.();
navigate(from, { replace: true });
} catch {
setError('Something went wrong');
}
setLoading(false);
}
return (
<div className="login-page">
<div className="card" style={{ maxWidth: 360, margin: '2rem auto' }}>
<h1 className="page-title" style={{ marginBottom: '1rem' }}>Log in</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="login-password">Password</label>
<input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
autoFocus
disabled={loading}
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in…' : 'Log in'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,194 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../api';
export default function OrderDetail() {
const { id } = useParams();
const [order, setOrder] = useState(null);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(false);
const [status, setStatus] = useState('pending');
const [notes, setNotes] = useState('');
const [items, setItems] = useState([]);
function load() {
Promise.all([
api(`/orders/${id}`).then((r) => (r.ok ? r.json() : Promise.reject(new Error('Not found')))),
api('/products').then((r) => r.json()),
])
.then(([o, p]) => {
setOrder(o);
setProducts(p);
setStatus(o.status);
setNotes(o.notes || '');
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))
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, [id]);
function handleUpdate(e) {
e.preventDefault();
const payload = {
status,
notes: notes.trim() || null,
items: items.map((line) => ({
product_id: line.product_id,
quantity: Number(line.quantity) || 0,
price_at_sale: line.price_at_sale,
})).filter((line) => line.quantity > 0),
};
if (payload.items.length === 0) {
setError('Order must have at least one item.');
return;
}
api(`/orders/${id}`, {
method: 'PUT',
body: payload,
})
.then((r) => {
if (!r.ok) return r.json().then((e) => Promise.reject(new Error(e.error || 'Failed')));
return r.json();
})
.then(setOrder)
.then(() => setEditing(false))
.then(load)
.catch((e) => setError(e.message));
}
function addLine() {
const productId = products[0]?.id;
if (!productId) return;
const p = products.find((x) => x.id === productId);
setItems((prev) => [...prev, { product_id: productId, quantity: 1, price_at_sale: p?.price }]);
}
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));
}
if (loading) return <p className="text-muted">Loading...</p>;
if (error && !order) return <p className="error">{error}</p>;
if (!order) return null;
const productById = Object.fromEntries(products.map((p) => [p.id, p]));
let total = 0;
for (const line of items) {
const price = line.price_at_sale != null ? line.price_at_sale : productById[line.product_id]?.price;
total += (price || 0) * (Number(line.quantity) || 0);
}
return (
<>
<h1 className="page-title">Order #{order.id}</h1>
{error && <p className="error">{error}</p>}
{!editing ? (
<div className="card">
<p><strong>Customer:</strong> {order.customer_id ? (order.customer_name || `#${order.customer_id}`) : 'Walk-in'}</p>
<p><strong>Status:</strong> {order.status}</p>
<p><strong>Created:</strong> {new Date(order.created_at).toLocaleString()}</p>
{order.notes && <p><strong>Notes:</strong> {order.notes}</p>}
<div className="table-wrap" style={{ marginTop: '1rem' }}>
<table>
<thead>
<tr>
<th>Product</th>
<th>Qty</th>
<th>Price</th>
<th>Subtotal</th>
</tr>
</thead>
<tbody>
{(order.items || []).map((line) => (
<tr key={line.id}>
<td>{line.product_name}</td>
<td>{line.quantity}</td>
<td>${Number(line.price_at_sale).toFixed(2)}</td>
<td>${(line.quantity * line.price_at_sale).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p style={{ fontWeight: 600, marginTop: '1rem' }}>
Total: ${(order.items || []).reduce((s, i) => s + i.quantity * i.price_at_sale, 0).toFixed(2)}
</p>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<button type="button" className="secondary" onClick={() => setEditing(true)}>Edit</button>
<Link to="/orders"><button type="button" className="secondary">Back to orders</button></Link>
</div>
</div>
) : (
<form onSubmit={handleUpdate} className="card">
<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>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}>Add line</button>
</div>
{items.map((line, i) => (
<div key={i} className="form-row" style={{ alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<select
value={line.product_id}
onChange={(e) => {
const p = products.find((x) => x.id === Number(e.target.value));
updateLine(i, 'product_id', Number(e.target.value));
if (p) updateLine(i, 'price_at_sale', p.price);
}}
style={{ flex: 2, minWidth: 0 }}
>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<input
type="number"
min="1"
value={line.quantity}
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
style={{ width: 80 }}
/>
<input
type="number"
step="0.01"
value={line.price_at_sale ?? ''}
onChange={(e) => updateLine(i, 'price_at_sale', parseFloat(e.target.value) || 0)}
style={{ width: 80 }}
placeholder="Price"
/>
<button type="button" className="secondary" onClick={() => removeLine(i)}>Remove</button>
</div>
))}
</div>
<p style={{ fontWeight: 600 }}>Total: ${total.toFixed(2)}</p>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit">Save</button>
<button type="button" className="secondary" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
)}
</>
);
}

View File

@ -0,0 +1,178 @@
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 [items, setItems] = useState([]);
const [status, setStatus] = useState('pending');
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,
status,
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);
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)}
>
<option value="">Walk-in</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</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>
<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>
</>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api';
export default function Orders() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statusFilter, setStatusFilter] = useState('');
function load() {
api('/orders')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to load'))))
.then(setOrders)
.catch(setError)
.finally(() => setLoading(false));
}
useEffect(() => {
load();
}, []);
const filtered = statusFilter
? orders.filter((o) => o.status === statusFilter)
: orders;
if (loading) return <p className="text-muted">Loading...</p>;
return (
<>
<h1 className="page-title">Orders</h1>
{error && <p className="error">{error}</p>}
<div className="toolbar" style={{ marginBottom: '1rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{ minWidth: 140 }}
>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="delivered">Delivered</option>
</select>
<Link to="/orders/new">
<button type="button">New order</button>
</Link>
</div>
<div className="card-list table-layout">
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Status</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map((o) => (
<tr key={o.id}>
<td data-label="Order">#{o.id}</td>
<td data-label="Customer">{o.customer_name || 'Walk-in'}</td>
<td data-label="Status">{o.status}</td>
<td data-label="Date">{new Date(o.created_at).toLocaleDateString()}</td>
<td data-label="">
<Link to={`/orders/${o.id}`}>View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{filtered.length === 0 && (
<p className="text-muted">
{statusFilter ? 'No orders with that status.' : 'No orders yet.'}
{' '}
<Link to="/orders/new">Create one</Link>.
</p>
)}
</>
);
}

15
client/vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
},
},
},
});

1605
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "girl-scout-cookies",
"private": true,
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",
"server": "node server/index.js",
"client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "NODE_ENV=production node server/index.js"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1"
},
"devDependencies": {
"concurrently": "^9.1.0"
}
}

63
server/db.js Normal file
View File

@ -0,0 +1,63 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '..', 'data', 'cookies.db');
let db;
function getDb() {
if (!db) {
const dir = path.dirname(dbPath);
const fs = require('fs');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(dbPath);
db.pragma('foreign_keys = ON');
initSchema(db);
}
return db;
}
function initSchema(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL DEFAULT 0,
quantity_on_hand INTEGER NOT NULL DEFAULT 0,
low_stock_threshold INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
address TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER REFERENCES customers(id),
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
price_at_sale REAL NOT NULL,
UNIQUE(order_id, product_id)
);
`);
}
module.exports = { getDb, initSchema };

37
server/index.js Normal file
View File

@ -0,0 +1,37 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const express = require('express');
const cors = require('cors');
const path = require('path');
const authRouter = require('./routes/auth');
const { authMiddleware } = require('./middleware/auth');
const productsRouter = require('./routes/products');
const customersRouter = require('./routes/customers');
const ordersRouter = require('./routes/orders');
const dashboardRouter = require('./routes/dashboard');
const app = express();
const PORT = process.env.PORT || 3002;
app.set('trust proxy', 1);
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api', authMiddleware);
app.use('/api/products', productsRouter);
app.use('/api/customers', customersRouter);
app.use('/api/orders', ordersRouter);
app.use('/api/dashboard', dashboardRouter);
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'client', 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'client', 'dist', 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

89
server/middleware/auth.js Normal file
View File

@ -0,0 +1,89 @@
const crypto = require('crypto');
const COOKIE_NAME = 'session';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function getSecret() {
const secret = process.env.APP_SECRET || process.env.APP_PASSWORD;
return secret || null;
}
function sign(payload) {
const secret = getSecret();
if (!secret) return null;
const data = JSON.stringify(payload);
const hmac = crypto.createHmac('sha256', secret).update(data).digest('hex');
const encoded = Buffer.from(data, 'utf8').toString('base64url');
return `${encoded}.${hmac}`;
}
function verify(token) {
const secret = getSecret();
if (!secret || !token) return false;
const parts = token.split('.');
if (parts.length !== 2) return false;
const [encoded, hmac] = parts;
let data;
try {
data = Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(data).digest('hex');
if (expected !== hmac) return false;
let payload;
try {
payload = JSON.parse(data);
} catch {
return false;
}
if (payload.t && Date.now() - payload.t > MAX_AGE_MS) return false;
return true;
}
function createSessionCookie() {
const token = sign({ t: Date.now() });
if (!token) return null;
const opts = {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: Math.floor(MAX_AGE_MS / 1000),
};
if (process.env.NODE_ENV === 'production') opts.secure = true;
return { token, opts };
}
function clearSessionCookie() {
return {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 0,
};
}
function authMiddleware(req, res, next) {
if (!process.env.APP_PASSWORD) {
return next();
}
const p = req.path || '';
if (p.startsWith('/auth')) return next();
const cookie = req.headers.cookie || '';
const match = cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
const token = match ? match[1].trim() : null;
if (!token || !verify(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
module.exports = {
COOKIE_NAME,
getSecret,
sign,
verify,
createSessionCookie,
clearSessionCookie,
authMiddleware,
};

47
server/routes/auth.js Normal file
View File

@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const {
COOKIE_NAME,
verify,
createSessionCookie,
clearSessionCookie,
} = require('../middleware/auth');
router.post('/login', (req, res) => {
const password = process.env.APP_PASSWORD;
if (!password) {
return res.status(200).json({ ok: true });
}
const submitted = req.body?.password;
if (submitted !== password) {
return res.status(401).json({ error: 'Invalid password' });
}
const session = createSessionCookie();
if (!session) {
return res.status(500).json({ error: 'Auth not configured' });
}
const cookieOpts = { ...session.opts };
if (cookieOpts.secure && !req.secure) cookieOpts.secure = false;
res.cookie(COOKIE_NAME, session.token, cookieOpts);
res.json({ ok: true });
});
router.post('/logout', (req, res) => {
res.cookie(COOKIE_NAME, '', clearSessionCookie());
res.status(204).send();
});
router.get('/me', (req, res) => {
if (!process.env.APP_PASSWORD) {
return res.status(200).json({ ok: true });
}
const cookie = req.headers.cookie || '';
const match = cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
const token = match ? match[1].trim() : null;
if (!token || !verify(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
res.json({ ok: true });
});
module.exports = router;

View File

@ -0,0 +1,82 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
try {
const db = getDb();
const rows = db.prepare('SELECT * FROM customers ORDER BY name').all();
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/:id', (req, res) => {
try {
const db = getDb();
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Customer not found' });
res.json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/', (req, res) => {
try {
const db = getDb();
const { name, phone, email, address, notes } = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Name is required' });
}
const result = db.prepare(
'INSERT INTO customers (name, phone, email, address, notes) VALUES (?, ?, ?, ?, ?)'
).run(
name.trim(),
phone ? String(phone).trim() : null,
email ? String(email).trim() : null,
address ? String(address).trim() : null,
notes ? String(notes).trim() : null
);
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/:id', (req, res) => {
try {
const db = getDb();
const existing = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Customer not found' });
const { name, phone, email, address, notes } = req.body;
const n = name !== undefined ? name.trim() : existing.name;
const ph = phone !== undefined ? (phone ? String(phone).trim() : null) : existing.phone;
const e = email !== undefined ? (email ? String(email).trim() : null) : existing.email;
const a = address !== undefined ? (address ? String(address).trim() : null) : existing.address;
const no = notes !== undefined ? (notes ? String(notes).trim() : null) : existing.notes;
if (!n) return res.status(400).json({ error: 'Name is required' });
db.prepare(
'UPDATE customers SET name = ?, phone = ?, email = ?, address = ?, notes = ? WHERE id = ?'
).run(n, ph, e, a, no, req.params.id);
const row = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
res.json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/:id', (req, res) => {
try {
const db = getDb();
const result = db.prepare('DELETE FROM customers WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Customer not found' });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
try {
const db = getDb();
const lowStock = db.prepare(`
SELECT * FROM products
WHERE low_stock_threshold > 0 AND quantity_on_hand <= low_stock_threshold
ORDER BY quantity_on_hand ASC
`).all();
const recentOrders = 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
LIMIT 10
`).all();
const productCount = db.prepare('SELECT COUNT(*) as count FROM products').get().count;
const customerCount = db.prepare('SELECT COUNT(*) as count FROM customers').get().count;
const orderCount = db.prepare('SELECT COUNT(*) as count FROM orders').get().count;
res.json({
lowStock,
recentOrders,
productCount,
customerCount,
orderCount,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

159
server/routes/orders.js Normal file
View File

@ -0,0 +1,159 @@
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 deductStockForOrder(db, orderId, newItems, existingItemsOverride) {
const existing = existingItemsOverride !== undefined
? existingItemsOverride
: db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(orderId);
const byProduct = {};
for (const e of existing) byProduct[e.product_id] = (byProduct[e.product_id] || 0) + e.quantity;
for (const it of newItems) {
const pid = it.product_id;
const qty = Number(it.quantity) || 0;
byProduct[pid] = (byProduct[pid] || 0) - qty;
}
for (const [productId, delta] of Object.entries(byProduct)) {
if (delta === 0) continue;
const product = db.prepare('SELECT quantity_on_hand FROM products WHERE id = ?').get(productId);
if (!product) throw new Error(`Product ${productId} not found`);
const newQty = product.quantity_on_hand + delta;
if (newQty < 0) throw new Error(`Insufficient stock for product id ${productId}`);
db.prepare('UPDATE products SET quantity_on_hand = ? WHERE id = ?').run(newQty, 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, status = 'pending', notes, items = [] } = req.body;
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, quantity_on_hand, price 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' });
if (product.quantity_on_hand < qty) {
return res.status(400).json({ error: `Insufficient stock for ${product.id}` });
}
}
const result = db.prepare(
'INSERT INTO orders (customer_id, status, notes) VALUES (?, ?, ?)'
).run(customer_id || null, status, notes || null);
const orderId = result.lastInsertRowid;
applyOrderItems(db, orderId, items);
deductStockForOrder(db, orderId, items, []);
db.prepare('UPDATE orders SET updated_at = datetime(\'now\') WHERE id = ?').run(orderId);
const order = getOrderWithItems(db, orderId);
res.status(201).json(order);
} catch (err) {
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 } = req.body;
if (items !== undefined) {
if (!Array.isArray(items)) return res.status(400).json({ error: 'items must be an array' });
const existingItems = db.prepare('SELECT product_id, quantity FROM order_items WHERE order_id = ?').all(req.params.id);
const newItems = items.map(it => ({ product_id: it.product_id, quantity: Number(it.quantity) || 0 }));
for (const it of newItems) {
const product = db.prepare('SELECT id, quantity_on_hand FROM products WHERE id = ?').get(it.product_id);
if (!product) return res.status(400).json({ error: `Product ${it.product_id} not found` });
}
deductStockForOrder(db, req.params.id, newItems, existingItems);
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;
db.prepare('UPDATE orders SET customer_id = ?, status = ?, notes = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(cid, st, no, req.params.id);
const updated = getOrderWithItems(db, req.params.id);
res.json(updated);
} catch (err) {
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 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('DELETE FROM order_items WHERE order_id = ?').run(req.params.id);
db.prepare('DELETE FROM orders WHERE id = ?').run(req.params.id);
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

97
server/routes/products.js Normal file
View File

@ -0,0 +1,97 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
router.get('/', (req, res) => {
try {
const db = getDb();
const rows = db.prepare('SELECT * FROM products ORDER BY name').all();
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/:id', (req, res) => {
try {
const db = getDb();
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Product not found' });
res.json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/', (req, res) => {
try {
const db = getDb();
const { name, price = 0, quantity_on_hand = 0, low_stock_threshold = 0 } = req.body;
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Name is required' });
}
const result = db.prepare(
'INSERT INTO products (name, price, quantity_on_hand, low_stock_threshold) VALUES (?, ?, ?, ?)'
).run(name.trim(), Number(price), Number(quantity_on_hand), Number(low_stock_threshold));
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.put('/:id', (req, res) => {
try {
const db = getDb();
const { name, price, quantity_on_hand, low_stock_threshold } = req.body;
const existing = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Product not found' });
const n = name !== undefined ? name.trim() : existing.name;
const p = price !== undefined ? Number(price) : existing.price;
const q = quantity_on_hand !== undefined ? Number(quantity_on_hand) : existing.quantity_on_hand;
const t = low_stock_threshold !== undefined ? Number(low_stock_threshold) : existing.low_stock_threshold;
if (!n) return res.status(400).json({ error: 'Name is required' });
db.prepare(
'UPDATE products SET name = ?, price = ?, quantity_on_hand = ?, low_stock_threshold = ? WHERE id = ?'
).run(n, p, q, t, req.params.id);
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
res.json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.patch('/:id/stock', (req, res) => {
try {
const db = getDb();
const { adjustment } = req.body;
if (adjustment === undefined || adjustment === null) {
return res.status(400).json({ error: 'adjustment is required' });
}
const delta = Number(adjustment);
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Product not found' });
const newQty = row.quantity_on_hand + delta;
if (newQty < 0) {
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);
const updated = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
res.json(updated);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/:id', (req, res) => {
try {
const db = getDb();
const result = db.prepare('DELETE FROM products WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Product not found' });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;