initial version

This commit is contained in:
2026-07-11 19:47:40 -04:00
parent b819a0dc6c
commit 362806413e
26 changed files with 3400 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
data/*.db
data/*.db-journal
data/*.db-wal
data/*.db-shm
*.log
+1988
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "hardingsclocks-atrex",
"version": "1.0.0",
"description": "Repair tracker for Harding's Clocks",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && cp src/schema.sql dist/schema.sql && cp -r src/views dist/views",
"start": "node dist/server.js",
"backup": "tsx scripts/backup.ts"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"htmx.org": "^1.9.12",
"nunjucks": "^3.2.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/node": "^20.14.10",
"@types/nunjucks": "^3.2.6",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}
+378
View File
@@ -0,0 +1,378 @@
:root {
/* Surfaces */
--color-bg: #F2E8D5; /* aged parchment — page background */
--color-surface: #EAD9BE; /* light oak — cards, panels */
--color-border: #C4A882; /* tan grain — dividers, input borders */
/* Wood tones */
--color-wood-dark: #4A2810; /* dark mahogany — nav, headings */
--color-wood-mid: #8B5E3C; /* medium walnut — buttons, interactive */
--color-wood-light: #B5842A; /* brass — hover states, focus rings */
/* Text */
--color-text: #2C1A0E; /* near-black brown — body copy */
--color-text-muted: #7A5C42; /* faded oak — labels, secondary info */
--color-text-on-dark: #F2E8D5; /* parchment — text on dark backgrounds */
/* Status */
--color-status-open: #8B5E3C; /* walnut */
--color-status-inprogress:#B5842A; /* brass */
--color-status-done: #5C7A42; /* muted forest green */
--color-status-paid: #3D5C2A; /* deeper green */
--font-heading: Georgia, 'Times New Roman', serif;
--font-body: system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.4;
}
h1, h2, h3 {
font-family: var(--font-heading);
color: var(--color-wood-dark);
margin: 0 0 0.5em;
}
a {
color: var(--color-wood-mid);
}
nav {
background: var(--color-wood-dark);
padding: 0.75rem 1.5rem;
display: flex;
gap: 1.5rem;
align-items: center;
}
nav a {
color: var(--color-text-on-dark);
text-decoration: none;
font-family: var(--font-body);
}
nav a.brand {
font-family: var(--font-heading);
font-weight: bold;
font-size: 1.1rem;
margin-right: auto;
}
nav a:hover {
color: var(--color-wood-light);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.empty {
color: var(--color-text-muted);
font-style: italic;
}
.hint {
color: var(--color-text-muted);
font-size: 0.9rem;
}
/* Forms */
fieldset {
border: 1px solid var(--color-border);
border-radius: 6px;
margin-bottom: 1.25rem;
padding: 1rem;
}
legend {
font-family: var(--font-heading);
color: var(--color-wood-dark);
padding: 0 0.5rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 0.75rem;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1.5rem;
}
input, select, textarea {
font-family: var(--font-body);
font-size: 1rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--color-wood-light);
outline-offset: 1px;
}
button, .button-link {
font-family: var(--font-body);
font-size: 0.95rem;
background: var(--color-wood-mid);
color: var(--color-text-on-dark);
border: none;
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
button:hover, .button-link:hover {
background: var(--color-wood-light);
}
button.danger {
background: transparent;
border: 1px solid var(--color-wood-dark);
color: var(--color-wood-dark);
}
.icon-button {
background: transparent;
color: var(--color-text-muted);
padding: 0.2rem 0.5rem;
}
.inline-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
margin-top: 1rem;
}
.search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
/* Tables */
.data-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface);
}
.data-table th, .data-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.data-table th {
color: var(--color-text-muted);
font-weight: normal;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.03em;
}
.row-link {
cursor: pointer;
}
.row-link:hover {
background: var(--color-bg);
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
color: var(--color-text-on-dark);
}
.status-open { background: var(--color-status-open); }
.status-in-progress { background: var(--color-status-inprogress); }
.status-done { background: var(--color-status-done); }
.status-paid { background: var(--color-status-paid); }
/* Totals */
.totals-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.5rem 1.5rem;
margin: 1rem 0 0;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.totals-summary div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.totals-summary dt {
color: var(--color-text-muted);
}
.totals-summary dd {
margin: 0;
font-weight: bold;
}
.totals-summary .balance-due dd {
color: var(--color-wood-dark);
font-size: 1.15rem;
}
/* Work log */
.work-log-list {
list-style: none;
padding: 0;
margin: 0;
}
.work-log-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.work-log-item time {
display: block;
color: var(--color-text-muted);
font-size: 0.8rem;
}
/* Invoice */
.invoice-toolbar {
padding: 1rem;
text-align: right;
}
.invoice {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: white;
color: #111;
}
.invoice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid var(--color-wood-dark);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.invoice-meta {
text-align: right;
}
.invoice-parties {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.invoice-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
}
.invoice-table th, .invoice-table td {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #ccc;
}
.invoice-totals dl {
max-width: 320px;
margin-left: auto;
}
.invoice-totals div {
display: flex;
justify-content: space-between;
padding: 0.2rem 0;
}
.invoice-totals .grand {
border-top: 1px solid #999;
margin-top: 0.3rem;
padding-top: 0.4rem;
font-weight: bold;
}
.invoice-totals .balance-due {
font-weight: bold;
font-size: 1.1rem;
}
.invoice-footer {
margin-top: 2rem;
text-align: center;
color: #666;
}
@media print {
.no-print { display: none !important; }
body { background: white; }
.invoice { box-shadow: none; padding: 0; }
}
+15
View File
@@ -0,0 +1,15 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
const backupDir = process.env.BACKUP_DIR || '/backups';
const date = execSync('date +%F').toString().trim();
fs.mkdirSync(backupDir, { recursive: true });
const db = new Database(path.join(process.cwd(), 'data', 'app.db'));
await db.backup(path.join(backupDir, `app-${date}.db`));
db.close();
console.log(`Backed up to ${path.join(backupDir, `app-${date}.db`)}`);
+33
View File
@@ -0,0 +1,33 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'app.db');
const isNewDb = !fs.existsSync(dbPath);
export const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
if (isNewDb) {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
db.exec(schema);
}
const FIRST_ORDER_NUMBER = 8246;
/** Allocates the next order number inside the caller's transaction. */
export function nextOrderNumber(): number {
const row = db
.prepare(`SELECT COALESCE(MAX(order_number), ?) + 1 AS next FROM service_orders`)
.get(FIRST_ORDER_NUMBER) as { next: number };
return row.next;
}
+18
View File
@@ -0,0 +1,18 @@
/** Formats integer cents as a "$1,234.56" string. */
export function formatCents(cents: number | null | undefined): string {
const value = (cents ?? 0) / 100;
const sign = value < 0 ? '-' : '';
return `${sign}$${Math.abs(value).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
/** Parses user input like "1.75", "$1.75", "-5" into integer cents. Returns 0 for blank/invalid input. */
export function parseCents(input: string | null | undefined): number {
if (!input) return 0;
const cleaned = input.replace(/[$,\s]/g, '');
const value = Number.parseFloat(cleaned);
if (Number.isNaN(value)) return 0;
return Math.round(value * 100);
}
+25
View File
@@ -0,0 +1,25 @@
import { Router } from 'express';
import { db } from '../db.js';
const router = Router();
/** HTMX fragment: <option> list of a customer's clocks, for the new-order form's clock picker. */
router.get('/by-customer', (req, res) => {
const customerId = req.query.existing_customer_id;
if (!customerId) {
res.render('customers/_clock_options.njk', { clocks: [] });
return;
}
const clocks = db
.prepare(
`SELECT id, manufacturer, model, description
FROM clocks WHERE customer_id = ? ORDER BY id DESC`
)
.all(customerId);
res.render('customers/_clock_options.njk', { clocks });
});
export default router;
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express';
import { db } from '../db.js';
const router = Router();
router.get('/', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
const customers = q
? db
.prepare(
`SELECT * FROM customers
WHERE name LIKE ? OR phone LIKE ? OR email LIKE ?
ORDER BY name`
)
.all(`%${q}%`, `%${q}%`, `%${q}%`)
: db.prepare(`SELECT * FROM customers ORDER BY name`).all();
res.render('customers/list.njk', { customers, q });
});
export default router;
+12
View File
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { getOrderDetail } from './orders.js';
const router = Router();
router.get('/orders/:id/invoice', (req, res) => {
const order = getOrderDetail(req.params.id);
if (!order) return res.status(404).send('Order not found');
res.render('invoice.njk', { order });
});
export default router;
+279
View File
@@ -0,0 +1,279 @@
import { Router } from 'express';
import { db, nextOrderNumber } from '../db.js';
import { parseCents } from '../money.js';
const router = Router();
export const JOB_TYPES = [
'31 DAY',
'CLEANING',
'ESTIMATE',
'FIT UP/REPLACE',
'HOUSE CALL',
'MOVE CLOCK',
'OVERHAUL',
'SERVICE',
'WALK IN SERVICE',
];
export const STATUSES = ['OPEN', 'IN PROGRESS', 'DONE', 'PAID'];
function todayStr(): string {
return new Date().toISOString().slice(0, 10);
}
/** Fetches a full order: customer, clock, line items, work log, totals. Null if missing/deleted. */
export function getOrderDetail(id: string | number) {
const order = db
.prepare(
`SELECT so.*, c.name AS customer_name, c.address_line1, c.address_line2, c.city, c.state, c.zip,
c.phone, c.alt_phone, c.email,
cl.manufacturer, cl.model, cl.serial_number, cl.alternate_id, cl.reference,
cl.description AS clock_description
FROM service_orders so
JOIN customers c ON c.id = so.customer_id
LEFT JOIN clocks cl ON cl.id = so.clock_id
WHERE so.id = ? AND so.deleted_at IS NULL`
)
.get(id) as Record<string, unknown> | undefined;
if (!order) return null;
const lineItems = db
.prepare(`SELECT * FROM line_items WHERE service_order_id = ? ORDER BY sort_order, id`)
.all(id);
const workLog = db
.prepare(`SELECT * FROM work_log WHERE service_order_id = ? ORDER BY logged_at DESC, id DESC`)
.all(id);
const totals = (db
.prepare(`SELECT * FROM order_totals WHERE service_order_id = ?`)
.get(id) as Record<string, unknown> | undefined) ?? {
total_parts: 0,
total_labor: 0,
total_sublet: 0,
grand_total: 0,
balance_due: 0,
};
return { ...order, lineItems, workLog, totals };
}
router.get('/', (_req, res) => {
const orders = db
.prepare(
`SELECT so.id, so.order_number, so.status, so.job_type, so.date_in, so.due_date,
c.name AS customer_name, ot.balance_due, ot.grand_total
FROM service_orders so
JOIN customers c ON c.id = so.customer_id
LEFT JOIN order_totals ot ON ot.service_order_id = so.id
WHERE so.deleted_at IS NULL
ORDER BY CASE so.status
WHEN 'OPEN' THEN 0
WHEN 'IN PROGRESS' THEN 1
WHEN 'DONE' THEN 2
WHEN 'PAID' THEN 3
ELSE 4
END,
so.date_in DESC,
so.order_number DESC`
)
.all();
res.render('orders/list.njk', { orders });
});
router.get('/orders/new', (_req, res) => {
const customers = db.prepare('SELECT id, name FROM customers ORDER BY name').all();
res.render('orders/new.njk', { customers, jobTypes: JOB_TYPES, today: todayStr() });
});
router.post('/orders', (req, res) => {
const b = req.body as Record<string, string>;
const create = db.transaction(() => {
let customerId = b.existing_customer_id ? Number(b.existing_customer_id) : null;
if (!customerId) {
const name = (b.new_customer_name || '').trim();
if (!name) {
throw new Error('Customer name is required when not selecting an existing customer.');
}
const info = db
.prepare(
`INSERT INTO customers
(name, address_line1, address_line2, city, state, zip, phone, alt_phone, email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
name,
b.new_customer_address_line1 || null,
b.new_customer_address_line2 || null,
b.new_customer_city || null,
b.new_customer_state || null,
b.new_customer_zip || null,
b.new_customer_phone || null,
b.new_customer_alt_phone || null,
b.new_customer_email || null
);
customerId = Number(info.lastInsertRowid);
}
let clockId = b.existing_clock_id ? Number(b.existing_clock_id) : null;
if (!clockId) {
const newClockFields = [
b.new_clock_manufacturer,
b.new_clock_model,
b.new_clock_serial_number,
b.new_clock_alternate_id,
b.new_clock_reference,
b.new_clock_description,
];
const hasNewClock = newClockFields.some((v) => v && v.trim());
if (hasNewClock) {
const info = db
.prepare(
`INSERT INTO clocks
(customer_id, manufacturer, model, serial_number, alternate_id, reference, description)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
customerId,
b.new_clock_manufacturer || null,
b.new_clock_model || null,
b.new_clock_serial_number || null,
b.new_clock_alternate_id || null,
b.new_clock_reference || null,
b.new_clock_description || null
);
clockId = Number(info.lastInsertRowid);
}
}
const orderNumber = nextOrderNumber();
const orderInfo = db
.prepare(
`INSERT INTO service_orders
(order_number, customer_id, clock_id, job_type, service_rep, technician,
date_in, due_date, cust_po, terms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
orderNumber,
customerId,
clockId,
b.job_type || null,
b.service_rep || null,
b.technician || null,
b.date_in || todayStr(),
b.due_date || null,
b.cust_po || null,
b.terms || null
);
return Number(orderInfo.lastInsertRowid);
});
try {
const orderId = create();
res.redirect(303, `/orders/${orderId}`);
} catch (err) {
res.status(400).send((err as Error).message);
}
});
router.get('/orders/:id', (req, res) => {
const order = getOrderDetail(req.params.id);
if (!order) return res.status(404).send('Order not found');
res.render('orders/detail.njk', { order, jobTypes: JOB_TYPES, statuses: STATUSES });
});
router.post('/orders/:id/line-items', (req, res) => {
const orderId = req.params.id;
const b = req.body as Record<string, string>;
const kind = ['labor', 'part', 'sublet'].includes(b.kind) ? b.kind : 'labor';
const quantity = b.quantity ? Number(b.quantity) : 1;
const unitPrice = parseCents(b.unit_price);
const countRow = db
.prepare('SELECT COUNT(*) AS n FROM line_items WHERE service_order_id = ?')
.get(orderId) as { n: number };
const info = db
.prepare(
`INSERT INTO line_items (service_order_id, kind, stock_code, description, quantity, unit_price, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(orderId, kind, b.stock_code || null, b.description || null, quantity, unitPrice, countRow.n);
const line = db.prepare('SELECT * FROM line_items WHERE id = ?').get(info.lastInsertRowid);
const totals = db.prepare('SELECT * FROM order_totals WHERE service_order_id = ?').get(orderId);
res.render('orders/_line_item_added.njk', { line, order: { id: orderId }, totals, oob: true });
});
router.delete('/orders/:id/line-items/:lineId', (req, res) => {
db.prepare('DELETE FROM line_items WHERE id = ? AND service_order_id = ?').run(
req.params.lineId,
req.params.id
);
const totals = db.prepare('SELECT * FROM order_totals WHERE service_order_id = ?').get(req.params.id);
res.render('orders/_totals.njk', { totals, oob: true });
});
router.post('/orders/:id/work-log', (req, res) => {
const note = ((req.body.note as string) || '').trim();
if (!note) return res.status(400).send('Note is required');
const info = db
.prepare('INSERT INTO work_log (service_order_id, note) VALUES (?, ?)')
.run(req.params.id, note);
const entry = db.prepare('SELECT * FROM work_log WHERE id = ?').get(info.lastInsertRowid);
res.render('orders/_work_log_item.njk', { entry });
});
router.post('/orders/:id', (req, res) => {
const b = req.body as Record<string, string>;
db.prepare(
`UPDATE service_orders SET
job_type = ?, service_rep = ?, technician = ?, status = ?,
date_out = ?, due_date = ?, cust_po = ?, terms = ?,
tax = ?, shipping = ?, pickup_delivery = ?, service_call = ?, disposal = ?, amount_paid = ?
WHERE id = ?`
).run(
b.job_type || null,
b.service_rep || null,
b.technician || null,
STATUSES.includes(b.status) ? b.status : 'OPEN',
b.date_out || null,
b.due_date || null,
b.cust_po || null,
b.terms || null,
parseCents(b.tax),
parseCents(b.shipping),
parseCents(b.pickup_delivery),
parseCents(b.service_call),
parseCents(b.disposal),
parseCents(b.amount_paid),
req.params.id
);
const order = getOrderDetail(req.params.id);
if (!order) return res.status(404).send('Order not found');
res.render('orders/_order_summary.njk', { order, jobTypes: JOB_TYPES, statuses: STATUSES });
});
router.delete('/orders/:id', (req, res) => {
db.prepare(`UPDATE service_orders SET deleted_at = datetime('now') WHERE id = ?`).run(req.params.id);
res.set('HX-Redirect', '/');
res.sendStatus(200);
});
export default router;
+93
View File
@@ -0,0 +1,93 @@
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
alt_phone TEXT,
email TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE clocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id),
manufacturer TEXT,
model TEXT,
serial_number TEXT,
alternate_id TEXT,
reference TEXT,
description TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE service_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number INTEGER UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
clock_id INTEGER REFERENCES clocks(id),
job_type TEXT CHECK (job_type IN (
'31 DAY','CLEANING','ESTIMATE','FIT UP/REPLACE',
'HOUSE CALL','MOVE CLOCK','OVERHAUL','SERVICE',
'WALK IN SERVICE')),
service_rep TEXT,
technician TEXT,
status TEXT DEFAULT 'OPEN' CHECK (status IN ('OPEN','IN PROGRESS','DONE','PAID')),
date_in TEXT DEFAULT (date('now')),
date_out TEXT,
due_date TEXT,
cust_po TEXT,
terms TEXT,
-- money summary (cents)
tax INTEGER DEFAULT 0,
shipping INTEGER DEFAULT 0,
pickup_delivery INTEGER DEFAULT 0,
service_call INTEGER DEFAULT 0,
disposal INTEGER DEFAULT 0,
amount_paid INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT -- soft delete; NULL = active
);
CREATE TABLE line_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'labor', -- 'labor' | 'part' | 'sublet'
stock_code TEXT,
description TEXT,
quantity REAL DEFAULT 1,
unit_price INTEGER DEFAULT 0, -- cents
sort_order INTEGER DEFAULT 0
);
CREATE TABLE work_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_order_id INTEGER NOT NULL REFERENCES service_orders(id) ON DELETE CASCADE,
note TEXT NOT NULL,
logged_at TEXT DEFAULT (datetime('now'))
);
-- ROUND() keeps cents integer despite REAL quantity (e.g. 1.5 hrs × 175_00 ¢).
CREATE VIEW order_totals AS
SELECT
so.id AS service_order_id,
COALESCE(SUM(CASE WHEN li.kind='part' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_parts,
COALESCE(SUM(CASE WHEN li.kind='labor' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_labor,
COALESCE(SUM(CASE WHEN li.kind='sublet' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_sublet,
COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total,
COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal
- so.amount_paid AS balance_due
FROM service_orders so
LEFT JOIN line_items li ON li.service_order_id = so.id
WHERE so.deleted_at IS NULL
GROUP BY so.id;
CREATE INDEX idx_line_items_order ON line_items(service_order_id);
CREATE INDEX idx_work_log_order ON work_log(service_order_id);
CREATE INDEX idx_clocks_customer ON clocks(customer_id);
CREATE INDEX idx_orders_customer ON service_orders(customer_id);
+45
View File
@@ -0,0 +1,45 @@
import express from 'express';
import nunjucks from 'nunjucks';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { formatCents } from './money.js';
import ordersRouter from './routes/orders.js';
import customersRouter from './routes/customers.js';
import clocksRouter from './routes/clocks.js';
import invoiceRouter from './routes/invoice.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const env = nunjucks.configure(path.join(__dirname, 'views'), {
autoescape: true,
express: app,
});
env.addFilter('money', (cents: number) => formatCents(cents));
env.addFilter('lineTotal', (unitPrice: number, quantity: number) =>
formatCents(Math.round((quantity ?? 1) * (unitPrice ?? 0)))
);
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(process.cwd(), 'public')));
app.use(
'/vendor/htmx',
express.static(path.join(process.cwd(), 'node_modules/htmx.org/dist'))
);
app.use(ordersRouter);
app.use('/customers', customersRouter);
app.use('/clocks', clocksRouter);
app.use(invoiceRouter);
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).send('Something went wrong.');
});
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
const HOST = '127.0.0.1';
app.listen(PORT, HOST, () => {
console.log(`Harding's Clocks running at http://${HOST}:${PORT}`);
});
+6
View File
@@ -0,0 +1,6 @@
<option value="">— new clock —</option>
{% for clock in clocks %}
<option value="{{ clock.id }}">
{{- clock.manufacturer or '' }} {{ clock.model or '' }}{% if clock.description %} ({{ clock.description }}){% endif -%}
</option>
{% endfor %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "layout.njk" %}
{% block title %}Customers — Harding's Clocks{% endblock %}
{% block content %}
<div class="page-header">
<h1>Customers</h1>
</div>
<form class="search-form" method="get" action="/customers">
<input type="search" name="q" placeholder="Search name, phone, or email…" value="{{ q }}">
<button type="submit">Search</button>
{% if q %}<a class="button-link" href="/customers">Clear</a>{% endif %}
</form>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Phone</th>
<th>Email</th>
<th>Address</th>
</tr>
</thead>
<tbody>
{% for c in customers %}
<tr>
<td>{{ c.name }}</td>
<td>{{ c.phone or '' }}</td>
<td>{{ c.email or '' }}</td>
<td>
{%- if c.address_line1 -%}
{{ c.address_line1 }}{% if c.city %}, {{ c.city }}{% endif %}{% if c.state %}, {{ c.state }}{% endif %} {{ c.zip or '' }}
{%- endif -%}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="empty">No customers found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+93
View File
@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invoice #{{ order.order_number }} — Harding's Clocks</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="invoice-body">
<div class="invoice-toolbar no-print">
<button type="button" onclick="window.print()">Print</button>
</div>
<div class="invoice">
<header class="invoice-header">
<div>
<h1>Harding's Clocks</h1>
<p>Clock Sales &amp; Repair</p>
</div>
<div class="invoice-meta">
<p><strong>Invoice #{{ order.order_number }}</strong></p>
<p>Date In: {{ order.date_in or '' }}</p>
{% if order.date_out %}<p>Date Out: {{ order.date_out }}</p>{% endif %}
{% if order.due_date %}<p>Due: {{ order.due_date }}</p>{% endif %}
<p>Status: {{ order.status }}</p>
</div>
</header>
<section class="invoice-parties">
<div>
<h2>Ship To</h2>
<p>{{ order.customer_name }}</p>
{% if order.address_line1 %}<p>{{ order.address_line1 }}</p>{% endif %}
{% if order.address_line2 %}<p>{{ order.address_line2 }}</p>{% endif %}
{% if order.city or order.state or order.zip %}<p>{{ order.city or '' }}{% if order.state %}, {{ order.state }}{% endif %} {{ order.zip or '' }}</p>{% endif %}
{% if order.phone %}<p>{{ order.phone }}</p>{% endif %}
</div>
<div>
<h2>Clock</h2>
{% if order.manufacturer or order.model %}<p>{{ order.manufacturer or '' }} {{ order.model or '' }}</p>{% endif %}
{% if order.serial_number %}<p>Serial: {{ order.serial_number }}</p>{% endif %}
{% if order.reference %}<p>Reference: {{ order.reference }}</p>{% endif %}
</div>
<div>
<h2>Order Info</h2>
<p>Job Type: {{ order.job_type or '' }}</p>
{% if order.service_rep %}<p>Rep: {{ order.service_rep }}</p>{% endif %}
{% if order.technician %}<p>Technician: {{ order.technician }}</p>{% endif %}
{% if order.cust_po %}<p>PO: {{ order.cust_po }}</p>{% endif %}
{% if order.terms %}<p>Terms: {{ order.terms }}</p>{% endif %}
</div>
</section>
<table class="invoice-table">
<thead>
<tr><th>Type</th><th>Stock #</th><th>Description</th><th>Qty</th><th>Unit Price</th><th>Total</th></tr>
</thead>
<tbody>
{% for line in order.lineItems %}
<tr>
<td>{{ line.kind }}</td>
<td>{{ line.stock_code or '' }}</td>
<td>{{ line.description or '' }}</td>
<td>{{ line.quantity }}</td>
<td>{{ line.unit_price | money }}</td>
<td>{{ line.unit_price | lineTotal(line.quantity) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="invoice-totals">
<dl>
<div><dt>Parts</dt><dd>{{ order.totals.total_parts | money }}</dd></div>
<div><dt>Labor</dt><dd>{{ order.totals.total_labor | money }}</dd></div>
<div><dt>Sublet</dt><dd>{{ order.totals.total_sublet | money }}</dd></div>
<div><dt>Tax</dt><dd>{{ order.tax | money }}</dd></div>
<div><dt>Shipping</dt><dd>{{ order.shipping | money }}</dd></div>
<div><dt>Pickup/Delivery</dt><dd>{{ order.pickup_delivery | money }}</dd></div>
<div><dt>Service Call</dt><dd>{{ order.service_call | money }}</dd></div>
<div><dt>Disposal</dt><dd>{{ order.disposal | money }}</dd></div>
<div class="grand"><dt>Grand Total</dt><dd>{{ order.totals.grand_total | money }}</dd></div>
<div><dt>Amount Paid</dt><dd>{{ order.amount_paid | money }}</dd></div>
<div class="balance-due"><dt>Balance Due</dt><dd>{{ order.totals.balance_due | money }}</dd></div>
</dl>
</section>
<footer class="invoice-footer">
<p>Thank you for your business.</p>
</footer>
</div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Harding's Clocks{% endblock %}</title>
<link rel="stylesheet" href="/css/style.css">
<script src="/vendor/htmx/htmx.min.js" defer></script>
</head>
<body>
<nav class="no-print">
<a class="brand" href="/">Harding's Clocks</a>
<a href="/">Orders</a>
<a href="/orders/new">New Order</a>
<a href="/customers">Customers</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
{% include "orders/_line_item_row.njk" %}
{% include "orders/_totals.njk" %}
+15
View File
@@ -0,0 +1,15 @@
<tr id="line-{{ line.id }}">
<td>{{ line.kind }}</td>
<td>{{ line.stock_code or '' }}</td>
<td>{{ line.description or '' }}</td>
<td>{{ line.quantity }}</td>
<td>{{ line.unit_price | money }}</td>
<td>{{ line.unit_price | lineTotal(line.quantity) }}</td>
<td>
<button type="button" class="icon-button"
hx-delete="/orders/{{ order.id }}/line-items/{{ line.id }}"
hx-target="closest tr"
hx-swap="outerHTML"
hx-confirm="Remove this line?">✕</button>
</td>
</tr>
+44
View File
@@ -0,0 +1,44 @@
{% set totals = order.totals %}
<div id="order-summary" class="card">
<form hx-post="/orders/{{ order.id }}" hx-target="#order-summary" hx-swap="outerHTML">
<div class="grid-2">
<label>
Status
<select name="status">
{% for s in statuses %}
<option value="{{ s }}" {% if order.status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</label>
<label>
Job type
<select name="job_type">
<option value="">— select —</option>
{% for jt in jobTypes %}
<option value="{{ jt }}" {% if order.job_type == jt %}selected{% endif %}>{{ jt }}</option>
{% endfor %}
</select>
</label>
<label>Service rep <input type="text" name="service_rep" value="{{ order.service_rep or '' }}"></label>
<label>Technician <input type="text" name="technician" value="{{ order.technician or '' }}"></label>
<label>Date in <input type="text" value="{{ order.date_in or '' }}" disabled></label>
<label>Date out <input type="date" name="date_out" value="{{ order.date_out or '' }}"></label>
<label>Due date <input type="date" name="due_date" value="{{ order.due_date or '' }}"></label>
<label>Customer PO <input type="text" name="cust_po" value="{{ order.cust_po or '' }}"></label>
<label>Terms <input type="text" name="terms" value="{{ order.terms or '' }}"></label>
</div>
<div class="grid-2">
<label>Tax <input type="text" name="tax" value="{{ (order.tax / 100) | round(2) }}"></label>
<label>Shipping <input type="text" name="shipping" value="{{ (order.shipping / 100) | round(2) }}"></label>
<label>Pickup/Delivery <input type="text" name="pickup_delivery" value="{{ (order.pickup_delivery / 100) | round(2) }}"></label>
<label>Service call <input type="text" name="service_call" value="{{ (order.service_call / 100) | round(2) }}"></label>
<label>Disposal <input type="text" name="disposal" value="{{ (order.disposal / 100) | round(2) }}"></label>
<label>Amount paid <input type="text" name="amount_paid" value="{{ (order.amount_paid / 100) | round(2) }}"></label>
</div>
<button type="submit">Save</button>
</form>
{% include "orders/_totals.njk" %}
</div>
+7
View File
@@ -0,0 +1,7 @@
<dl id="totals-summary" class="totals-summary"{% if oob %} hx-swap-oob="true"{% endif %}>
<div><dt>Parts</dt><dd>{{ totals.total_parts | money }}</dd></div>
<div><dt>Labor</dt><dd>{{ totals.total_labor | money }}</dd></div>
<div><dt>Sublet</dt><dd>{{ totals.total_sublet | money }}</dd></div>
<div><dt>Grand Total</dt><dd>{{ totals.grand_total | money }}</dd></div>
<div class="balance-due"><dt>Balance Due</dt><dd>{{ totals.balance_due | money }}</dd></div>
</dl>
+4
View File
@@ -0,0 +1,4 @@
<li id="log-{{ entry.id }}" class="work-log-item">
<time>{{ entry.logged_at }}</time>
<p>{{ entry.note }}</p>
</li>
+90
View File
@@ -0,0 +1,90 @@
{% extends "layout.njk" %}
{% block title %}Order #{{ order.order_number }} — Harding's Clocks{% endblock %}
{% block content %}
<div class="page-header">
<h1>Order #{{ order.order_number }}</h1>
<div class="header-actions">
<a class="button-link" href="/orders/{{ order.id }}/invoice" target="_blank">Print Invoice</a>
<button type="button" class="danger"
hx-delete="/orders/{{ order.id }}"
hx-confirm="Delete this order? It can be recovered later if needed.">
Delete Order
</button>
</div>
</div>
<section class="card two-col">
<div>
<h2>Customer</h2>
<p>{{ order.customer_name }}</p>
{% if order.address_line1 %}<p>{{ order.address_line1 }}{% if order.address_line2 %}, {{ order.address_line2 }}{% endif %}</p>{% endif %}
{% if order.city or order.state or order.zip %}<p>{{ order.city or '' }}{% if order.state %}, {{ order.state }}{% endif %} {{ order.zip or '' }}</p>{% endif %}
{% if order.phone %}<p>{{ order.phone }}{% if order.alt_phone %} / {{ order.alt_phone }}{% endif %}</p>{% endif %}
{% if order.email %}<p>{{ order.email }}</p>{% endif %}
</div>
<div>
<h2>Clock</h2>
{% if order.manufacturer or order.model %}
<p>{{ order.manufacturer or '' }} {{ order.model or '' }}</p>
{% endif %}
{% if order.serial_number %}<p>Serial: {{ order.serial_number }}</p>{% endif %}
{% if order.alternate_id %}<p>Alt ID: {{ order.alternate_id }}</p>{% endif %}
{% if order.reference %}<p>Reference: {{ order.reference }}</p>{% endif %}
{% if order.clock_description %}<p>{{ order.clock_description }}</p>{% endif %}
{% if not order.manufacturer and not order.model and not order.clock_description %}
<p class="empty">No clock on file.</p>
{% endif %}
</div>
</section>
{% include "orders/_order_summary.njk" %}
<section class="card">
<h2>Line Items</h2>
<table class="data-table">
<thead>
<tr><th>Type</th><th>Stock #</th><th>Description</th><th>Qty</th><th>Unit Price</th><th>Total</th><th></th></tr>
</thead>
<tbody id="lines-body">
{% for line in order.lineItems %}
{% include "orders/_line_item_row.njk" %}
{% endfor %}
</tbody>
</table>
<form class="inline-form"
hx-post="/orders/{{ order.id }}/line-items"
hx-target="#lines-body"
hx-swap="beforeend"
hx-on::after-request="this.reset()">
<select name="kind">
<option value="labor">Labor</option>
<option value="part">Part</option>
<option value="sublet">Sublet</option>
</select>
<input type="text" name="stock_code" placeholder="Stock #">
<input type="text" name="description" placeholder="Description" required>
<input type="number" name="quantity" placeholder="Qty" value="1" step="0.25" min="0">
<input type="text" name="unit_price" placeholder="Unit price">
<button type="submit">Add Line</button>
</form>
</section>
<section class="card">
<h2>Work Log</h2>
<ul id="work-log-list" class="work-log-list">
{% for entry in order.workLog %}
{% include "orders/_work_log_item.njk" %}
{% endfor %}
</ul>
<form class="inline-form"
hx-post="/orders/{{ order.id }}/work-log"
hx-target="#work-log-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
<input type="text" name="note" placeholder="Add a note…" required>
<button type="submit">Add Note</button>
</form>
</section>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "layout.njk" %}
{% block title %}Orders — Harding's Clocks{% endblock %}
{% block content %}
<div class="page-header">
<h1>Service Orders</h1>
<a class="button-link" href="/orders/new">+ New Order</a>
</div>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Customer</th>
<th>Job Type</th>
<th>Status</th>
<th>Date In</th>
<th>Due</th>
<th>Balance Due</th>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr onclick="location.href='/orders/{{ o.id }}'" class="row-link">
<td>{{ o.order_number }}</td>
<td>{{ o.customer_name }}</td>
<td>{{ o.job_type or '' }}</td>
<td><span class="status-badge status-{{ o.status | lower | replace(' ', '-') }}">{{ o.status }}</span></td>
<td>{{ o.date_in or '' }}</td>
<td>{{ o.due_date or '' }}</td>
<td>{{ o.balance_due | money }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No orders yet. <a href="/orders/new">Create one</a>.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
{% extends "layout.njk" %}
{% block title %}New Order — Harding's Clocks{% endblock %}
{% block content %}
<div class="page-header">
<h1>New Service Order</h1>
</div>
<form method="post" action="/orders" class="stacked-form">
<fieldset>
<legend>Customer</legend>
<label>
Existing customer
<select name="existing_customer_id" id="existing_customer_id"
hx-get="/clocks/by-customer"
hx-trigger="change"
hx-target="#clock-select"
hx-include="this">
<option value="">— new customer —</option>
{% for c in customers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
<div class="grid-2">
<label>Name <input type="text" name="new_customer_name"></label>
<label>Phone <input type="text" name="new_customer_phone"></label>
<label>Alt phone <input type="text" name="new_customer_alt_phone"></label>
<label>Email <input type="email" name="new_customer_email"></label>
<label>Address line 1 <input type="text" name="new_customer_address_line1"></label>
<label>Address line 2 <input type="text" name="new_customer_address_line2"></label>
<label>City <input type="text" name="new_customer_city"></label>
<label>State <input type="text" name="new_customer_state"></label>
<label>Zip <input type="text" name="new_customer_zip"></label>
</div>
<p class="hint">Leave the name blank above and pick an existing customer instead, if applicable.</p>
</fieldset>
<fieldset>
<legend>Clock</legend>
<label>
Existing clock
<select name="existing_clock_id" id="clock-select">
<option value="">— new clock —</option>
</select>
</label>
<div class="grid-2">
<label>Manufacturer <input type="text" name="new_clock_manufacturer"></label>
<label>Model <input type="text" name="new_clock_model"></label>
<label>Serial number <input type="text" name="new_clock_serial_number"></label>
<label>Alternate ID <input type="text" name="new_clock_alternate_id"></label>
<label>Reference <input type="text" name="new_clock_reference"></label>
<label>Description <input type="text" name="new_clock_description"></label>
</div>
</fieldset>
<fieldset>
<legend>Order</legend>
<div class="grid-2">
<label>
Job type
<select name="job_type">
<option value="">— select —</option>
{% for jt in jobTypes %}
<option value="{{ jt }}">{{ jt }}</option>
{% endfor %}
</select>
</label>
<label>Service rep <input type="text" name="service_rep"></label>
<label>Technician <input type="text" name="technician"></label>
<label>Date in <input type="date" name="date_in" value="{{ today }}"></label>
<label>Due date <input type="date" name="due_date"></label>
<label>Customer PO <input type="text" name="cust_po"></label>
<label>Terms <input type="text" name="terms"></label>
</div>
</fieldset>
<button type="submit">Create Order</button>
</form>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*.ts"]
}