Compare commits

..

10 Commits

Author SHA1 Message Date
steve 7d105bce21 chore: bump version to 0.4.6
Build and push Docker image / build-push (push) Has been cancelled
TODO to Issues / todo (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 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
8 changed files with 316 additions and 34 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.3", "version": "0.4.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.3", "version": "0.4.6",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.3", "version": "0.4.6",
"description": "Self-hosted check printing web app", "description": "Self-hosted check printing web app",
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
+4 -2
View File
@@ -679,7 +679,7 @@ input[type="file"] {
right: 0; right: 0;
width: 560px; width: 560px;
max-width: 98vw; max-width: 98vw;
height: 100vh; height: 100dvh;
background: var(--surface); background: var(--surface);
z-index: 101; z-index: 101;
box-shadow: -4px 0 24px rgba(0,0,0,0.15); box-shadow: -4px 0 24px rgba(0,0,0,0.15);
@@ -687,7 +687,7 @@ input[type="file"] {
transition: transform 0.2s ease; transition: transform 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow: hidden;
} }
#deposit-panel.open { transform: translateX(0); } #deposit-panel.open { transform: translateX(0); }
@@ -697,6 +697,8 @@ input[type="file"] {
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
flex: 1; flex: 1;
overflow-y: auto;
min-height: 0;
} }
.dep-summary { .dep-summary {
+2 -1
View File
@@ -886,7 +886,8 @@
</div> </div>
</div> </div>
<div id="layout-save-status" style="font-size:11px;color:var(--text-muted);min-width:56px"></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> <button id="btn-layout-reset" class="btn-secondary btn-sm" data-admin-only>↺ Reset to Default</button>
</div> </div>
</div> </div>
+99 -12
View File
@@ -780,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() { async function generatePdf() {
const ids = [...state.selected]; const ids = [...state.selected];
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -801,7 +813,7 @@ async function generatePdf() {
throw new Error(err.error || res.statusText); throw new Error(err.error || res.statusText);
} }
const blob = await res.blob(); const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank'); openPdfBlob(blob, 'checks.pdf');
await loadChecks(); // refresh to show printed status await loadChecks(); // refresh to show printed status
} catch (err) { } catch (err) {
countSpan.textContent = savedCount; countSpan.textContent = savedCount;
@@ -1430,14 +1442,18 @@ async function openDepositPanel(id = null) {
document.getElementById('dep-coin').value = dep.coin || ''; document.getElementById('dep-coin').value = dep.coin || '';
document.getElementById('dep-cashback').value = dep.cash_back || ''; document.getElementById('dep-cashback').value = dep.cash_back || '';
depState.items = (dep.items || []).map(it => ({ ...it })); depState.items = (dep.items || []).map(it => ({ ...it }));
while (depState.items.length < 30) depState.items.push(newDepItem());
} catch (err) { } catch (err) {
alert('Error loading deposit: ' + err.message); alert('Error loading deposit: ' + err.message);
return; return;
} }
} else { } 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(); renderDepItems();
recalcDepTotals(); recalcDepTotals();
@@ -1463,6 +1479,13 @@ function newDepItem() {
} }
function renderDepItems() { 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'); const tbody = document.getElementById('dep-items-tbody');
tbody.innerHTML = depState.items.map((item, i) => ` tbody.innerHTML = depState.items.map((item, i) => `
<tr data-idx="${i}"> <tr data-idx="${i}">
@@ -1485,6 +1508,8 @@ function renderDepItems() {
tbody.querySelectorAll('.dep-item-remove').forEach(btn => { tbody.querySelectorAll('.dep-item-remove').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
depState.items.splice(parseInt(btn.dataset.idx, 10), 1); 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(); renderDepItems();
recalcDepTotals(); recalcDepTotals();
}); });
@@ -1595,7 +1620,7 @@ async function generateDepositPdf(type) {
throw new Error(err.error || res.statusText); throw new Error(err.error || res.statusText);
} }
const blob = await res.blob(); 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(); if (type === 'slip') await loadDeposits();
} catch (err) { } catch (err) {
alert('PDF error: ' + err.message); alert('PDF error: ' + err.message);
@@ -1888,7 +1913,7 @@ async function init() {
document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel); document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel);
document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit); document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit);
document.getElementById('btn-add-dep-item').addEventListener('click', () => { document.getElementById('btn-add-dep-item').addEventListener('click', () => {
depState.items.push(newDepItem()); while (depState.items.length < 60) depState.items.push(newDepItem());
renderDepItems(); renderDepItems();
}); });
document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip')); document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip'));
@@ -1982,6 +2007,7 @@ async function init() {
document.getElementById('nudge-left').addEventListener('click', () => nudgeLayoutField(-1, 0)); document.getElementById('nudge-left').addEventListener('click', () => nudgeLayoutField(-1, 0));
document.getElementById('nudge-right').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-reset').addEventListener('click', resetLayoutToDefault);
document.getElementById('btn-layout-preview').addEventListener('click', previewLayoutPdf);
// Initial auth check → loads app if already signed in // Initial auth check → loads app if already signed in
const authed = await checkAuth(); const authed = await checkAuth();
@@ -2082,6 +2108,12 @@ function populateLayoutDropdown() {
).join(''); ).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'; const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs, text) { function svgEl(tag, attrs, text) {
const el = document.createElementNS(SVG_NS, tag); const el = document.createElementNS(SVG_NS, tag);
@@ -2104,11 +2136,41 @@ function renderLayoutCanvas() {
// White check background // White check background
svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#bbb', 'stroke-width':1 })); 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 // MICR reference line
const micrY = (3.5 - 0.267) * SCALE; 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('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')); 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) { for (const f of layoutState.fields) {
const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id); const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id);
svg.appendChild(g); svg.appendChild(g);
@@ -2333,11 +2395,11 @@ function onLayoutDragMove(e) {
const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale; const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale;
const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId); const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId);
if (!f) return; if (!f) return;
f.x_pos = clampIn(round16(layoutDrag.origX + dx), 0, 8.5); f.x_pos = clampIn(round16(layoutDrag.origX + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(layoutDrag.origY + dy), 0, 3.5); f.y_pos = clampIn(round16(layoutDrag.origY + dy), SAFE_TOP, SAFE_BOTTOM);
if (layoutDrag.moveEnd) { if (layoutDrag.moveEnd) {
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), 0, 8.5); f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), 0, 3.5); f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), SAFE_TOP, SAFE_BOTTOM);
} }
// Update just the dragged element for smooth performance // Update just the dragged element for smooth performance
const svg = document.querySelector('#layout-canvas-container svg'); const svg = document.querySelector('#layout-canvas-container svg');
@@ -2364,11 +2426,11 @@ function nudgeLayoutField(dx, dy) {
const f = layoutState.fields.find(x => x.id === layoutState.selectedId); const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
if (!f) return; if (!f) return;
const S = 1 / 16; const S = 1 / 16;
f.x_pos = clampIn(round16(f.x_pos + dx * S), 0, 8.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), 0, 3.5); f.y_pos = clampIn(round16(f.y_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
if (f.field_type === 'Line' || f.field_type === 'Graph') { 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.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), 0, 3.5); f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
} }
updateLayoutSidebar(f); updateLayoutSidebar(f);
renderLayoutCanvas(); renderLayoutCanvas();
@@ -2395,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() { async function resetLayoutToDefault() {
if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return; if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return;
try { 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; module.exports = router;
+142 -11
View File
@@ -44,7 +44,7 @@ const SL = {
cX: 0.65, cX: 0.65,
// ── Depositor block ─────────────────────────────────────────────────────── // ── Depositor block ───────────────────────────────────────────────────────
depositorY: 0.28, // Y of company name (first depositor line) depositorY: 0.42, // Y of company name (first depositor line)
// ── Date ───────────────────────────────────────────────────────────────── // ── Date ─────────────────────────────────────────────────────────────────
dateY: 1.38, // Y of DATE label dateY: 1.38, // Y of DATE label
@@ -87,8 +87,8 @@ const SL = {
checkCountValY: 6.1, // check count value start checkCountValY: 6.1, // check count value start
// ── Colours ─────────────────────────────────────────────────────────────── // ── Colours ───────────────────────────────────────────────────────────────
bgLineColor: '#888888', bgLineColor: '#333333',
bgLabelColor: '#444444', bgLabelColor: '#111111',
bgHeaderColor: '#000000', bgHeaderColor: '#000000',
}; };
@@ -238,7 +238,17 @@ function generateDepositSlip(account, deposit, items) {
const depositTotal = subTotal - (deposit.cash_back || 0); const depositTotal = subTotal - (deposit.cash_back || 0);
const checkCount = items.length; 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 totalRowY_ = rowTopY(totalRows);
const gridBottom = totalRowY_ + SL.rowH; 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 }); doc.text('TOTAL $', SL.cX * PT, rowY(totalRows) * PT - 5, { lineBreak: false });
// Top disclaimer (above grid) // Top disclaimer (above grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666') doc.font('Helvetica').fontSize(5).fillColor('#333333')
.text( .text(
'DEPOSITS MAY NOT BE AVAILABLE FOR IMMEDIATE WITHDRAWAL', 'DEPOSITS MAY NOT BE AVAILABLE FOR IMMEDIATE WITHDRAWAL',
SL.cX * PT, SL.disclaimerY * PT, SL.cX * PT, SL.disclaimerY * PT,
@@ -312,7 +322,7 @@ function generateDepositSlip(account, deposit, items) {
); );
// Bottom disclaimer (below grid) // Bottom disclaimer (below grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666') doc.font('Helvetica').fontSize(5).fillColor('#333333')
.text( .text(
'Checks and other items are received for deposit subject to the provisions of the Uniform Commercial Code or any applicable collection agreements.', '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, SL.cX * PT, (gridBottom + 0.05) * PT,
@@ -321,7 +331,7 @@ function generateDepositSlip(account, deposit, items) {
// DEPOSIT TICKET header // DEPOSIT TICKET header
doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor) 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 }); { width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false });
// ── Depositor block — account info, then bank info stacked below ──────── // ── Depositor block — account info, then bank info stacked below ────────
@@ -368,25 +378,32 @@ function generateDepositSlip(account, deposit, items) {
function drawAmountRow(amount, rowIdx) { function drawAmountRow(amount, rowIdx) {
const y = (rowY(rowIdx) - 0.015) * PT; 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); drawDigitAmount(doc, amount, dollarsRightX, y);
} }
drawAmountRow(deposit.currency || 0, SL.currencyRow); drawAmountRow(deposit.currency || 0, SL.currencyRow);
drawAmountRow(deposit.coin || 0, SL.coinRow); drawAmountRow(deposit.coin || 0, SL.coinRow);
items.slice(0, SL.maxChecks).forEach((item, i) => { frontItems.forEach((item, i) => {
const r = SL.firstCheckRow + i; const r = SL.firstCheckRow + i;
const y = (rowY(r) - 0.015) * PT; const y = (rowY(r) - 0.015) * PT;
if (item.check_no) { 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), .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 }); { width: SL.checkNoW * PT, lineBreak: false });
} }
drawAmountRow(item.amount || 0, r); 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); drawAmountRow(depositTotal, totalRows);
// ── Rotated left strip elements ───────────────────────────────────────── // ── Rotated left strip elements ─────────────────────────────────────────
@@ -440,10 +457,124 @@ function generateDepositSlip(account, deposit, items) {
doc.restore(); // end slip position translate doc.restore(); // end slip position translate
if (hasBackPage) {
doc.addPage();
renderDepositBackPage(doc, backItems, backTotal);
}
doc.end(); 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 ────────────────────────────────────────────────── // ── Amount rendering helpers ──────────────────────────────────────────────────
/** /**
+15 -5
View File
@@ -235,17 +235,27 @@ function generateCheckPdf(account, checks, fields) {
} }
// --- MICR line --- // --- 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 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) { if (hasMicrFont) {
doc.font('MICR').fontSize(12).fillColor('#000000') doc.font('MICR').fontSize(12).fillColor('#000000');
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
} else { } else {
doc.font('Courier').fontSize(10).fillColor('#000000') doc.font('Courier').fontSize(10).fillColor('#000000');
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
} }
// 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 slot loop
} // end page loop } // end page loop