Add full project structure: backend, frontend, Docker, and CI workflows

- Organize backend into src/ (routes/, services/, db/) per package.json entrypoint
- Add migrations/import-mdb.js for one-time .mdb → SQLite migration
- Add public/ frontend: check ledger table, slide-in new/edit panel, PDF generation
- Add docker/Dockerfile and docker-compose.yml for self-hosted deployment
- Add .github/workflows: Docker Hub build+push on main/tags, TODO→Issues scanner
- Add GnuMICR font files (GPL-2.0) for MICR E-13B line rendering
This commit is contained in:
2026-03-12 10:29:36 -06:00
parent 9fcb31ba0d
commit e252ddb952
35 changed files with 4112 additions and 1 deletions
+313
View File
@@ -0,0 +1,313 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--border: #d0d0d0;
--header-bg: #1a2b3c;
--header-fg: #ffffff;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #dc2626;
--text: #1a1a1a;
--text-muted: #6b7280;
--row-hover: #f0f4ff;
--panel-width: 420px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
font-family: var(--font);
font-size: 13px;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
header {
background: var(--header-bg);
color: var(--header-fg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
height: 44px;
flex-shrink: 0;
}
.header-brand { font-size: 15px; font-weight: 600; }
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
.header-info strong { color: #fff; }
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 1rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.toolbar-left { display: flex; align-items: center; gap: 8px; }
.toolbar-right { display: flex; align-items: center; gap: 8px; }
.toolbar label { font-size: 12px; font-weight: 500; color: var(--text-muted); }
select {
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-size: 13px;
font-family: var(--font);
background: var(--surface);
cursor: pointer;
}
/* ── Buttons ── */
button {
cursor: pointer;
border: none;
border-radius: 4px;
font-size: 13px;
font-family: var(--font);
padding: 5px 12px;
line-height: 1.4;
transition: background 0.1s, opacity 0.1s;
}
button:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; font-weight: 500; }
.btn-primary:not(:disabled):hover { background: var(--primary-hover); }
.btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--bg); }
.btn-ghost { background: transparent; color: var(--text-muted); }
.btn-ghost:hover { color: var(--text); background: var(--bg); }
.btn-icon {
background: transparent;
color: rgba(255,255,255,0.75);
font-size: 22px;
padding: 0 6px;
line-height: 1;
border-radius: 3px;
}
.btn-icon:hover { color: #fff; background: rgba(255,255,255,0.1); }
.btn-sm {
padding: 2px 8px;
font-size: 12px;
}
.btn-edit { background: transparent; color: var(--primary); }
.btn-edit:hover { background: #eff6ff; }
.btn-delete { background: transparent; color: var(--danger); }
.btn-delete:hover { background: #fee2e2; }
.btn-reprint { background: transparent; color: var(--text-muted); }
.btn-reprint:hover { background: var(--bg); }
.badge {
display: inline-block;
background: rgba(255,255,255,0.25);
border-radius: 10px;
padding: 1px 7px;
font-size: 11px;
min-width: 20px;
text-align: center;
font-weight: 600;
}
/* ── Table ── */
.table-wrap {
flex: 1;
overflow-y: auto;
background: var(--surface);
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background: #f8f8f8;
border-bottom: 2px solid var(--border);
padding: 6px 8px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
white-space: nowrap;
user-select: none;
}
thead th.sortable { cursor: pointer; }
thead th.sortable:hover { color: var(--text); }
thead th.sort-asc .sort-indicator::after { content: ' ↑'; }
thead th.sort-desc .sort-indicator::after { content: ' ↓'; }
tbody tr { border-bottom: 1px solid #f0f0f0; }
tbody tr:hover { background: var(--row-hover); }
tbody tr.printed { color: var(--text-muted); }
td {
padding: 6px 8px;
vertical-align: middle;
}
.col-select { width: 32px; }
.col-no { width: 60px; font-variant-numeric: tabular-nums; }
.col-date { width: 95px; white-space: nowrap; }
.col-amount { width: 95px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; }
.col-memo { color: var(--text-muted); font-size: 12px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-status { width: 88px; text-align: center; }
.col-actions { width: 130px; text-align: right; white-space: nowrap; }
.status-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 10px;
}
.status-unprinted { background: #fef3c7; color: #92400e; }
.status-printed { background: #e0f2fe; color: #0369a1; }
.loading-row td,
.empty-row td { text-align: center; padding: 2rem; color: var(--text-muted); }
/* ── Slide-in panel ── */
#panel-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#panel-overlay.open { opacity: 1; pointer-events: auto; }
#check-panel {
position: fixed;
top: 0;
right: 0;
width: var(--panel-width);
height: 100vh;
background: var(--surface);
z-index: 101;
box-shadow: -4px 0 24px rgba(0,0,0,0.15);
transform: translateX(100%);
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
overflow-y: auto;
}
#check-panel.open { transform: translateX(0); }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--header-bg);
color: var(--header-fg);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 1;
}
.panel-header h2 { font-size: 14px; font-weight: 600; }
/* ── Form ── */
#check-form {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.form-group.required label::after { content: ' *'; color: var(--danger); }
.form-group input {
border: 1px solid var(--border);
border-radius: 4px;
padding: 7px 10px;
font-size: 13px;
font-family: var(--font);
width: 100%;
background: var(--surface);
color: var(--text);
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37,99,235,0.15);
}
.form-group input.error { border-color: var(--danger); }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.address-section {
border: 1px solid var(--border);
border-radius: 4px;
}
.address-section summary {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
cursor: pointer;
user-select: none;
list-style: none;
}
.address-section summary::before { content: '▶ '; font-size: 9px; }
.address-section[open] summary::before { content: '▼ '; }
.address-section[open] summary { border-bottom: 1px solid var(--border); }
.address-fields {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.form-actions {
display: flex;
gap: 8px;
padding-top: 4px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.form-actions .btn-primary { flex: 1; padding: 8px; }
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ezcheck</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<div class="header-brand">
<span id="company-name">ezcheck</span>
</div>
<div class="header-info">
Next check: <strong id="current-check-no"></strong>
</div>
</header>
<div class="toolbar">
<div class="toolbar-left">
<label for="filter-status">Show:</label>
<select id="filter-status">
<option value="">All</option>
<option value="0" selected>Unprinted</option>
<option value="1">Printed</option>
</select>
</div>
<div class="toolbar-right">
<button id="btn-generate-pdf" class="btn-primary" disabled>
Generate PDF <span id="selected-count" class="badge">0</span>
</button>
<button id="btn-new-check" class="btn-secondary">+ New Check</button>
</div>
</div>
<div class="table-wrap">
<table id="checks-table">
<thead>
<tr>
<th class="col-select"></th>
<th class="col-no sortable" data-col="check_no"># <span class="sort-indicator"></span></th>
<th class="col-date sortable" data-col="check_date">Date <span class="sort-indicator"></span></th>
<th class="col-payee sortable" data-col="payee">Payee <span class="sort-indicator"></span></th>
<th class="col-amount sortable" data-col="amount">Amount <span class="sort-indicator"></span></th>
<th class="col-memo">Memo</th>
<th class="col-status">Status</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody id="checks-tbody">
<tr class="loading-row"><td colspan="8">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Slide-in panel -->
<div id="panel-overlay"></div>
<aside id="check-panel">
<div class="panel-header">
<h2 id="panel-title">New Check</h2>
<button id="btn-close-panel" class="btn-icon" title="Close">×</button>
</div>
<form id="check-form" novalidate>
<div class="form-group required">
<label for="f-payee">Payee</label>
<input type="text" id="f-payee" name="payee" required autocomplete="off">
</div>
<div class="form-row">
<div class="form-group required">
<label for="f-amount">Amount ($)</label>
<input type="number" id="f-amount" name="amount" required min="0.01" step="0.01" placeholder="0.00">
</div>
<div class="form-group required">
<label for="f-date">Date</label>
<input type="date" id="f-date" name="check_date" required>
</div>
</div>
<div class="form-group">
<label for="f-memo">Memo</label>
<input type="text" id="f-memo" name="memo">
</div>
<div class="form-row">
<div class="form-group">
<label for="f-note1">Note 1</label>
<input type="text" id="f-note1" name="note1">
</div>
<div class="form-group">
<label for="f-note2">Note 2</label>
<input type="text" id="f-note2" name="note2">
</div>
</div>
<details class="address-section">
<summary>Payee Address</summary>
<div class="address-fields">
<div class="form-group">
<label for="f-addr1">Line 1</label>
<input type="text" id="f-addr1" name="payee_address1">
</div>
<div class="form-group">
<label for="f-addr2">Line 2</label>
<input type="text" id="f-addr2" name="payee_address2">
</div>
<div class="form-group">
<label for="f-addr3">Line 3</label>
<input type="text" id="f-addr3" name="payee_address3">
</div>
<div class="form-group">
<label for="f-addr4">Line 4</label>
<input type="text" id="f-addr4" name="payee_address4">
</div>
</div>
</details>
<div class="form-actions">
<button type="submit" class="btn-primary" id="btn-save">Save Check</button>
<button type="button" class="btn-ghost" id="btn-cancel">Cancel</button>
</div>
</form>
</aside>
<script src="/js/app.js"></script>
</body>
</html>
+391
View File
@@ -0,0 +1,391 @@
'use strict';
const state = {
checks: [],
account: null,
filter: '0', // '' = all, '0' = unprinted, '1' = printed
sortCol: 'check_no',
sortDir: 'desc',
selected: new Set(),
editingId: null,
};
// ── API helpers ──────────────────────────────────────────────────────────────
async function apiFetch(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
// ── Data loading ─────────────────────────────────────────────────────────────
async function loadAccount() {
try {
state.account = await apiFetch('GET', '/api/account');
renderHeader();
} catch {
// account not configured yet — silently skip
}
}
async function loadChecks() {
const tbody = document.getElementById('checks-tbody');
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
try {
const params = new URLSearchParams();
if (state.filter !== '') params.set('printed', state.filter);
state.checks = await apiFetch('GET', `/api/checks?${params}`);
state.selected.clear();
renderTable();
refreshPdfButton();
} catch (err) {
tbody.innerHTML = `<tr class="empty-row"><td colspan="8">Error loading checks: ${escHtml(err.message)}</td></tr>`;
}
}
// ── Rendering ────────────────────────────────────────────────────────────────
function renderHeader() {
const a = state.account;
if (!a) return;
document.getElementById('company-name').textContent = a.company1 || 'ezcheck';
document.getElementById('current-check-no').textContent = (a.current_check_no + 1).toLocaleString();
}
function renderTable() {
const checks = sortedChecks();
const tbody = document.getElementById('checks-tbody');
if (checks.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No checks found.</td></tr>';
updateSortIndicators();
return;
}
tbody.innerHTML = checks.map(renderRow).join('');
updateSortIndicators();
updateCheckboxStates();
// Attach row-level event listeners
tbody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => onCheckboxChange(cb));
});
tbody.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', () => openPanel(parseInt(btn.dataset.id, 10)));
});
tbody.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => deleteCheck(parseInt(btn.dataset.id, 10)));
});
tbody.querySelectorAll('.btn-reprint').forEach(btn => {
btn.addEventListener('click', () => reprintCheck(parseInt(btn.dataset.id, 10)));
});
}
function renderRow(c) {
const printed = !!c.printed;
const selected = state.selected.has(c.id);
const fmtAmount = new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD',
}).format(c.amount);
const fmtDate = c.check_date
? new Date(c.check_date + 'T12:00:00').toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
})
: '—';
const checkbox = printed
? '<td class="col-select"></td>'
: `<td class="col-select"><input type="checkbox" data-id="${c.id}"${selected ? ' checked' : ''}></td>`;
const statusBadge = printed
? '<span class="status-badge status-printed">Printed</span>'
: '<span class="status-badge status-unprinted">Unprinted</span>';
const actions = printed
? `<button class="btn-sm btn-reprint" data-id="${c.id}">Reprint</button>`
: `<button class="btn-sm btn-edit" data-id="${c.id}">Edit</button>` +
`<button class="btn-sm btn-delete" data-id="${c.id}">Delete</button>`;
return `<tr class="${printed ? 'printed' : ''}">
${checkbox}
<td class="col-no">${c.check_no}</td>
<td class="col-date">${fmtDate}</td>
<td class="col-payee">${escHtml(c.payee)}</td>
<td class="col-amount">${fmtAmount}</td>
<td class="col-memo" title="${escHtml(c.memo || '')}">${escHtml(c.memo || '')}</td>
<td class="col-status">${statusBadge}</td>
<td class="col-actions">${actions}</td>
</tr>`;
}
function sortedChecks() {
const col = state.sortCol;
const dir = state.sortDir === 'asc' ? 1 : -1;
return [...state.checks].sort((a, b) => {
let av = a[col];
let bv = b[col];
if (col === 'amount') { av = parseFloat(av); bv = parseFloat(bv); }
if (av == null) return 1;
if (bv == null) return -1;
if (av < bv) return -dir;
if (av > bv) return dir;
return 0;
});
}
function updateSortIndicators() {
document.querySelectorAll('thead th.sortable').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
if (th.dataset.col === state.sortCol) {
th.classList.add(state.sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
}
});
}
function updateCheckboxStates() {
document.querySelectorAll('#checks-tbody input[type="checkbox"]').forEach(cb => {
const id = parseInt(cb.dataset.id, 10);
if (!state.selected.has(id)) {
cb.disabled = state.selected.size >= 3;
}
});
}
function refreshPdfButton() {
const n = state.selected.size;
const btn = document.getElementById('btn-generate-pdf');
btn.disabled = n === 0;
document.getElementById('selected-count').textContent = n;
}
// ── Checkbox handling ────────────────────────────────────────────────────────
function onCheckboxChange(cb) {
const id = parseInt(cb.dataset.id, 10);
if (cb.checked) {
if (state.selected.size >= 3) {
cb.checked = false;
return;
}
state.selected.add(id);
} else {
state.selected.delete(id);
}
refreshPdfButton();
updateCheckboxStates();
}
// ── Slide-in panel ───────────────────────────────────────────────────────────
function openPanel(id = null) {
state.editingId = id;
const form = document.getElementById('check-form');
const title = document.getElementById('panel-title');
form.reset();
clearFormErrors();
document.querySelector('.address-section').removeAttribute('open');
if (id !== null) {
const check = state.checks.find(c => c.id === id);
if (!check) return;
title.textContent = `Edit Check #${check.check_no}`;
form.payee.value = check.payee || '';
form.amount.value = check.amount != null ? check.amount : '';
form.check_date.value = check.check_date || '';
form.memo.value = check.memo || '';
form.note1.value = check.note1 || '';
form.note2.value = check.note2 || '';
form.payee_address1.value = check.payee_address1 || '';
form.payee_address2.value = check.payee_address2 || '';
form.payee_address3.value = check.payee_address3 || '';
form.payee_address4.value = check.payee_address4 || '';
if (check.payee_address1) {
document.querySelector('.address-section').setAttribute('open', '');
}
} else {
title.textContent = 'New Check';
form.check_date.value = new Date().toISOString().slice(0, 10);
}
document.getElementById('panel-overlay').classList.add('open');
document.getElementById('check-panel').classList.add('open');
form.payee.focus();
}
function closePanel() {
document.getElementById('panel-overlay').classList.remove('open');
document.getElementById('check-panel').classList.remove('open');
state.editingId = null;
}
function clearFormErrors() {
document.querySelectorAll('#check-form .error').forEach(el => el.classList.remove('error'));
}
// ── CRUD actions ─────────────────────────────────────────────────────────────
async function saveCheck(e) {
e.preventDefault();
clearFormErrors();
const form = e.target;
const data = {
payee: form.payee.value.trim(),
amount: parseFloat(form.amount.value),
check_date: form.check_date.value,
memo: form.memo.value.trim() || null,
note1: form.note1.value.trim() || null,
note2: form.note2.value.trim() || null,
payee_address1: form.payee_address1.value.trim() || null,
payee_address2: form.payee_address2.value.trim() || null,
payee_address3: form.payee_address3.value.trim() || null,
payee_address4: form.payee_address4.value.trim() || null,
};
let valid = true;
if (!data.payee) { form.payee.classList.add('error'); valid = false; }
if (!data.amount || isNaN(data.amount) || data.amount <= 0) { form.amount.classList.add('error'); valid = false; }
if (!data.check_date) { form.check_date.classList.add('error'); valid = false; }
if (!valid) return;
const btn = document.getElementById('btn-save');
btn.disabled = true;
btn.textContent = 'Saving…';
try {
if (state.editingId !== null) {
await apiFetch('PUT', `/api/checks/${state.editingId}`, data);
} else {
await apiFetch('POST', '/api/checks', data);
}
closePanel();
await Promise.all([loadAccount(), loadChecks()]);
} catch (err) {
alert(`Error: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Save Check';
}
}
async function deleteCheck(id) {
const check = state.checks.find(c => c.id === id);
if (!check) return;
if (!confirm(`Delete check #${check.check_no} payable to "${check.payee}"?`)) return;
try {
await apiFetch('DELETE', `/api/checks/${id}`);
await loadChecks();
} catch (err) {
alert(`Error: ${err.message}`);
}
}
async function generatePdf() {
const ids = [...state.selected];
if (ids.length === 0 || ids.length > 3) return;
const btn = document.getElementById('btn-generate-pdf');
btn.disabled = true;
btn.textContent = 'Generating…';
try {
const res = await fetch('/api/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkIds: ids }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
await loadChecks(); // refresh to show printed status
} catch (err) {
alert(`PDF error: ${err.message}`);
} finally {
refreshPdfButton();
}
}
async function reprintCheck(id) {
const check = state.checks.find(c => c.id === id);
if (!check) return;
if (!confirm(`Reprint check #${check.check_no} to "${check.payee}"?\n(Will not re-mark as printed)`)) return;
try {
const res = await fetch('/api/pdf?mark_printed=false', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkIds: [id] }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
} catch (err) {
alert(`Reprint error: ${err.message}`);
}
}
// ── Utilities ────────────────────────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Initialization ───────────────────────────────────────────────────────────
function init() {
// Column sort
document.querySelectorAll('thead th.sortable').forEach(th => {
th.addEventListener('click', () => {
if (state.sortCol === th.dataset.col) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortCol = th.dataset.col;
state.sortDir = th.dataset.col === 'check_no' ? 'desc' : 'asc';
}
renderTable();
});
});
// Filter dropdown
document.getElementById('filter-status').addEventListener('change', e => {
state.filter = e.target.value;
loadChecks();
});
// New check
document.getElementById('btn-new-check').addEventListener('click', () => openPanel());
// Panel close
document.getElementById('btn-close-panel').addEventListener('click', closePanel);
document.getElementById('btn-cancel').addEventListener('click', closePanel);
document.getElementById('panel-overlay').addEventListener('click', closePanel);
// Form submit
document.getElementById('check-form').addEventListener('submit', saveCheck);
// Generate PDF
document.getElementById('btn-generate-pdf').addEventListener('click', generatePdf);
// Initial data load
loadAccount();
loadChecks();
}
document.addEventListener('DOMContentLoaded', init);