From 7ccfc133f811022ce1449b8fa39e6ac600942505 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 3 Jun 2026 01:05:10 -0400 Subject: [PATCH] Bill-payment checks: per-element positioning + visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bill-payment check generator (checkPdf.ts) now supports per-field position offsets (X/Y inches) and show/hide for every element — check number, return address, bank block, date, pay-to, amounts, payee address, memo, signature, and MICR — layered on the existing layout (defaults render identically). Edited in Settings → Check Setup ("Element positions"). Stored in accounting.check_settings.field_positions (jsonb). Also replaced a "→" with ">" in the MICR placeholder to avoid the UTF-16 spacing artifact. Migration applied: check_settings.field_positions. Co-Authored-By: Claude Opus 4.8 --- src/pages/accounting/AccountingBillsPage.tsx | 1 + .../accounting/AccountingCheckSetupPage.tsx | 48 ++- src/pages/accounting/lib/checkPdf.ts | 367 ++++++++++-------- 3 files changed, 261 insertions(+), 155 deletions(-) diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index 291c1a9..69d9f71 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -368,6 +368,7 @@ export default function AccountingBillsPage() { micrOffsetY: (cs as any)?.micr_offset_y ?? 0, micrGap1: (cs as any)?.micr_gap_1 ?? 1, micrGap2: (cs as any)?.micr_gap_2 ?? 1, + fieldPositions: (cs as any)?.field_positions ?? {}, } ); const w = window.open(""); diff --git a/src/pages/accounting/AccountingCheckSetupPage.tsx b/src/pages/accounting/AccountingCheckSetupPage.tsx index 0d7529a..d254095 100644 --- a/src/pages/accounting/AccountingCheckSetupPage.tsx +++ b/src/pages/accounting/AccountingCheckSetupPage.tsx @@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch"; import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react"; import { toast } from "sonner"; -import { generateCheckPDF, generateTestAlignmentPDF } from "./lib/checkPdf"; +import { generateCheckPDF, generateTestAlignmentPDF, CHECK_FIELD_KEYS, CHECK_FIELD_LABELS } from "./lib/checkPdf"; export default function AccountingCheckSetupPage() { const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); @@ -37,6 +37,7 @@ export default function AccountingCheckSetupPage() { const [micrOffsetY, setMicrOffsetY] = useState(0); const [micrGap1, setMicrGap1] = useState(1); const [micrGap2, setMicrGap2] = useState(1); + const [fieldPositions, setFieldPositions] = useState({}); const { data: settings } = useQuery({ queryKey: ["check-settings", cid], @@ -71,6 +72,7 @@ export default function AccountingCheckSetupPage() { setMicrOffsetY(Number(s.micr_offset_y ?? 0)); setMicrGap1(Number(s.micr_gap_1 ?? 1)); setMicrGap2(Number(s.micr_gap_2 ?? 1)); + setFieldPositions(s.field_positions ?? {}); }, [settings]); const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, "")); @@ -96,6 +98,7 @@ export default function AccountingCheckSetupPage() { micr_offset_y: micrOffsetY, micr_gap_1: micrGap1, micr_gap_2: micrGap2, + field_positions: fieldPositions, }, { onConflict: "company_id" }); setSaving(false); if (error) return toast.error(error.message); @@ -136,7 +139,7 @@ export default function AccountingCheckSetupPage() { style: defaultStyle as any, position: defaultPosition as any, fontSize: fontSize as any, - offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, + offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions, }); const w = window.open(""); if (w) w.document.write(``); @@ -147,12 +150,17 @@ export default function AccountingCheckSetupPage() { style: defaultStyle as any, position: defaultPosition as any, fontSize: fontSize as any, - offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, + offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions, }); const w = window.open(""); if (w) w.document.write(``); }; + const updateFieldPos = (key, patch) => + setFieldPositions((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), ...patch } })); + const resetFieldPos = (key) => + setFieldPositions((prev) => { const n = { ...prev }; delete n[key]; return n; }); + if (!associationId) return

Select an association.

