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