initial version
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-journal
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
|
*.log
|
||||||
Generated
+1988
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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`)}`);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% include "orders/_line_item_row.njk" %}
|
||||||
|
{% include "orders/_totals.njk" %}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<li id="log-{{ entry.id }}" class="work-log-item">
|
||||||
|
<time>{{ entry.logged_at }}</time>
|
||||||
|
<p>{{ entry.note }}</p>
|
||||||
|
</li>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user