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:
commit
b0e4e977c1
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git init:*)",
|
||||
"Bash(git remote add:*)",
|
||||
"Bash(git config:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
20
.cursor/debug.log
Normal file
20
.cursor/debug.log
Normal 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
6
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
client/dist/
|
||||
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal 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
75
README.md
Normal 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
12
client/index.html
Normal 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
1849
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
client/package.json
Normal file
20
client/package.json
Normal 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
60
client/src/App.jsx
Normal 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
22
client/src/api.js
Normal 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);
|
||||
}
|
||||
72
client/src/components/Layout.jsx
Normal file
72
client/src/components/Layout.jsx
Normal 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
415
client/src/index.css
Normal 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
10
client/src/main.jsx
Normal 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>
|
||||
);
|
||||
203
client/src/pages/Customers.jsx
Normal file
203
client/src/pages/Customers.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
client/src/pages/Dashboard.jsx
Normal file
88
client/src/pages/Dashboard.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
219
client/src/pages/Inventory.jsx
Normal file
219
client/src/pages/Inventory.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
client/src/pages/Login.jsx
Normal file
57
client/src/pages/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
client/src/pages/OrderDetail.jsx
Normal file
194
client/src/pages/OrderDetail.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
178
client/src/pages/OrderNew.jsx
Normal file
178
client/src/pages/OrderNew.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
client/src/pages/Orders.jsx
Normal file
87
client/src/pages/Orders.jsx
Normal 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
15
client/vite.config.js
Normal 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
1605
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
63
server/db.js
Normal 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
37
server/index.js
Normal 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
89
server/middleware/auth.js
Normal 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
47
server/routes/auth.js
Normal 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;
|
||||
82
server/routes/customers.js
Normal file
82
server/routes/customers.js
Normal 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;
|
||||
35
server/routes/dashboard.js
Normal file
35
server/routes/dashboard.js
Normal 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
159
server/routes/orders.js
Normal 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
97
server/routes/products.js
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user