; if (companyLoading) return
; if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; @@ -358,6 +366,40 @@ export default function AccountingCheckSetupPage() { + +
+

Element positions (nudge any element; inches, +right / +down)

+
+
Element
+
Show
+
X
+
Y
+
+
+ {CHECK_FIELD_KEYS.map((key) => { + const fp = fieldPositions[key] || {}; + return ( +
+
{CHECK_FIELD_LABELS[key]}
+
+ updateFieldPos(key, { hidden: !v })} /> +
+
+ updateFieldPos(key, { dx: parseFloat(e.target.value) || 0 })} /> +
+
+ updateFieldPos(key, { dy: parseFloat(e.target.value) || 0 })} /> +
+
+ +
+
+ ); + })} +
diff --git a/src/pages/accounting/lib/checkPdf.ts b/src/pages/accounting/lib/checkPdf.ts index 23299c1..727dfab 100644 --- a/src/pages/accounting/lib/checkPdf.ts +++ b/src/pages/accounting/lib/checkPdf.ts @@ -55,6 +55,28 @@ export type CheckPrintOptions = { micrGap1?: number; /** Spaces between MICR routing and account segments (default 1) */ micrGap2?: number; + /** Per-field position offsets (inches) + visibility, keyed by field. */ + fieldPositions?: Record; +}; + +/** Field keys for per-element check positioning (bill-payment check face). */ +export const CHECK_FIELD_KEYS = [ + "check_number", "company", "bank", "date", "pay_to", + "amount_box", "amount_words", "address_block", "memo", "signature", "micr", +] as const; + +export const CHECK_FIELD_LABELS: Record = { + check_number: "Check Number", + company: "Company / Return Address", + bank: "Bank Name & Address", + date: "Date", + pay_to: "Pay to the Order Of", + amount_box: "Numeric Amount ($)", + amount_words: "Written Amount", + address_block: "Payee Address Block", + memo: "Memo", + signature: "Signature Line & Label", + micr: "MICR Line", }; // ── Constants ──────────────────────────────────────────────────────────────── @@ -110,10 +132,26 @@ function hline(doc: jsPDF, x1: number, y: number, x2: number, w = 0.006, gray = // ── Check face ─────────────────────────────────────────────────────────────── -function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0, micrGap1 = 1, micrGap2 = 1) { +function drawCheck( + doc: jsPDF, + originY: number, + c: CheckData, + ox = 0, + micrOy = 0, + micrGap1 = 1, + micrGap2 = 1, + fields: Record = {} +) { const y = (dy: number) => originY + dy; const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate + // Per-field positioning: each labeled element can be nudged (dx/dy in inches) + // or hidden. Layered on top of the default layout, so {} renders identically. + const F = (k: string): { dx?: number; dy?: number; hidden?: boolean } => (fields && fields[k]) || {}; + const hid = (k: string) => F(k).hidden === true; + const ax = (k: string) => Number(F(k).dx) || 0; + const ay = (k: string) => Number(F(k).dy) || 0; + // VOID watermark if (c.voided) { doc.saveGraphicsState(); @@ -125,175 +163,199 @@ function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0 doc.setTextColor(0); } - // ── Top row ────────────────────────────────────────────────────────────── - doc.setFont("helvetica", "bold"); - doc.setFontSize(12); - doc.setTextColor(0); - doc.text(String(c.checkNumber), x(RIGHT), y(0.18), { align: "right" }); + // ── Check number (top-right) ── + if (!hid("check_number")) { + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(0); + doc.text(String(c.checkNumber), x(RIGHT + ax("check_number")), y(0.18 + ay("check_number")), { align: "right" }); + } - doc.setFont("helvetica", "bold"); - doc.setFontSize(8.5); - doc.text(c.companyName, x(ML), y(0.18)); - - doc.setFont("helvetica", "normal"); - doc.setFontSize(7.5); - const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean); - companyAddrLines.slice(0, 4).forEach((line, i) => { - doc.text(line, x(ML), y(0.32 + i * 0.135)); - }); + // ── Company / return address ── + if (!hid("company")) { + const dx = ax("company"), dy = ay("company"); + doc.setFont("helvetica", "bold"); + doc.setFontSize(8.5); + doc.setTextColor(0); + doc.text(c.companyName, x(ML + dx), y(0.18 + dy)); + doc.setFont("helvetica", "normal"); + doc.setFontSize(7.5); + const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean); + companyAddrLines.slice(0, 4).forEach((line, i) => { + doc.text(line, x(ML + dx), y(0.32 + i * 0.135 + dy)); + }); + } + // ── Bank name / address ── const bankX = ML + CW * 0.52; - doc.setFont("helvetica", "bold"); - doc.setFontSize(9); - doc.text(c.bankName ?? "", x(bankX), y(0.18)); - doc.setFont("helvetica", "normal"); - doc.setFontSize(7.5); - if (c.bankAddress) doc.text(c.bankAddress, x(bankX), y(0.32)); + if (!hid("bank")) { + const dx = ax("bank"), dy = ay("bank"); + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.setTextColor(0); + doc.text(c.bankName ?? "", x(bankX + dx), y(0.18 + dy)); + doc.setFont("helvetica", "normal"); + doc.setFontSize(7.5); + if (c.bankAddress) doc.text(c.bankAddress, x(bankX + dx), y(0.32 + dy)); + } - // ── Date line ──────────────────────────────────────────────────────────── - const dateLineY = y(0.78); - doc.setFont("helvetica", "normal"); - doc.setFontSize(9); - doc.text(c.date, x(RIGHT), dateLineY - 0.04, { align: "right" }); - hline(doc, x(ML + CW * 0.62), dateLineY, x(RIGHT)); + // ── Date + date line ── + if (!hid("date")) { + const dx = ax("date"); + const dateLineY = y(0.78 + ay("date")); + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + doc.setTextColor(0); + doc.text(c.date, x(RIGHT + dx), dateLineY - 0.04, { align: "right" }); + hline(doc, x(ML + CW * 0.62 + dx), dateLineY, x(RIGHT + dx)); + } - // ── Pay to the order of ────────────────────────────────────────────────── - const payY = y(1.10); + // ── Pay to the order of ── const payeeX = ML + 0.78; + if (!hid("pay_to")) { + const dx = ax("pay_to"); + const payY = y(1.10 + ay("pay_to")); + doc.setFont("helvetica", "normal"); + doc.setFontSize(7); + doc.setTextColor(0); + doc.text("PAY TO THE", x(ML + dx), payY - 0.10); + doc.text("ORDER OF", x(ML + dx), payY + 0.03); + doc.setFont("helvetica", "bold"); + doc.setFontSize(10); + doc.text(c.payee, x(payeeX + dx), payY); + hline(doc, x(payeeX - 0.02 + dx), payY + 0.06, x(RIGHT - 1.55 + dx)); + } - doc.setFont("helvetica", "normal"); - doc.setFontSize(7); - doc.text("PAY TO THE", x(ML), payY - 0.10); - doc.text("ORDER OF", x(ML), payY + 0.03); + // ── Numeric amount box ── + if (!hid("amount_box")) { + const dx = ax("amount_box"); + const amtY = y(1.10 + ay("amount_box")); + doc.setFont("helvetica", "bold"); + doc.setFontSize(11); + doc.setTextColor(0); + doc.text(`$${c.amount.toFixed(2)}`, x(RIGHT - 0.08 + dx), amtY, { align: "right" }); + doc.setFont("helvetica", "normal"); + doc.setFontSize(7.5); + doc.setTextColor(80); + doc.text("DOLLARS", x(RIGHT - 0.08 + dx), amtY + 0.17, { align: "right" }); + doc.setTextColor(0); + } - doc.setFont("helvetica", "bold"); - doc.setFontSize(10); - doc.text(c.payee, x(payeeX), payY); - hline(doc, x(payeeX - 0.02), payY + 0.06, x(RIGHT - 1.55)); + // ── Written amount ── + if (!hid("amount_words")) { + const dx = ax("amount_words"); + const writtenY = y(1.40 + ay("amount_words")); + const amountWords = numberToWords(c.amount).toUpperCase(); + doc.setFont("helvetica", "normal"); + doc.setFontSize(8.5); + doc.setTextColor(0); + const wordsW = doc.getTextWidth(amountWords); + const starW = doc.getTextWidth("*"); + const starsTotal = Math.max(0, Math.floor((CW * 0.90 - wordsW) / starW) - 2); + const leftStars = Math.max(4, Math.floor(starsTotal * 0.18)); + const rightStars = Math.max(0, starsTotal - leftStars); + doc.text("*".repeat(leftStars) + " " + amountWords + " " + "*".repeat(rightStars), + x(ML + dx), writtenY, { maxWidth: CW }); + hline(doc, x(ML + dx), writtenY + 0.06, x(RIGHT + dx), 0.008, 0); + } - // Amount box - doc.setFont("helvetica", "bold"); - doc.setFontSize(11); - doc.text(`$${c.amount.toFixed(2)}`, x(RIGHT - 0.08), payY, { align: "right" }); - doc.setFont("helvetica", "normal"); - doc.setFontSize(7.5); - doc.setTextColor(80); - doc.text("DOLLARS", x(RIGHT - 0.08), payY + 0.17, { align: "right" }); - doc.setTextColor(0); - - // ── Written amount — with more breathing room after the pay-to line ──────── - // numberToWords() already ends with "Dollars" — no suffix needed - const writtenY = payY + 0.30; // extra gap below pay-to line - const amountWords = numberToWords(c.amount).toUpperCase(); - doc.setFont("helvetica", "normal"); - doc.setFontSize(8.5); - const wordsW = doc.getTextWidth(amountWords); - const starW = doc.getTextWidth("*"); - const starsTotal = Math.max(0, Math.floor((CW * 0.90 - wordsW) / starW) - 2); - const leftStars = Math.max(4, Math.floor(starsTotal * 0.18)); - const rightStars = Math.max(0, starsTotal - leftStars); - doc.text("*".repeat(leftStars) + " " + amountWords + " " + "*".repeat(rightStars), - x(ML), writtenY, { maxWidth: CW }); - // Underline the written amount - hline(doc, x(ML), writtenY + 0.06, x(RIGHT), 0.008, 0); - - // ── Envelope address block — more space below written amount ───────────── - // Layout: payee name (bold) on left | "Authorized Signer" on right (same Y) - // address line 1 (indented) - // address line 2 (indented) + // ── Envelope address block ── const addrLines = c.payeeAddress ? c.payeeAddress.split(/\n/).map(s => s.trim()).filter(Boolean) : []; - const addrBlockY = writtenY + 0.28; // extra gap below written amount const addrIndentX = ML + 0.35; - - // Payee name bold (envelope window — first line) - doc.setFont("helvetica", "bold"); - doc.setFontSize(9); - doc.text(c.payee, x(addrIndentX), addrBlockY); - - // "Authorized Signer" on the RIGHT, same Y as payee name - doc.setFont("helvetica", "normal"); - doc.setFontSize(7.5); - doc.setTextColor(80); - doc.text("Authorized Signer", x(RIGHT), addrBlockY, { align: "right" }); - doc.setTextColor(0); - - // Address lines below, slightly indented - if (addrLines.length > 0) { - doc.setFont("helvetica", "normal"); - doc.setFontSize(8.5); - doc.setTextColor(30); - addrLines.slice(0, 3).forEach((line, i) => { - doc.text(line, x(addrIndentX + 0.05), addrBlockY + 0.155 + i * 0.145); - }); + if (!hid("address_block")) { + const dx = ax("address_block"); + const addrBlockY = y(1.68 + ay("address_block")); + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); doc.setTextColor(0); - } - - // ── Memo / Authorized Signature line — no grey divider, just space ──────── - const addrEndY = addrBlockY + 0.155 + Math.max(addrLines.length, 0) * 0.145; - const bottomLineY = addrEndY + 0.30; // generous gap, no grey line between - const sigLabelX = PW / 2 + 0.2; - - // Memo (left side) - doc.setFont("helvetica", "normal"); - doc.setFontSize(8); - doc.setTextColor(0); - doc.text("MEMO", x(ML), bottomLineY); - hline(doc, x(ML + 0.5), bottomLineY, x(ML + 2.8)); - if (c.memo) { + doc.text(c.payee, x(addrIndentX + dx), addrBlockY); + doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); - doc.text(c.memo, x(ML + 0.55), bottomLineY - 0.04); - } - - // "AUTHORIZED SIGNATURE" label centered above the right-side line - doc.setFontSize(7); - doc.setTextColor(80); - const sigCenterX = sigLabelX + (RIGHT - sigLabelX) / 2; - doc.text("AUTHORIZED SIGNATURE", x(sigCenterX), bottomLineY - 0.36, { align: "center" }); - doc.setTextColor(0); - - // Signature image — natural aspect ratio, bottom edge sits ON the line - if (c.printSignature && c.signatureDataUrl) { - try { - const props = doc.getImageProperties(c.signatureDataUrl); - const maxW = RIGHT - sigLabelX - 0.1; - const maxH = 0.42; - const ratio = props.width / props.height; - let sigW = maxW; - let sigH = sigW / ratio; - if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; } - const sigX = x(sigCenterX - sigW / 2); - const sigY = bottomLineY - sigH; - doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH); - } catch { /* silent */ } - } - - // Signature LINE - hline(doc, x(sigLabelX), bottomLineY, x(RIGHT)); - - // ── MICR line (X offset + separate MICR Y fine-tune) ───────────────────── - const micrY = y(CHECK_H - 0.22) + micrOy; - const micr = buildMicr( - (c.routingNumber ?? "").replace(/\D/g, ""), - (c.accountNumber ?? "").replace(/\D/g, ""), - c.checkNumber, - micrGap1, - micrGap2 - ); - - if (micr) { - ensureMicrFont(doc); - doc.setFontSize(11); + doc.setTextColor(80); + doc.text("Authorized Signer", x(RIGHT + dx), addrBlockY, { align: "right" }); doc.setTextColor(0); - doc.text(micr, x(PW / 2), micrY, { align: "center" }); + if (addrLines.length > 0) { + doc.setFont("helvetica", "normal"); + doc.setFontSize(8.5); + doc.setTextColor(30); + addrLines.slice(0, 3).forEach((line, i) => { + doc.text(line, x(addrIndentX + 0.05 + dx), addrBlockY + 0.155 + i * 0.145); + }); + doc.setTextColor(0); + } + } + + // ── Memo + signature (flow below the address block) ── + const addrEndDy = 1.68 + 0.155 + Math.max(addrLines.length, 0) * 0.145; + const bottomDy = addrEndDy + 0.30; + const sigLabelX = PW / 2 + 0.2; + const sigCenterX = sigLabelX + (RIGHT - sigLabelX) / 2; + + if (!hid("memo")) { + const dx = ax("memo"); + const bottomLineY = y(bottomDy + ay("memo")); doc.setFont("helvetica", "normal"); - } else { + doc.setFontSize(8); + doc.setTextColor(0); + doc.text("MEMO", x(ML + dx), bottomLineY); + hline(doc, x(ML + 0.5 + dx), bottomLineY, x(ML + 2.8 + dx)); + if (c.memo) { + doc.setFontSize(7.5); + doc.text(c.memo, x(ML + 0.55 + dx), bottomLineY - 0.04); + } + } + + if (!hid("signature")) { + const dx = ax("signature"); + const bottomLineY = y(bottomDy + ay("signature")); doc.setFont("helvetica", "normal"); doc.setFontSize(7); - doc.setTextColor(160); - doc.text("[ Configure routing & account numbers in Settings → Check Setup ]", x(PW / 2), micrY, { align: "center" }); + doc.setTextColor(80); + doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY - 0.36, { align: "center" }); doc.setTextColor(0); + if (c.printSignature && c.signatureDataUrl) { + try { + const props = doc.getImageProperties(c.signatureDataUrl); + const maxW = RIGHT - sigLabelX - 0.1; + const maxH = 0.42; + const ratio = props.width / props.height; + let sigW = maxW; + let sigH = sigW / ratio; + if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; } + const sigX = x(sigCenterX + dx - sigW / 2); + const sigY = bottomLineY - sigH; + doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH); + } catch { /* silent */ } + } + hline(doc, x(sigLabelX + dx), bottomLineY, x(RIGHT + dx)); + } + + // ── MICR line (X offset + separate MICR Y fine-tune) ── + if (!hid("micr")) { + const dx = ax("micr"); + const micrY = y(CHECK_H - 0.22 + ay("micr")) + micrOy; + const micr = buildMicr( + (c.routingNumber ?? "").replace(/\D/g, ""), + (c.accountNumber ?? "").replace(/\D/g, ""), + c.checkNumber, + micrGap1, + micrGap2 + ); + if (micr) { + ensureMicrFont(doc); + doc.setFontSize(11); + doc.setTextColor(0); + doc.text(micr, x(PW / 2 + dx), micrY, { align: "center" }); + doc.setFont("helvetica", "normal"); + } else { + doc.setFont("helvetica", "normal"); + doc.setFontSize(7); + doc.setTextColor(160); + doc.text("[ Configure routing & account numbers in Settings > Check Setup ]", x(PW / 2 + dx), micrY, { align: "center" }); + doc.setTextColor(0); + } } } @@ -362,19 +424,20 @@ export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions): const micrOy = opts.micrOffsetY ?? 0; const g1 = opts.micrGap1 ?? 1; const g2 = opts.micrGap2 ?? 1; + const fp = opts.fieldPositions || {}; checks.forEach((c, idx) => { if (idx > 0) doc.addPage(); if (opts.style === "voucher") { - drawCheck(doc, 0 + oy, c, ox, micrOy, g1, g2); + drawCheck(doc, 0 + oy, c, ox, micrOy, g1, g2, fp); drawStub(doc, SECTION_H + oy, c, ox); drawStub(doc, SECTION_H * 2 + oy, c, ox); } else { let posY = 0; if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2; if (opts.position === "bottom") posY = PH - CHECK_H; - drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2); + drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2, fp); } });