16 Commits

Author SHA1 Message Date
steve 7d105bce21 chore: bump version to 0.4.6
TODO to Issues / todo (push) Has been cancelled
Build and push Docker image / build-push (push) Has been cancelled
2026-05-02 17:22:31 -06:00
steve 0b21f4ea3c feat(layout): add preview PDF button to layout editor
Generates a 3-up PDF with dummy check data (no real checks touched) so the
layout can be proofed without printing live checks.

Closes #12
2026-05-02 17:21:41 -06:00
steve f91fc7bd8a fix(deposits): bold check numbers and amounts for better print visibility 2026-04-28 10:28:08 -06:00
steve bb935acfa9 fix(deposits): check number spacing, darker text, header/title repositioning
- Shift check numbers right (0.16->0.28") so they clear the row number labels
- Darken label text (#444->##111) and disclaimer text (#666->#333)
- Move 'DEPOSIT TICKET' header and depositor/bank block down 0.12" on front page
- Vertically center the check grid on the back page
- Reposition 'ADDITIONAL CHECK LISTING' title relative to centered grid
2026-04-28 09:59:28 -06:00
steve 3957cf5518 feat(deposits): add back page rows all at once via single button 2026-04-28 09:28:49 -06:00
steve 4a47394923 fix(deposits): darken deposit slip grid lines for scanner readability 2026-04-28 09:14:52 -06:00
steve 189ae53d34 feat(deposits): pre-fill 30 slots and add back-page overflow for 31-60 checks
- Deposit panel now pre-fills all 30 check slots on open (new and existing)
- Remove button maintains 30-slot minimum by appending a blank row
- Add Row button hidden at <30, visible at 30-59, disabled at 60
- Deposit slip PDF splits items: first 30 on front, up to 30 more on back
- Front page gains a FROM REVERSE row carrying back-page subtotal when needed
- Back page renders with same column positions/width as front, titled
  'ADDITIONAL CHECK LISTING', numbered rows 31-60, and
  'Forward to other side / TOTAL $' footer

Closes #10, closes #11
2026-04-28 09:04:44 -06:00
steve 657de9e61a fix(ui): resolve deposit panel scroll and PDF open on iOS
- Switch #deposit-panel to 100dvh so iOS browser chrome doesn't clip the Save button
- Move overflow-y:auto to #deposit-panel-body so the actions footer stays pinned
- Replace window.open(blob:) with <a download> click to fix PDF opening in Firefox iOS
  (Firefox iOS blocks blob: URL navigation in new tabs)

Closes #7, closes #9
2026-04-28 08:32:07 -06:00
steve 0ee95dbb09 chore: bump version to 0.4.5
Build and push Docker image / build-push (push) Has been cancelled
2026-04-13 08:28:46 -06:00
steve a2de7e2d9d feat(layout): add grid, safe zone, and MICR anchor alignment
- Draw 1/8" grid overlay on layout editor canvas
- Anchor MICR second transit symbol at 2 59/64" from left
- Clamp draggable fields to printing safe zone (11/64" sides, 13/64" top, 0.5" bottom)
- Render dashed safe-zone outline on layout canvas
2026-04-13 08:06:23 -06:00
steve c4e4a8c246 chore: bump version to 0.4.3
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 10:23:17 -06:00
steve b692791436 fix: align account access checkboxes with fixed grid layout
Use a 3-column inner grid (checkbox | label | dropdown) with fixed
64px row height for consistent alignment across all account entries.
2026-04-11 10:23:15 -06:00
steve 37d70b4d82 chore: bump version to 0.4.2
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 09:51:20 -06:00
steve 7d854d4e01 fix: clean up account access grid and separate OIDC fields in user form
Use CSS grid for uniform account checkbox layout and add a bordered
subsection with heading for the OIDC identity fields.
2026-04-11 09:51:18 -06:00
steve f9f6a4cd9a chore: bump version to 0.4.1
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 09:44:22 -06:00
steve fd36c25636 feat: replace settings modal with full-page sidebar layout
Convert the users/admin modal into a dedicated settings page with
left sidebar navigation and spacious content panels. Hash-based
routing (#settings/users, #settings/smtp, etc.) enables browser
back-button support and direct URL access. Admin-only tabs are
hidden for non-admin users.
2026-04-11 09:43:24 -06:00
8 changed files with 729 additions and 191 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ezcheck",
"version": "0.4.0",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ezcheck",
"version": "0.4.0",
"version": "0.4.6",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ezcheck",
"version": "0.4.0",
"version": "0.4.6",
"description": "Self-hosted check printing web app",
"main": "src/app.js",
"scripts": {
+161 -9
View File
@@ -22,11 +22,17 @@ body {
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
#main-app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
#main-app[hidden] { display: none; }
/* ── Header ── */
header {
background: var(--header-bg);
@@ -673,7 +679,7 @@ input[type="file"] {
right: 0;
width: 560px;
max-width: 98vw;
height: 100vh;
height: 100dvh;
background: var(--surface);
z-index: 101;
box-shadow: -4px 0 24px rgba(0,0,0,0.15);
@@ -681,7 +687,7 @@ input[type="file"] {
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow: hidden;
}
#deposit-panel.open { transform: translateX(0); }
@@ -691,6 +697,8 @@ input[type="file"] {
flex-direction: column;
gap: 14px;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.dep-summary {
@@ -861,21 +869,165 @@ input[type="file"] {
}
/* ── User management ── */
.account-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.account-checkboxes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin-top: 4px;
}
.account-checkbox-label {
display: flex;
display: grid;
grid-template-columns: 20px 1fr auto;
align-items: center;
gap: 5px;
gap: 8px;
font-size: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 3px 8px;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
height: 64px;
}
.account-checkbox-label:hover { border-color: var(--primary); }
.account-checkbox-label input[type="checkbox"] {
justify-self: center;
}
.account-checkbox-label span {
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.account-checkbox-label select {
flex-shrink: 0;
}
.uf-oidc-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.uf-oidc-section h4 {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-muted);
}
/* Hide layout editor button on portrait/mobile — canvas needs landscape space */
@media (max-width: 768px), (orientation: portrait) {
#btn-layout-editor { display: none !important; }
}
/* ── Settings Page ── */
.settings-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.settings-page[hidden] { display: none; }
.settings-header {
background: var(--header-bg);
color: var(--header-fg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 44px;
flex-shrink: 0;
}
.settings-header-left { display: flex; align-items: center; gap: 12px; }
.settings-header-right { display: flex; align-items: center; gap: 10px; }
.settings-back-link {
color: rgba(255,255,255,0.7);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: color 0.15s;
}
.settings-back-link:hover { color: #fff; }
.settings-layout {
display: flex;
flex: 1;
overflow: hidden;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.settings-sidebar {
width: 220px;
flex-shrink: 0;
padding: 20px 0;
border-right: 1px solid var(--border);
overflow-y: auto;
background: var(--surface);
}
.settings-sidebar-title {
font-size: 15px;
font-weight: 700;
padding: 4px 20px 16px;
color: var(--text);
}
.settings-nav-group { margin-bottom: 8px; }
.settings-nav-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 12px 20px 6px;
}
.settings-nav-item {
display: block;
padding: 7px 20px;
font-size: 13px;
color: var(--text);
text-decoration: none;
border-radius: 6px;
margin: 1px 8px;
transition: background 0.12s;
}
.settings-nav-item:hover { background: var(--bg); }
.settings-nav-item.active {
background: var(--primary);
color: #fff;
font-weight: 500;
}
.settings-content {
flex: 1;
padding: 32px 48px;
overflow-y: auto;
max-width: 780px;
}
.settings-panel h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text);
}
.settings-panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.settings-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
line-height: 1.5;
}
.settings-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.settings-section .form-row { gap: 16px; }
.settings-section .form-group { margin-bottom: 4px; }
+169 -122
View File
@@ -87,6 +87,172 @@
</div>
</div>
<!-- Settings page (full-page, hidden by default) -->
<div id="settings-page" class="settings-page" hidden>
<header class="settings-header">
<div class="settings-header-left">
<a href="#" id="settings-back-link" class="settings-back-link">&larr; Back</a>
</div>
<div class="settings-header-right">
<span id="settings-username" class="header-username"></span>
<button id="btn-settings-logout" class="btn-header-icon" title="Sign out"></button>
</div>
</header>
<div class="settings-layout">
<nav class="settings-sidebar">
<div class="settings-sidebar-title">Settings</div>
<div class="settings-nav-group" data-admin-only>
<div class="settings-nav-label">Administration</div>
<a href="#settings/users" class="settings-nav-item" data-settings-tab="users">Users</a>
<a href="#settings/smtp" class="settings-nav-item" data-settings-tab="smtp">Email (SMTP)</a>
</div>
<div class="settings-nav-group">
<div class="settings-nav-label">Account</div>
<a href="#settings/password" class="settings-nav-item" data-settings-tab="password">Password</a>
<a href="#settings/sso" class="settings-nav-item" data-settings-tab="sso">Single Sign-On</a>
</div>
</nav>
<main class="settings-content">
<!-- Users panel (admin only) -->
<div id="settings-panel-users" class="settings-panel" hidden>
<h2>Users</h2>
<p class="settings-desc">Manage user accounts and access permissions.</p>
<div class="settings-section">
<div id="users-list"></div>
</div>
<div class="settings-section" id="user-form-section">
<h3 id="user-form-title">Add User</h3>
<div class="form-row">
<div class="form-group required">
<label for="uf-username">Username</label>
<input type="text" id="uf-username" autocapitalize="none">
</div>
<div class="form-group">
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
<input type="email" id="uf-email" autocomplete="email">
</div>
<div class="form-group required">
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="uf-password" autocomplete="new-password">
</div>
<div class="form-group required">
<label for="uf-role">Role</label>
<select id="uf-role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="form-group" id="uf-accounts-group">
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
</div>
<div id="uf-oidc-group" class="uf-oidc-section" hidden>
<h4>Single Sign-On Identity</h4>
<div class="form-row">
<div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off">
</div>
<div class="form-group">
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
<input type="text" id="uf-oidc-issuer" autocomplete="off">
</div>
</div>
</div>
<div id="user-form-error" class="wizard-error" hidden></div>
<div style="display:flex;gap:8px;margin-top:12px">
<button id="btn-save-user" class="btn-primary">Add User</button>
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
</div>
</div>
</div>
<!-- SMTP panel (admin only) -->
<div id="settings-panel-smtp" class="settings-panel" hidden>
<h2>Email Settings</h2>
<p class="settings-desc">Configure SMTP for password reset emails.</p>
<div class="settings-section" id="smtp-settings-section">
<div class="form-row">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group" style="max-width:100px">
<label for="smtp-port">Port</label>
<input type="number" id="smtp-port" value="587" min="1" max="65535">
</div>
<div class="form-group" style="max-width:160px">
<label for="smtp-secure">Encryption</label>
<select id="smtp-secure">
<option value="0">STARTTLS (587)</option>
<option value="1">SSL/TLS (465)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="smtp-user">Username</label>
<input type="text" id="smtp-user" autocomplete="off">
</div>
<div class="form-group">
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
<input type="password" id="smtp-pass" autocomplete="new-password">
</div>
<div class="form-group">
<label for="smtp-from">From Address</label>
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
</div>
</div>
<div id="smtp-error" class="wizard-error" hidden></div>
<div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:12px">Save Email Settings</button>
</div>
</div>
<!-- Password panel (all users) -->
<div id="settings-panel-password" class="settings-panel" hidden>
<h2>Change Password</h2>
<p class="settings-desc">Update your login password.</p>
<div class="settings-section">
<div class="form-row">
<div class="form-group">
<label for="cp-current">Current Password</label>
<input type="password" id="cp-current" autocomplete="current-password">
</div>
<div class="form-group">
<label for="cp-new">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="cp-new" autocomplete="new-password">
</div>
<div class="form-group">
<label for="cp-confirm">Confirm New</label>
<input type="password" id="cp-confirm" autocomplete="new-password">
</div>
</div>
<div id="cp-error" class="wizard-error" hidden></div>
<div id="cp-success" class="import-result" hidden>Password changed.</div>
<button id="btn-change-password" class="btn-secondary" style="margin-top:12px">Change Password</button>
</div>
</div>
<!-- SSO panel (all users, shown when OIDC enabled) -->
<div id="settings-panel-sso" class="settings-panel" hidden>
<h2>Single Sign-On</h2>
<p class="settings-desc">Link your account to an external identity provider.</p>
<div class="settings-section" id="oidc-link-section">
<p id="oidc-link-status" class="settings-desc" style="margin-bottom:12px"></p>
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
</div>
</div>
</main>
</div>
</div>
<div id="main-app">
<header>
<div class="header-left">
<span class="header-brand" id="company-name">ezcheck</span>
@@ -657,128 +823,8 @@
</div>
</div>
</aside>
</div><!-- /main-app -->
<!-- User management modal (admin only) -->
<div id="users-overlay" class="modal-overlay"></div>
<div id="users-modal" class="modal modal-wide" role="dialog" aria-labelledby="users-title">
<div class="modal-header">
<h2 id="users-title">Manage Users</h2>
<button id="btn-close-users" class="btn-icon" title="Close">×</button>
</div>
<div class="modal-body">
<div id="users-list"></div>
<div id="user-form-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px" id="user-form-title">Add User</h3>
<div class="form-row">
<div class="form-group required">
<label for="uf-username">Username</label>
<input type="text" id="uf-username" autocapitalize="none">
</div>
<div class="form-group">
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
<input type="email" id="uf-email" autocomplete="email">
</div>
<div class="form-group required">
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="uf-password" autocomplete="new-password">
</div>
<div class="form-group required">
<label for="uf-role">Role</label>
<select id="uf-role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="form-group" id="uf-accounts-group">
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
</div>
<div id="uf-oidc-group" class="form-row" hidden>
<div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off">
</div>
<div class="form-group">
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
<input type="text" id="uf-oidc-issuer" autocomplete="off">
</div>
</div>
<div id="user-form-error" class="wizard-error" hidden></div>
<div style="display:flex;gap:8px;margin-top:8px">
<button id="btn-save-user" class="btn-primary">Add User</button>
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
</div>
</div>
<!-- SMTP settings (admin only) -->
<div id="smtp-settings-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Email Settings (SMTP)</h3>
<div class="form-row">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group" style="max-width:90px">
<label for="smtp-port">Port</label>
<input type="number" id="smtp-port" value="587" min="1" max="65535">
</div>
<div class="form-group" style="max-width:140px">
<label for="smtp-secure">Encryption</label>
<select id="smtp-secure">
<option value="0">STARTTLS (587)</option>
<option value="1">SSL/TLS (465)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="smtp-user">Username</label>
<input type="text" id="smtp-user" autocomplete="off">
</div>
<div class="form-group">
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
<input type="password" id="smtp-pass" autocomplete="new-password">
</div>
<div class="form-group">
<label for="smtp-from">From Address</label>
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
</div>
</div>
<div id="smtp-error" class="wizard-error" hidden></div>
<div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
</div>
<!-- Change own password -->
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3>
<div class="form-row">
<div class="form-group">
<label for="cp-current">Current Password</label>
<input type="password" id="cp-current" autocomplete="current-password">
</div>
<div class="form-group">
<label for="cp-new">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
<input type="password" id="cp-new" autocomplete="new-password">
</div>
<div class="form-group">
<label for="cp-confirm">Confirm New</label>
<input type="password" id="cp-confirm" autocomplete="new-password">
</div>
</div>
<div id="cp-error" class="wizard-error" hidden></div>
<div id="cp-success" class="import-result" hidden>Password changed.</div>
<button id="btn-change-password" class="btn-secondary" style="margin-top:8px">Change Password</button>
</div>
<!-- Link my OIDC identity (self-service, shown when OIDC is enabled) -->
<div id="oidc-link-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px" hidden>
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Single Sign-On</h3>
<p id="oidc-link-status" style="font-size:12px;color:var(--text-muted);margin-bottom:8px"></p>
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
</div>
</div>
</div>
<!-- Layout Editor Modal -->
<div id="layout-editor-overlay" class="modal-overlay"></div>
@@ -840,7 +886,8 @@
</div>
</div>
<div id="layout-save-status" style="font-size:11px;color:var(--text-muted);min-width:56px"></div>
<div style="margin-left:auto">
<div style="margin-left:auto;display:flex;gap:8px;align-items:center">
<button id="btn-layout-preview" class="btn-secondary btn-sm">⎙ Preview PDF</button>
<button id="btn-layout-reset" class="btn-secondary btn-sm" data-admin-only>↺ Reset to Default</button>
</div>
</div>
+188 -41
View File
@@ -65,7 +65,8 @@ async function checkAuth() {
return false;
}
if (location.hash === '#oidc-linked') {
history.replaceState(null, '', location.pathname);
// After OIDC link callback, navigate to SSO settings panel
location.hash = '#settings/sso';
// Fall through to normal auth check — user is still logged in
}
@@ -75,6 +76,8 @@ async function checkAuth() {
state.user = await res.json();
hideLoginOverlay();
applyRoleUI();
// Route to settings page if hash says so
if (location.hash.startsWith('#settings')) handleHashRoute();
return true;
}
// No session — check if this is first-run (no users at all)
@@ -165,6 +168,9 @@ async function logout() {
document.getElementById('login-error').hidden = true;
document.getElementById('login-setup-section').hidden = true;
document.getElementById('login-form-section').hidden = false;
// Ensure settings page is hidden and main app restored
document.getElementById('settings-page').hidden = true;
document.getElementById('main-app').hidden = false;
showLoginOverlay();
}
@@ -176,8 +182,9 @@ function applyRoleUI() {
const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor'));
document.getElementById('header-username').textContent = state.user ? state.user.username : '';
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
// Admin-only elements
// Admin-only elements (main app + settings sidebar)
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
// Editor+ elements (hide for viewers)
@@ -191,28 +198,62 @@ function applyRoleUI() {
let usersState = { users: [], editingId: null };
function openUsersModal() {
// ── Settings page navigation ────────────────────────────────────────────────
function navigateToSettings(tab) {
const isAdmin = state.user && state.user.role === 'admin';
document.getElementById('user-form-error').hidden = true;
document.getElementById('users-title').textContent = isAdmin ? 'Manage Users' : 'My Account';
document.getElementById('users-overlay').classList.add('open');
document.getElementById('users-modal').classList.add('open');
// Admin-only sections
document.getElementById('users-list').hidden = !isAdmin;
document.getElementById('user-form-section').hidden = !isAdmin;
document.getElementById('smtp-settings-section').hidden = !isAdmin;
if (isAdmin) {
loadUsers();
renderUfAccountCheckboxes();
loadSmtpSettings();
const defaultTab = isAdmin ? 'users' : 'password';
const resolved = tab || defaultTab;
// Guard non-admin from admin tabs
if (!isAdmin && (resolved === 'users' || resolved === 'smtp')) {
location.hash = '#settings/password';
return;
}
document.getElementById('main-app').hidden = true;
const sp = document.getElementById('settings-page');
sp.hidden = false;
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
// Check OIDC status to show/hide SSO tab
loadOidcLinkStatus();
activateSettingsTab(resolved);
}
function closeUsersModal() {
document.getElementById('users-overlay').classList.remove('open');
document.getElementById('users-modal').classList.remove('open');
cancelUserEdit();
function activateSettingsTab(tab) {
// Hide all panels, show the target
document.querySelectorAll('.settings-panel').forEach(p => { p.hidden = true; });
const panel = document.getElementById('settings-panel-' + tab);
if (panel) panel.hidden = false;
// Update sidebar active state
document.querySelectorAll('.settings-nav-item').forEach(a => {
a.classList.toggle('active', a.dataset.settingsTab === tab);
});
// Load data for the activated tab
if (tab === 'users') { loadUsers(); renderUfAccountCheckboxes(); }
if (tab === 'smtp') { loadSmtpSettings(); }
if (tab === 'sso') { loadOidcLinkStatus(); }
}
function showMainApp() {
document.getElementById('settings-page').hidden = true;
document.getElementById('main-app').hidden = false;
}
function handleHashRoute() {
const hash = location.hash;
if (hash.startsWith('#settings/')) {
const tab = hash.split('/')[1];
navigateToSettings(tab);
} else if (hash.startsWith('#settings')) {
navigateToSettings();
} else {
showMainApp();
}
}
async function loadUsers() {
@@ -275,8 +316,8 @@ function renderUfAccountCheckboxes() {
const acctRole = assignment ? assignment.role : 'viewer';
return `<label class="account-checkbox-label">
<input type="checkbox" name="uf-account" value="${a.id}"${checked ? ' checked' : ''}>
${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}
<select name="uf-account-role" data-account-id="${a.id}" style="margin-left:6px;font-size:12px">
<span>${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}</span>
<select name="uf-account-role" data-account-id="${a.id}" style="font-size:12px">
<option value="editor"${acctRole === 'editor' ? ' selected' : ''}>Editor</option>
<option value="viewer"${acctRole === 'viewer' ? ' selected' : ''}>Viewer</option>
</select>
@@ -739,6 +780,18 @@ async function deleteCheck(id) {
}
}
// Firefox on iOS blocks window.open(blob:) in a new tab; use a temporary <a download> instead.
function openPdfBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
async function generatePdf() {
const ids = [...state.selected];
if (ids.length === 0) return;
@@ -760,7 +813,7 @@ async function generatePdf() {
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
openPdfBlob(blob, 'checks.pdf');
await loadChecks(); // refresh to show printed status
} catch (err) {
countSpan.textContent = savedCount;
@@ -1389,14 +1442,18 @@ async function openDepositPanel(id = null) {
document.getElementById('dep-coin').value = dep.coin || '';
document.getElementById('dep-cashback').value = dep.cash_back || '';
depState.items = (dep.items || []).map(it => ({ ...it }));
while (depState.items.length < 30) depState.items.push(newDepItem());
} catch (err) {
alert('Error loading deposit: ' + err.message);
return;
}
} else {
depState.items = [newDepItem()];
depState.items = [];
}
// Always start with at least 30 slots (one full deposit slip page)
while (depState.items.length < 30) depState.items.push(newDepItem());
renderDepItems();
recalcDepTotals();
@@ -1422,6 +1479,13 @@ function newDepItem() {
}
function renderDepItems() {
const addBtn = document.getElementById('btn-add-dep-item');
if (addBtn) {
const count = depState.items.length;
addBtn.hidden = count >= 60; // hide once back page rows are added
addBtn.disabled = count >= 60;
addBtn.textContent = 'Add Back Page Rows';
}
const tbody = document.getElementById('dep-items-tbody');
tbody.innerHTML = depState.items.map((item, i) => `
<tr data-idx="${i}">
@@ -1444,6 +1508,8 @@ function renderDepItems() {
tbody.querySelectorAll('.dep-item-remove').forEach(btn => {
btn.addEventListener('click', () => {
depState.items.splice(parseInt(btn.dataset.idx, 10), 1);
// Maintain 30-slot minimum (one full slip page)
while (depState.items.length < 30) depState.items.push(newDepItem());
renderDepItems();
recalcDepTotals();
});
@@ -1554,7 +1620,7 @@ async function generateDepositPdf(type) {
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank');
openPdfBlob(blob, type === 'slip' ? 'deposit-slip.pdf' : 'deposit-report.pdf');
if (type === 'slip') await loadDeposits();
} catch (err) {
alert('PDF error: ' + err.message);
@@ -1664,9 +1730,12 @@ async function saveSmtpSettings() {
async function loadOidcLinkStatus() {
try {
const cfg = await fetch('/api/auth/oidc/config').then(r => r.json());
const section = document.getElementById('oidc-link-section');
if (!cfg.enabled) { section.hidden = true; return; }
section.hidden = false;
const ssoNavItem = document.querySelector('[data-settings-tab="sso"]');
if (!cfg.enabled) {
if (ssoNavItem) ssoNavItem.hidden = true;
return;
}
if (ssoNavItem) ssoNavItem.hidden = false;
const me = await apiFetch('GET', '/api/auth/me');
const statusEl = document.getElementById('oidc-link-status');
@@ -1844,7 +1913,7 @@ async function init() {
document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel);
document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit);
document.getElementById('btn-add-dep-item').addEventListener('click', () => {
depState.items.push(newDepItem());
while (depState.items.length < 60) depState.items.push(newDepItem());
renderDepItems();
});
document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip'));
@@ -1886,11 +1955,27 @@ async function init() {
document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword);
document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); });
// User management
document.getElementById('btn-users').addEventListener('click', openUsersModal);
document.getElementById('header-username').addEventListener('click', openUsersModal);
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
// User management / settings page
document.getElementById('btn-users').addEventListener('click', () => { location.hash = '#settings/users'; });
document.getElementById('header-username').addEventListener('click', () => {
const isAdmin = state.user && state.user.role === 'admin';
location.hash = isAdmin ? '#settings/users' : '#settings/password';
});
document.getElementById('settings-back-link').addEventListener('click', e => {
e.preventDefault();
location.hash = '';
});
document.getElementById('btn-settings-logout').addEventListener('click', () => {
location.hash = '';
logout();
});
// Sidebar tab navigation
document.querySelectorAll('.settings-nav-item').forEach(a => {
a.addEventListener('click', e => { e.preventDefault(); location.hash = a.getAttribute('href'); });
});
window.addEventListener('hashchange', () => {
if (state.user) handleHashRoute();
});
document.getElementById('users-list').addEventListener('click', e => {
const editBtn = e.target.closest('.user-btn-edit');
const deleteBtn = e.target.closest('.user-btn-delete');
@@ -1922,6 +2007,7 @@ async function init() {
document.getElementById('nudge-left').addEventListener('click', () => nudgeLayoutField(-1, 0));
document.getElementById('nudge-right').addEventListener('click', () => nudgeLayoutField( 1, 0));
document.getElementById('btn-layout-reset').addEventListener('click', resetLayoutToDefault);
document.getElementById('btn-layout-preview').addEventListener('click', previewLayoutPdf);
// Initial auth check → loads app if already signed in
const authed = await checkAuth();
@@ -2022,6 +2108,12 @@ function populateLayoutDropdown() {
).join('');
}
// Printing safe zone for user-adjustable fields (inches). MICR is exempt.
const SAFE_LEFT = 11 / 64;
const SAFE_RIGHT = 8.5 - 11 / 64;
const SAFE_TOP = 13 / 64;
const SAFE_BOTTOM = 3.5 - 0.5;
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs, text) {
const el = document.createElementNS(SVG_NS, tag);
@@ -2044,11 +2136,41 @@ function renderLayoutCanvas() {
// White check background
svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#bbb', 'stroke-width':1 }));
// Grid at 1/8" increments (darker every 1/4", darkest on whole inches)
for (let n8 = 1; n8 < Math.ceil(8.5 * 8); n8++) {
const x = (n8 / 8) * SCALE;
if (x >= W) break;
const isInch = n8 % 8 === 0;
const isQtr = n8 % 2 === 0;
const stroke = isInch ? '#d0d7de' : isQtr ? '#e4e8ed' : '#f0f2f5';
svg.appendChild(svgEl('line', { x1:x, y1:0, x2:x, y2:H, stroke, 'stroke-width':1 }));
}
for (let n8 = 1; n8 < Math.ceil(3.5 * 8); n8++) {
const y = (n8 / 8) * SCALE;
if (y >= H) break;
const isInch = n8 % 8 === 0;
const isQtr = n8 % 2 === 0;
const stroke = isInch ? '#d0d7de' : isQtr ? '#e4e8ed' : '#f0f2f5';
svg.appendChild(svgEl('line', { x1:0, y1:y, x2:W, y2:y, stroke, 'stroke-width':1 }));
}
// MICR reference line
const micrY = (3.5 - 0.267) * SCALE;
svg.appendChild(svgEl('line', { x1:0, y1:micrY, x2:W, y2:micrY, stroke:'#ccc', 'stroke-width':1, 'stroke-dasharray':'4,4' }));
svg.appendChild(svgEl('text', { x:4, y:micrY - 3, 'font-size':9, fill:'#bbb', 'font-family':'sans-serif' }, 'MICR'));
// Safe zone outline for user-adjustable fields
svg.appendChild(svgEl('rect', {
x: SAFE_LEFT * SCALE,
y: SAFE_TOP * SCALE,
width: (SAFE_RIGHT - SAFE_LEFT) * SCALE,
height: (SAFE_BOTTOM - SAFE_TOP) * SCALE,
fill: 'none',
stroke: '#60a5fa',
'stroke-width': 1,
'stroke-dasharray': '3,3',
}));
for (const f of layoutState.fields) {
const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id);
svg.appendChild(g);
@@ -2273,11 +2395,11 @@ function onLayoutDragMove(e) {
const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale;
const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId);
if (!f) return;
f.x_pos = clampIn(round16(layoutDrag.origX + dx), 0, 8.5);
f.y_pos = clampIn(round16(layoutDrag.origY + dy), 0, 3.5);
f.x_pos = clampIn(round16(layoutDrag.origX + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(layoutDrag.origY + dy), SAFE_TOP, SAFE_BOTTOM);
if (layoutDrag.moveEnd) {
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), 0, 8.5);
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), 0, 3.5);
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), SAFE_TOP, SAFE_BOTTOM);
}
// Update just the dragged element for smooth performance
const svg = document.querySelector('#layout-canvas-container svg');
@@ -2304,11 +2426,11 @@ function nudgeLayoutField(dx, dy) {
const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
if (!f) return;
const S = 1 / 16;
f.x_pos = clampIn(round16(f.x_pos + dx * S), 0, 8.5);
f.y_pos = clampIn(round16(f.y_pos + dy * S), 0, 3.5);
f.x_pos = clampIn(round16(f.x_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(f.y_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
if (f.field_type === 'Line' || f.field_type === 'Graph') {
f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), 0, 8.5);
f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), 0, 3.5);
f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
}
updateLayoutSidebar(f);
renderLayoutCanvas();
@@ -2335,6 +2457,31 @@ async function saveLayoutField(f) {
}
}
async function previewLayoutPdf() {
const btn = document.getElementById('btn-layout-preview');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/api/pdf/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: state.activeAccountId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
openPdfBlob(blob, 'layout-preview.pdf');
} catch (err) {
alert('Preview error: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
async function resetLayoutToDefault() {
if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return;
try {
+51
View File
@@ -70,4 +70,55 @@ router.post('/', async (req, res) => {
}
});
/**
* POST /api/pdf/preview
* Body: { account_id: X }
*
* Generates a layout preview PDF using dummy check data no real checks touched.
* Shows all three slots filled with sample data so every visible field is visible.
*/
router.post('/preview', async (req, res) => {
const resolvedAccountId = parseInt(req.body.account_id, 10);
if (!resolvedAccountId) return res.status(400).json({ error: 'account_id required' });
const account = db.prepare('SELECT * FROM account WHERE id = ?').get(resolvedAccountId);
if (!account) return res.status(404).json({ error: 'Account not found.' });
const fields = db.prepare('SELECT * FROM layout_fields WHERE account_id = ?').all(resolvedAccountId);
const DUMMY_CHECK = {
id: 0,
check_no: 1001,
payee: 'Sample Payee Name',
amount: 1234.56,
check_date: new Date().toISOString().slice(0, 10),
memo: 'Sample Memo',
payee_address1: '123 Sample Street',
payee_address2: 'City, ST 12345',
payee_address3: null,
payee_address4: null,
printed: 0,
account_id: resolvedAccountId,
};
const checks = [
{ ...DUMMY_CHECK, check_no: 1001 },
{ ...DUMMY_CHECK, check_no: 1002 },
{ ...DUMMY_CHECK, check_no: 1003 },
];
try {
const pdfBuffer = await generateCheckPdf(account, checks, fields);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="layout-preview.pdf"',
'Content-Length': pdfBuffer.length,
});
res.send(pdfBuffer);
} catch (err) {
console.error('Preview PDF error:', err);
res.status(500).json({ error: 'Preview generation failed.' });
}
});
module.exports = router;
+142 -11
View File
@@ -44,7 +44,7 @@ const SL = {
cX: 0.65,
// ── Depositor block ───────────────────────────────────────────────────────
depositorY: 0.28, // Y of company name (first depositor line)
depositorY: 0.42, // Y of company name (first depositor line)
// ── Date ─────────────────────────────────────────────────────────────────
dateY: 1.38, // Y of DATE label
@@ -87,8 +87,8 @@ const SL = {
checkCountValY: 6.1, // check count value start
// ── Colours ───────────────────────────────────────────────────────────────
bgLineColor: '#888888',
bgLabelColor: '#444444',
bgLineColor: '#333333',
bgLabelColor: '#111111',
bgHeaderColor: '#000000',
};
@@ -238,7 +238,17 @@ function generateDepositSlip(account, deposit, items) {
const depositTotal = subTotal - (deposit.cash_back || 0);
const checkCount = items.length;
const totalRows = SL.firstCheckRow + SL.maxChecks;
// Split items: first 30 on front, up to 30 more on back
const frontItems = items.slice(0, SL.maxChecks);
const backItems = items.slice(SL.maxChecks, SL.maxChecks * 2);
const hasBackPage = backItems.some(it => (it.amount || 0) > 0 || it.check_no || it.payee);
const backTotal = hasBackPage ? backItems.reduce((s, i) => s + (i.amount || 0), 0) : 0;
// When back page exists, add one extra row on front for "FROM REVERSE"
const fromReverseRow = hasBackPage ? SL.firstCheckRow + SL.maxChecks : null;
const totalRows = fromReverseRow != null
? SL.firstCheckRow + SL.maxChecks + 1
: SL.firstCheckRow + SL.maxChecks;
const totalRowY_ = rowTopY(totalRows);
const gridBottom = totalRowY_ + SL.rowH;
@@ -304,7 +314,7 @@ function generateDepositSlip(account, deposit, items) {
doc.text('TOTAL $', SL.cX * PT, rowY(totalRows) * PT - 5, { lineBreak: false });
// Top disclaimer (above grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666')
doc.font('Helvetica').fontSize(5).fillColor('#333333')
.text(
'DEPOSITS MAY NOT BE AVAILABLE FOR IMMEDIATE WITHDRAWAL',
SL.cX * PT, SL.disclaimerY * PT,
@@ -312,7 +322,7 @@ function generateDepositSlip(account, deposit, items) {
);
// Bottom disclaimer (below grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666')
doc.font('Helvetica').fontSize(5).fillColor('#333333')
.text(
'Checks and other items are received for deposit subject to the provisions of the Uniform Commercial Code or any applicable collection agreements.',
SL.cX * PT, (gridBottom + 0.05) * PT,
@@ -321,7 +331,7 @@ function generateDepositSlip(account, deposit, items) {
// DEPOSIT TICKET header
doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor)
.text('D E P O S I T T I C K E T', SL.cX * PT, 0.08 * PT,
.text('D E P O S I T T I C K E T', SL.cX * PT, 0.20 * PT,
{ width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false });
// ── Depositor block — account info, then bank info stacked below ────────
@@ -368,25 +378,32 @@ function generateDepositSlip(account, deposit, items) {
function drawAmountRow(amount, rowIdx) {
const y = (rowY(rowIdx) - 0.015) * PT;
doc.font('Courier').fontSize(8).fillColor('#000000');
doc.font('Courier-Bold').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, amount, dollarsRightX, y);
}
drawAmountRow(deposit.currency || 0, SL.currencyRow);
drawAmountRow(deposit.coin || 0, SL.coinRow);
items.slice(0, SL.maxChecks).forEach((item, i) => {
frontItems.forEach((item, i) => {
const r = SL.firstCheckRow + i;
const y = (rowY(r) - 0.015) * PT;
if (item.check_no) {
doc.font('Courier').fontSize(7).fillColor('#000000')
doc.font('Courier-Bold').fontSize(7).fillColor('#000000')
.text(String(item.check_no).slice(0, 8),
(SL.cX + 0.16) * PT, y,
(SL.cX + 0.28) * PT, y,
{ width: SL.checkNoW * PT, lineBreak: false });
}
drawAmountRow(item.amount || 0, r);
});
// "FROM REVERSE" row carries back-page subtotal onto the front
if (fromReverseRow != null) {
doc.font('Courier').fontSize(6).fillColor(SL.bgLabelColor)
.text('FROM REVERSE', SL.cX * PT, rowY(fromReverseRow) * PT - 4, { lineBreak: false });
drawAmountRow(backTotal, fromReverseRow);
}
drawAmountRow(depositTotal, totalRows);
// ── Rotated left strip elements ─────────────────────────────────────────
@@ -440,10 +457,124 @@ function generateDepositSlip(account, deposit, items) {
doc.restore(); // end slip position translate
if (hasBackPage) {
doc.addPage();
renderDepositBackPage(doc, backItems, backTotal);
}
doc.end();
});
}
// ── Back page renderer ────────────────────────────────────────────────────────
function renderDepositBackPage(doc, backItems, backTotal) {
// Same slip position and width as front (slipX=0, W=3.375").
// No left strip elements; grid starts near the top.
// Vertically center the grid on the 8.5" page.
// Grid height = (checksRow + maxChecks + 1 TOTAL row + 1 border) * rowH = 33 * 0.175 = 5.775"
// Allow ~0.45" above grid for title + column headers; remainder splits top/bottom.
const BK_GRID_HEIGHT = (1 + SL.maxChecks + 1 + 1) * SL.rowH; // 33 rows
const BK_TITLE_AREA = 0.45;
const BK_GRID_TOP = (SL.H - BK_GRID_HEIGHT - BK_TITLE_AREA) / 2 + BK_TITLE_AREA;
const BK_TITLE_Y = (SL.H - BK_GRID_HEIGHT - BK_TITLE_AREA) / 2;
const BK = {
gridTop: BK_GRID_TOP,
titleY: BK_TITLE_Y,
checksRow: 0,
firstRow: 1,
maxChecks: SL.maxChecks, // 30
};
const totalRows = BK.firstRow + BK.maxChecks; // "TOTAL $" row index
const bkRowTopY = r => BK.gridTop + r * SL.rowH;
const bkRowY = r => BK.gridTop + r * SL.rowH + SL.rowH * 0.7;
const gridTopPt = bkRowTopY(0) * PT;
const gridBotPt = (bkRowTopY(totalRows) + SL.rowH) * PT;
doc.save();
doc.translate(SL.slipX * PT, 0);
// ── Title ─────────────────────────────────────────────────────────────────
doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor)
.text('A D D I T I O N A L C H E C K L I S T I N G',
SL.cX * PT, BK.titleY * PT,
{ width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false });
// ── Grid verticals (same column positions as front) ───────────────────────
const dollarsRightX = SL.colCentsR - SL.colCentsW - SL.colDollarSep;
const dividerX = (dollarsRightX - 7 * SL.digitW) * PT;
const dollarsCentsX = dollarsRightX * PT;
doc.moveTo(dividerX, gridTopPt).lineTo(dividerX, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
doc.moveTo(dollarsCentsX, gridTopPt).lineTo(dollarsCentsX, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
doc.moveTo(SL.colCentsR * PT, gridTopPt).lineTo(SL.colCentsR * PT, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
// Column headers
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor);
const hdrY = (BK.gridTop - 0.10) * PT;
doc.text('DOLLARS', dollarsCentsX - 7 * SL.digitW * PT, hdrY,
{ width: 7 * SL.digitW * PT, align: 'center', lineBreak: false });
doc.text('CENTS', (SL.colCentsR - SL.colCentsW) * PT, hdrY,
{ width: SL.colCentsW * PT, align: 'center', lineBreak: false });
// "CHECKS:" header label
doc.font('Courier').fontSize(7).fillColor(SL.bgLabelColor)
.text('CHECKS:', SL.cX * PT, bkRowY(BK.checksRow) * PT - 5, { lineBreak: false });
// ── Horizontal grid lines ─────────────────────────────────────────────────
for (let r = 0; r <= totalRows + 1; r++) {
const y = bkRowTopY(r) * PT;
const isOuter = r === 0 || r === totalRows + 1;
doc.moveTo(SL.stripX * PT, y).lineTo(SL.colCentsR * PT, y)
.lineWidth(isOuter ? 0.75 : 0.3).stroke(SL.bgLineColor);
}
// ── Row numbers (continuing from front: 3160) ────────────────────────────
doc.font('Courier').fontSize(6).fillColor(SL.bgLabelColor);
for (let i = 0; i < BK.maxChecks; i++) {
const r = BK.firstRow + i;
doc.text(String(SL.maxChecks + i + 1), SL.cX * PT, bkRowY(r) * PT - 4,
{ width: 14, align: 'right', lineBreak: false });
}
// ── "TOTAL $" footer label ────────────────────────────────────────────────
doc.font('Courier-Bold').fontSize(7).fillColor('#000000')
.text('T O T A L $', SL.cX * PT, bkRowY(totalRows) * PT - 5, { lineBreak: false });
// ── "Forward to other side" in left strip (rotated) ───────────────────────
const fwdY = bkRowTopY(totalRows) + SL.rowH * 0.5;
doc.save();
doc.translate(SL.stripCenterX * PT, fwdY * PT);
doc.rotate(90);
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor)
.text('Forward to other side', 0, 0, { lineBreak: false });
doc.restore();
// ── Amount data ───────────────────────────────────────────────────────────
backItems.forEach((item, i) => {
const r = BK.firstRow + i;
const y = (bkRowY(r) - 0.015) * PT;
if (item.check_no) {
doc.font('Courier-Bold').fontSize(7).fillColor('#000000')
.text(String(item.check_no).slice(0, 8),
(SL.cX + 0.28) * PT, y,
{ width: SL.checkNoW * PT, lineBreak: false });
}
if ((item.amount || 0) > 0) {
doc.font('Courier-Bold').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, item.amount, dollarsRightX, y);
}
});
// Back page total
doc.font('Courier-Bold').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, backTotal, dollarsRightX, (bkRowY(totalRows) - 0.015) * PT);
doc.restore();
}
// ── Amount rendering helpers ──────────────────────────────────────────────────
/**
+15 -5
View File
@@ -235,17 +235,27 @@ function generateCheckPdf(account, checks, fields) {
}
// --- MICR line ---
// Anchor the second transit symbol (the 'A' after routing) at 2 59/64" from left.
// Routing extends left from that anchor; account + check extend right.
const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no);
const micrPos = pt(0.3, MICR_Y_IN);
const ANCHOR_IN = 2 + 59 / 64;
if (hasMicrFont) {
doc.font('MICR').fontSize(12).fillColor('#000000')
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
doc.font('MICR').fontSize(12).fillColor('#000000');
} else {
doc.font('Courier').fontSize(10).fillColor('#000000')
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
doc.font('Courier').fontSize(10).fillColor('#000000');
}
// Prefix = everything up to and including the second 'A' (first A + routing + second A).
const secondA = micrLine.indexOf('A', 1) + 1;
const prefix = micrLine.slice(0, secondA);
const prefixWidthPts = doc.widthOfString(prefix);
const anchorXPts = (ANCHOR_IN + offX) * POINTS_PER_INCH;
const micrXPts = anchorXPts - prefixWidthPts;
const micrYPts = (slotOriginY + MICR_Y_IN + offY) * POINTS_PER_INCH;
doc.text(micrLine, micrXPts, micrYPts, { lineBreak: false });
} // end slot loop
} // end page loop