229 lines
9.6 KiB
Markdown
229 lines
9.6 KiB
Markdown
# Harding's Clocks — Repair Tracker Design Doc
|
|
|
|
## 1. Purpose
|
|
|
|
A dead-simple web app for a one-person clock repair shop to:
|
|
|
|
- Track customers and the clocks they bring in
|
|
- Open service orders, log work performed, and list parts
|
|
- Generate a printable invoice matching the existing paper form
|
|
|
|
Design principle: **KISS**. One user (Dad), one machine, one job at a time. No accounts, no multi-tenancy, no plugin system, no build ceremony beyond what Vite needs.
|
|
|
|
## 2. Non-Goals
|
|
|
|
- No authentication in the app (handled by Caddy — see §9)
|
|
- No customer-facing portal
|
|
- No payment processing (just record what was paid)
|
|
- No job templates — a fixed `job_type` field instead
|
|
- No inventory management — parts are free-text lines on an order
|
|
- No email/SMS, no reporting dashboards (can add later)
|
|
|
|
## 3. Tech Stack
|
|
|
|
| Layer | Choice | Why |
|
|
|---------------|---------------------------|--------------------------------------------------|
|
|
| Database | SQLite (`better-sqlite3`) | Single file, zero-config, synchronous API |
|
|
| Runtime | Node.js (LTS) | Familiar, boring, reliable |
|
|
| Language | TypeScript | Type safety on the data model without heaviness |
|
|
| HTTP server | Express (or Fastify) | Minimal routing; serve HTML + handle form posts |
|
|
| Frontend | HTMX + server-rendered HTML | No SPA. Server returns HTML fragments. |
|
|
| Templating | Nunjucks (or EJS) | Plain HTML templates, easy to eyeball |
|
|
| Build/dev | Vite | Fast dev server + asset bundling for the bit of JS/CSS |
|
|
| Reverse proxy | Caddy | TLS + basic-auth in front |
|
|
|
|
**Why HTMX, not React:** the whole app is forms and lists. HTMX lets the server
|
|
return HTML fragments on `hx-post`/`hx-get`, so there's no client state to manage
|
|
and no API/JSON layer to maintain. TypeScript stays on the server where the logic is.
|
|
|
|
## 4. Data Model
|
|
|
|
Five tables plus one view. Money stored as **INTEGER cents** to avoid float rounding.
|
|
Dates stored as ISO-8601 TEXT (`YYYY-MM-DD` or full datetime).
|
|
|
|
```sql
|
|
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', -- 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'))
|
|
);
|
|
|
|
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'))
|
|
);
|
|
|
|
CREATE VIEW order_totals AS
|
|
SELECT
|
|
so.id AS service_order_id,
|
|
COALESCE(SUM(CASE WHEN li.kind='part' THEN li.quantity*li.unit_price END),0) AS total_parts,
|
|
COALESCE(SUM(CASE WHEN li.kind='labor' THEN li.quantity*li.unit_price END),0) AS total_labor,
|
|
COALESCE(SUM(CASE WHEN li.kind='sublet' THEN li.quantity*li.unit_price END),0) AS total_sublet,
|
|
COALESCE(SUM(li.quantity*li.unit_price),0)
|
|
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total
|
|
FROM service_orders so
|
|
LEFT JOIN line_items li ON li.service_order_id = so.id
|
|
GROUP BY so.id;
|
|
```
|
|
|
|
**Order numbers:** `order_number` starts at 8247 (next after the sample) and
|
|
increments. Simplest approach: `MAX(order_number)+1` at insert time inside a
|
|
transaction. `better-sqlite3` is synchronous so there's no race on a single-user app.
|
|
|
|
## 5. Application Structure
|
|
|
|
```
|
|
clock-tracker/
|
|
├─ src/
|
|
│ ├─ server.ts # Express app, route wiring
|
|
│ ├─ db.ts # better-sqlite3 connection + prepared statements
|
|
│ ├─ schema.sql # the DDL above (run on first boot if db missing)
|
|
│ ├─ money.ts # cents <-> "$1.75" helpers
|
|
│ ├─ routes/
|
|
│ │ ├─ customers.ts
|
|
│ │ ├─ clocks.ts
|
|
│ │ ├─ orders.ts # create/edit order, add line items, work log
|
|
│ │ └─ invoice.ts # printable invoice view
|
|
│ └─ views/ # Nunjucks templates + HTMX fragments
|
|
│ ├─ layout.njk
|
|
│ ├─ orders/
|
|
│ │ ├─ list.njk
|
|
│ │ ├─ detail.njk
|
|
│ │ └─ _line_item_row.njk # fragment returned to HTMX
|
|
│ └─ invoice.njk
|
|
├─ public/ # CSS, tiny JS, htmx.min.js
|
|
├─ data/app.db # the SQLite file (gitignored)
|
|
├─ vite.config.ts
|
|
├─ tsconfig.json
|
|
└─ package.json
|
|
```
|
|
|
|
## 6. Routes
|
|
|
|
| Method | Path | Returns | Purpose |
|
|
|--------|-----------------------------------|------------------|----------------------------------|
|
|
| GET | `/` | full page | Order list (open orders first) |
|
|
| GET | `/orders/new` | full page | New-order form |
|
|
| POST | `/orders` | redirect | Create order + customer/clock |
|
|
| GET | `/orders/:id` | full page | Order detail (work + parts) |
|
|
| POST | `/orders/:id/line-items` | HTML fragment | Add a labor/part line (HTMX) |
|
|
| DELETE | `/orders/:id/line-items/:lineId` | empty/200 | Remove a line (HTMX) |
|
|
| POST | `/orders/:id/work-log` | HTML fragment | Add a work-log note (HTMX) |
|
|
| POST | `/orders/:id` | HTML fragment | Update status/dates/totals |
|
|
| GET | `/orders/:id/invoice` | full page | Printable invoice |
|
|
| GET | `/customers` | full page | Customer list/search |
|
|
|
|
HTMX pattern: adding a part line does `hx-post="/orders/42/line-items"` with
|
|
`hx-target="#lines" hx-swap="beforeend"`; the server returns just the `<tr>` for
|
|
the new line plus an out-of-band swap updating the totals box.
|
|
|
|
## 7. The Invoice View
|
|
|
|
`GET /orders/:id/invoice` renders `invoice.njk` — a print-oriented HTML page
|
|
mirroring the paper layout: shop header block, Ship-To, manufacturer/clock block,
|
|
the line-item table (work performed + parts), and the totals box from
|
|
`order_totals`. A `@media print` stylesheet hides nav and fits one page.
|
|
"Print" is just the browser's print dialog → save as PDF or paper. No PDF library.
|
|
|
|
## 8. Local Dev & Build
|
|
|
|
- `npm run dev` — Vite dev server for assets + `tsx watch src/server.ts` for the API. (Vite proxies to the Node server, or run them side by side; keep it simple with `concurrently`.)
|
|
- `npm run build` — `vite build` for `public/` assets + `tsc` for server.
|
|
- `npm start` — `node dist/server.js`, serving built assets and listening on `127.0.0.1:3000`.
|
|
- DB bootstraps itself: on start, if `data/app.db` doesn't exist, run `schema.sql`.
|
|
|
|
## 9. Deployment & "Auth"
|
|
|
|
The app binds to localhost only. Caddy sits in front, terminates TLS, and does
|
|
HTTP Basic Auth so only Dad gets in. Example `Caddyfile`:
|
|
|
|
```
|
|
clocks.example.com {
|
|
basic_auth {
|
|
dad $2a$14$...bcrypt-hash...
|
|
}
|
|
reverse_proxy 127.0.0.1:3000
|
|
}
|
|
```
|
|
|
|
Generate the hash with `caddy hash-password`. That single credential is the whole
|
|
security model — acceptable because it's one private user on a small shop tool.
|
|
The app itself never sees or stores credentials.
|
|
|
|
## 10. Backups
|
|
|
|
The database is one file (`data/app.db`). A nightly `cron` job copies it somewhere
|
|
safe:
|
|
|
|
```
|
|
sqlite3 data/app.db ".backup '/backups/app-$(date +%F).db'"
|
|
```
|
|
|
|
Keep ~30 days. That's the entire disaster-recovery plan, and it's enough.
|
|
|
|
## 11. Future (explicitly deferred)
|
|
|
|
Only if actually needed: photo attachments per clock (the software's "Images" tab),
|
|
customer search-as-you-type, a simple monthly revenue total, quoted-values/estimate
|
|
tracking. None of this is built now.
|