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:
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
Reference in New Issue
Block a user