mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Bill-payment checks: per-element positioning + visibility
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 <noreply@anthropic.com>
This commit is contained in:
@@ -368,6 +368,7 @@ export default function AccountingBillsPage() {
|
|||||||
micrOffsetY: (cs as any)?.micr_offset_y ?? 0,
|
micrOffsetY: (cs as any)?.micr_offset_y ?? 0,
|
||||||
micrGap1: (cs as any)?.micr_gap_1 ?? 1,
|
micrGap1: (cs as any)?.micr_gap_1 ?? 1,
|
||||||
micrGap2: (cs as any)?.micr_gap_2 ?? 1,
|
micrGap2: (cs as any)?.micr_gap_2 ?? 1,
|
||||||
|
fieldPositions: (cs as any)?.field_positions ?? {},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const w = window.open("");
|
const w = window.open("");
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react";
|
import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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() {
|
export default function AccountingCheckSetupPage() {
|
||||||
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
@@ -37,6 +37,7 @@ export default function AccountingCheckSetupPage() {
|
|||||||
const [micrOffsetY, setMicrOffsetY] = useState(0);
|
const [micrOffsetY, setMicrOffsetY] = useState(0);
|
||||||
const [micrGap1, setMicrGap1] = useState(1);
|
const [micrGap1, setMicrGap1] = useState(1);
|
||||||
const [micrGap2, setMicrGap2] = useState(1);
|
const [micrGap2, setMicrGap2] = useState(1);
|
||||||
|
const [fieldPositions, setFieldPositions] = useState({});
|
||||||
|
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = useQuery({
|
||||||
queryKey: ["check-settings", cid],
|
queryKey: ["check-settings", cid],
|
||||||
@@ -71,6 +72,7 @@ export default function AccountingCheckSetupPage() {
|
|||||||
setMicrOffsetY(Number(s.micr_offset_y ?? 0));
|
setMicrOffsetY(Number(s.micr_offset_y ?? 0));
|
||||||
setMicrGap1(Number(s.micr_gap_1 ?? 1));
|
setMicrGap1(Number(s.micr_gap_1 ?? 1));
|
||||||
setMicrGap2(Number(s.micr_gap_2 ?? 1));
|
setMicrGap2(Number(s.micr_gap_2 ?? 1));
|
||||||
|
setFieldPositions(s.field_positions ?? {});
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
|
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
|
||||||
@@ -96,6 +98,7 @@ export default function AccountingCheckSetupPage() {
|
|||||||
micr_offset_y: micrOffsetY,
|
micr_offset_y: micrOffsetY,
|
||||||
micr_gap_1: micrGap1,
|
micr_gap_1: micrGap1,
|
||||||
micr_gap_2: micrGap2,
|
micr_gap_2: micrGap2,
|
||||||
|
field_positions: fieldPositions,
|
||||||
}, { onConflict: "company_id" });
|
}, { onConflict: "company_id" });
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (error) return toast.error(error.message);
|
if (error) return toast.error(error.message);
|
||||||
@@ -136,7 +139,7 @@ export default function AccountingCheckSetupPage() {
|
|||||||
style: defaultStyle as any,
|
style: defaultStyle as any,
|
||||||
position: defaultPosition as any,
|
position: defaultPosition as any,
|
||||||
fontSize: fontSize as any,
|
fontSize: fontSize as any,
|
||||||
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
|
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
|
||||||
});
|
});
|
||||||
const w = window.open("");
|
const w = window.open("");
|
||||||
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`);
|
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`);
|
||||||
@@ -147,12 +150,17 @@ export default function AccountingCheckSetupPage() {
|
|||||||
style: defaultStyle as any,
|
style: defaultStyle as any,
|
||||||
position: defaultPosition as any,
|
position: defaultPosition as any,
|
||||||
fontSize: fontSize as any,
|
fontSize: fontSize as any,
|
||||||
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
|
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
|
||||||
});
|
});
|
||||||
const w = window.open("");
|
const w = window.open("");
|
||||||
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
|
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 <p className="text-sm text-muted-foreground">Select an association.</p>;
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||||
@@ -358,6 +366,40 @@ export default function AccountingCheckSetupPage() {
|
|||||||
<GapControl label="Routing → Account" value={micrGap2} onChange={setMicrGap2} />
|
<GapControl label="Routing → Account" value={micrGap2} onChange={setMicrGap2} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="text-sm font-medium">Element positions <span className="text-xs font-normal text-muted-foreground">(nudge any element; inches, +right / +down)</span></p>
|
||||||
|
<div className="mt-2 grid grid-cols-12 gap-2 text-xs font-semibold text-muted-foreground px-1">
|
||||||
|
<div className="col-span-5">Element</div>
|
||||||
|
<div className="col-span-2 text-center">Show</div>
|
||||||
|
<div className="col-span-2">X</div>
|
||||||
|
<div className="col-span-2">Y</div>
|
||||||
|
<div className="col-span-1" />
|
||||||
|
</div>
|
||||||
|
{CHECK_FIELD_KEYS.map((key) => {
|
||||||
|
const fp = fieldPositions[key] || {};
|
||||||
|
return (
|
||||||
|
<div key={key} className="grid grid-cols-12 gap-2 items-center px-1 py-1 rounded hover:bg-background">
|
||||||
|
<div className="col-span-5 text-sm">{CHECK_FIELD_LABELS[key]}</div>
|
||||||
|
<div className="col-span-2 flex justify-center">
|
||||||
|
<Switch checked={fp.hidden !== true} onCheckedChange={(v) => updateFieldPos(key, { hidden: !v })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Input type="number" step="0.05" className="h-7 text-xs font-mono" value={fp.dx ?? 0}
|
||||||
|
onChange={(e) => updateFieldPos(key, { dx: parseFloat(e.target.value) || 0 })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Input type="number" step="0.05" className="h-7 text-xs font-mono" value={fp.dy ?? 0}
|
||||||
|
onChange={(e) => updateFieldPos(key, { dy: parseFloat(e.target.value) || 0 })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-right">
|
||||||
|
<button type="button" onClick={() => resetFieldPos(key)} title="Reset"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground">↺</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ export type CheckPrintOptions = {
|
|||||||
micrGap1?: number;
|
micrGap1?: number;
|
||||||
/** Spaces between MICR routing and account segments (default 1) */
|
/** Spaces between MICR routing and account segments (default 1) */
|
||||||
micrGap2?: number;
|
micrGap2?: number;
|
||||||
|
/** Per-field position offsets (inches) + visibility, keyed by field. */
|
||||||
|
fieldPositions?: Record<string, { dx?: number; dy?: number; hidden?: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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<string, string> = {
|
||||||
|
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 ────────────────────────────────────────────────────────────────
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
@@ -110,10 +132,26 @@ function hline(doc: jsPDF, x1: number, y: number, x2: number, w = 0.006, gray =
|
|||||||
|
|
||||||
// ── Check face ───────────────────────────────────────────────────────────────
|
// ── 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<string, { dx?: number; dy?: number; hidden?: boolean }> = {}
|
||||||
|
) {
|
||||||
const y = (dy: number) => originY + dy;
|
const y = (dy: number) => originY + dy;
|
||||||
const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate
|
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
|
// VOID watermark
|
||||||
if (c.voided) {
|
if (c.voided) {
|
||||||
doc.saveGraphicsState();
|
doc.saveGraphicsState();
|
||||||
@@ -125,175 +163,199 @@ function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0
|
|||||||
doc.setTextColor(0);
|
doc.setTextColor(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Top row ──────────────────────────────────────────────────────────────
|
// ── Check number (top-right) ──
|
||||||
doc.setFont("helvetica", "bold");
|
if (!hid("check_number")) {
|
||||||
doc.setFontSize(12);
|
doc.setFont("helvetica", "bold");
|
||||||
doc.setTextColor(0);
|
doc.setFontSize(12);
|
||||||
doc.text(String(c.checkNumber), x(RIGHT), y(0.18), { align: "right" });
|
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");
|
// ── Company / return address ──
|
||||||
doc.setFontSize(8.5);
|
if (!hid("company")) {
|
||||||
doc.text(c.companyName, x(ML), y(0.18));
|
const dx = ax("company"), dy = ay("company");
|
||||||
|
doc.setFont("helvetica", "bold");
|
||||||
doc.setFont("helvetica", "normal");
|
doc.setFontSize(8.5);
|
||||||
doc.setFontSize(7.5);
|
doc.setTextColor(0);
|
||||||
const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean);
|
doc.text(c.companyName, x(ML + dx), y(0.18 + dy));
|
||||||
companyAddrLines.slice(0, 4).forEach((line, i) => {
|
doc.setFont("helvetica", "normal");
|
||||||
doc.text(line, x(ML), y(0.32 + i * 0.135));
|
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;
|
const bankX = ML + CW * 0.52;
|
||||||
doc.setFont("helvetica", "bold");
|
if (!hid("bank")) {
|
||||||
doc.setFontSize(9);
|
const dx = ax("bank"), dy = ay("bank");
|
||||||
doc.text(c.bankName ?? "", x(bankX), y(0.18));
|
doc.setFont("helvetica", "bold");
|
||||||
doc.setFont("helvetica", "normal");
|
doc.setFontSize(9);
|
||||||
doc.setFontSize(7.5);
|
doc.setTextColor(0);
|
||||||
if (c.bankAddress) doc.text(c.bankAddress, x(bankX), y(0.32));
|
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 ────────────────────────────────────────────────────────────
|
// ── Date + date line ──
|
||||||
const dateLineY = y(0.78);
|
if (!hid("date")) {
|
||||||
doc.setFont("helvetica", "normal");
|
const dx = ax("date");
|
||||||
doc.setFontSize(9);
|
const dateLineY = y(0.78 + ay("date"));
|
||||||
doc.text(c.date, x(RIGHT), dateLineY - 0.04, { align: "right" });
|
doc.setFont("helvetica", "normal");
|
||||||
hline(doc, x(ML + CW * 0.62), dateLineY, x(RIGHT));
|
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 ──────────────────────────────────────────────────
|
// ── Pay to the order of ──
|
||||||
const payY = y(1.10);
|
|
||||||
const payeeX = ML + 0.78;
|
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");
|
// ── Numeric amount box ──
|
||||||
doc.setFontSize(7);
|
if (!hid("amount_box")) {
|
||||||
doc.text("PAY TO THE", x(ML), payY - 0.10);
|
const dx = ax("amount_box");
|
||||||
doc.text("ORDER OF", x(ML), payY + 0.03);
|
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");
|
// ── Written amount ──
|
||||||
doc.setFontSize(10);
|
if (!hid("amount_words")) {
|
||||||
doc.text(c.payee, x(payeeX), payY);
|
const dx = ax("amount_words");
|
||||||
hline(doc, x(payeeX - 0.02), payY + 0.06, x(RIGHT - 1.55));
|
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
|
// ── Envelope address block ──
|
||||||
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)
|
|
||||||
const addrLines = c.payeeAddress
|
const addrLines = c.payeeAddress
|
||||||
? c.payeeAddress.split(/\n/).map(s => s.trim()).filter(Boolean)
|
? 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;
|
const addrIndentX = ML + 0.35;
|
||||||
|
if (!hid("address_block")) {
|
||||||
// Payee name bold (envelope window — first line)
|
const dx = ax("address_block");
|
||||||
doc.setFont("helvetica", "bold");
|
const addrBlockY = y(1.68 + ay("address_block"));
|
||||||
doc.setFontSize(9);
|
doc.setFont("helvetica", "bold");
|
||||||
doc.text(c.payee, x(addrIndentX), addrBlockY);
|
doc.setFontSize(9);
|
||||||
|
|
||||||
// "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);
|
|
||||||
});
|
|
||||||
doc.setTextColor(0);
|
doc.setTextColor(0);
|
||||||
}
|
doc.text(c.payee, x(addrIndentX + dx), addrBlockY);
|
||||||
|
doc.setFont("helvetica", "normal");
|
||||||
// ── 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.setFontSize(7.5);
|
doc.setFontSize(7.5);
|
||||||
doc.text(c.memo, x(ML + 0.55), bottomLineY - 0.04);
|
doc.setTextColor(80);
|
||||||
}
|
doc.text("Authorized Signer", x(RIGHT + dx), addrBlockY, { align: "right" });
|
||||||
|
|
||||||
// "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(0);
|
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");
|
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.setFont("helvetica", "normal");
|
||||||
doc.setFontSize(7);
|
doc.setFontSize(7);
|
||||||
doc.setTextColor(160);
|
doc.setTextColor(80);
|
||||||
doc.text("[ Configure routing & account numbers in Settings → Check Setup ]", x(PW / 2), micrY, { align: "center" });
|
doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY - 0.36, { align: "center" });
|
||||||
doc.setTextColor(0);
|
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 micrOy = opts.micrOffsetY ?? 0;
|
||||||
const g1 = opts.micrGap1 ?? 1;
|
const g1 = opts.micrGap1 ?? 1;
|
||||||
const g2 = opts.micrGap2 ?? 1;
|
const g2 = opts.micrGap2 ?? 1;
|
||||||
|
const fp = opts.fieldPositions || {};
|
||||||
|
|
||||||
checks.forEach((c, idx) => {
|
checks.forEach((c, idx) => {
|
||||||
if (idx > 0) doc.addPage();
|
if (idx > 0) doc.addPage();
|
||||||
|
|
||||||
if (opts.style === "voucher") {
|
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 + oy, c, ox);
|
||||||
drawStub(doc, SECTION_H * 2 + oy, c, ox);
|
drawStub(doc, SECTION_H * 2 + oy, c, ox);
|
||||||
} else {
|
} else {
|
||||||
let posY = 0;
|
let posY = 0;
|
||||||
if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2;
|
if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2;
|
||||||
if (opts.position === "bottom") posY = PH - CHECK_H;
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user