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
+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;