From c3a0682e576ce26f0c41e88ae9cac0379783151f Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 3 Jun 2026 00:35:30 -0400 Subject: [PATCH 1/4] Accounting: A/P-clearing payments, check return address + MICR gaps, dashboard fixes Bill/vendor payments: - Bill "Pay" and bank-register vendor debits now clear Accounts Payable (Dr A/P / Cr Bank) instead of re-debiting the expense, which had double-counted expenses in the P&L and never cleared A/P. The expense account is kept as a display-only category on the payment. Checks: - Bill-payment checks now print the return address (payer name/address) from the company check layout (was hardcoded blank). - Per-segment MICR gap control (check# / routing / account) in both check generators, wired to Check Setup (bill payments) and the Check Layout editor (Print Checks). New columns: check_settings.micr_gap_1/2 and check_layouts/company_check_layouts.micr_gap_1/2. Accounting Dashboard / Financial Overview: - Date-range selector (presets + custom) drives the charts, top expenses, and recent transactions. - PDF title renamed to "Financial Overview" and shows the period. - Fixed amounts rendering as "$ 5 0 0. 0 0": the U+2212 minus sign forced jsPDF into a UTF-16 fallback; replaced with an ASCII hyphen (also in the report PDF out-of-balance line). DB migrations applied to the project: repost_gl_on_line_item_change, budget_actuals_accrual_owner_income, add_micr_gap_controls. Co-Authored-By: Claude Opus 4.8 --- src/components/CheckLayoutEditor.tsx | 19 +++ .../accounting/AccountingBankingPage.tsx | 7 +- src/pages/accounting/AccountingBillsPage.tsx | 49 +++++-- .../accounting/AccountingCheckSetupPage.tsx | 46 ++++++- .../accounting/AccountingDashboardPage.tsx | 125 +++++++++++++----- src/pages/accounting/lib/checkPdf.ts | 23 +++- src/pages/accounting/lib/dashboardPdf.ts | 10 +- src/pages/accounting/lib/reportPdf.ts | 2 +- src/utils/checkPdfGenerator.ts | 14 +- 9 files changed, 228 insertions(+), 67 deletions(-) diff --git a/src/components/CheckLayoutEditor.tsx b/src/components/CheckLayoutEditor.tsx index 54ad83b..88c05b3 100644 --- a/src/components/CheckLayoutEditor.tsx +++ b/src/components/CheckLayoutEditor.tsx @@ -53,6 +53,8 @@ const DEFAULTS: CheckLayout = { font_family: "helvetica", field_positions: {}, logo_url: "", + micr_gap_1: 3, + micr_gap_2: 3, }; const FIELD_ORDER: CheckFieldKey[] = [ @@ -107,6 +109,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode font_family: (data as any).font_family || "helvetica", field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {}, logo_url: (data as any).logo_url || "", + micr_gap_1: Number((data as any).micr_gap_1 ?? 3), + micr_gap_2: Number((data as any).micr_gap_2 ?? 3), }); } else { setLayoutId(null); @@ -178,6 +182,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode font_family: (data as any).font_family || "helvetica", field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {}, logo_url: (data as any).logo_url || "", + micr_gap_1: Number((data as any).micr_gap_1 ?? 3), + micr_gap_2: Number((data as any).micr_gap_2 ?? 3), }); toast({ title: "Settings copied", @@ -255,6 +261,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode font_family: layout.font_family || "helvetica", field_positions: (layout.field_positions || {}) as any, logo_url: layout.logo_url || null, + micr_gap_1: Number(layout.micr_gap_1 ?? 3), + micr_gap_2: Number(layout.micr_gap_2 ?? 3), }; const payload: any = isCompany ? basePayload @@ -412,6 +420,17 @@ export default function CheckLayoutEditor({ associationId, associationName, mode update("offset_y", Number(e.target.value))} /> +
+ + update("micr_gap_1", Math.max(0, parseInt(e.target.value) || 0))} /> +

Spaces between segments in the MICR line.

+
+
+ + update("micr_gap_2", Math.max(0, parseInt(e.target.value) || 0))} /> +
diff --git a/src/pages/accounting/AccountingBankingPage.tsx b/src/pages/accounting/AccountingBankingPage.tsx index 074d5a1..7233530 100644 --- a/src/pages/accounting/AccountingBankingPage.tsx +++ b/src/pages/accounting/AccountingBankingPage.tsx @@ -256,9 +256,14 @@ export default function AccountingBankingPage() { const description = [partyName, coaName, memo].filter(Boolean).join(" · "); const category = coaName; + // A vendor payment (debit) clears Accounts Payable — the expense was already + // recognized when the bill was entered (accrual). Leaving coa_account_id null + // with the vendor set makes post_transaction_gl post Dr A/P / Cr Bank; the + // chosen expense account is retained as the display `category` only. Customer + // deposits (credits) clear A/R via customer_id, so they need no change here. const payload: any = { account_id, date, description, amount, type, category, reference: reference || null, - coa_account_id: coa_account_id || null, + coa_account_id: type === "debit" ? null : (coa_account_id || null), vendor_id: vendor_id || null, customer_id: customer_id || null, }; diff --git a/src/pages/accounting/AccountingBillsPage.tsx b/src/pages/accounting/AccountingBillsPage.tsx index 36b9f95..291c1a9 100644 --- a/src/pages/accounting/AccountingBillsPage.tsx +++ b/src/pages/accounting/AccountingBillsPage.tsx @@ -329,10 +329,21 @@ export default function AccountingBillsPage() { const { data: billItems } = await accounting.from("bill_items").select("description,amount").eq("bill_id", payBill?.id ?? ""); const vendorAddress = payBill?.vendors?.address ?? undefined; + // Return-address (payer) block comes from the company check layout the user + // configured in Settings → Check Layout. Without this, checks print no + // return address. + const { data: layout } = await supabase + .from("company_check_layouts") + .select("payer_name, payer_address, show_payer_block") + .maybeSingle(); + const payerName = (layout?.payer_name || "").trim(); + const showPayer = layout?.show_payer_block !== false; + const returnAddress = showPayer ? (layout?.payer_address || undefined) : undefined; + const dataUrl = generateCheckPDF( [{ - companyName: associationName ?? "Company", - companyAddress: undefined, + companyName: (payerName || associationName) ?? "Company", + companyAddress: returnAddress, bankName: cs?.bank_name ?? bankAccount?.name ?? undefined, bankAddress: cs?.bank_address ?? undefined, routingNumber: cs?.routing_number ?? undefined, @@ -355,6 +366,8 @@ export default function AccountingBillsPage() { offsetX: (cs as any)?.offset_x ?? 0, offsetY: (cs as any)?.offset_y ?? 0, micrOffsetY: (cs as any)?.micr_offset_y ?? 0, + micrGap1: (cs as any)?.micr_gap_1 ?? 1, + micrGap2: (cs as any)?.micr_gap_2 ?? 1, } ); const w = window.open(""); @@ -372,16 +385,24 @@ export default function AccountingBillsPage() { const vendorName = payBill.vendors?.name ?? "Vendor"; const refLabel = payReference || payMethod.toUpperCase(); - // 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor) - // Pull the primary expense account from the first bill item so the COA balance updates - const { data: billItems } = await accounting - .from("bill_items") - .select("account_id") - .eq("bill_id", payBill.id) - .not("account_id", "is", null) - .limit(1); - const primaryCoa = billItems?.[0]?.account_id ?? null; + // Expense category name(s) from the bill — shown on the payment line in the + // transaction journal for visibility (display only; does not affect the GL). + const { data: payItems } = await accounting + .from("bill_items").select("account_id").eq("bill_id", payBill.id).not("account_id", "is", null); + const payAcctIds = Array.from(new Set((payItems ?? []).map((i: any) => i.account_id))); + let categoryLabel = "Bill Payment"; + if (payAcctIds.length) { + const { data: payAccs } = await accounting.from("accounts").select("name").in("id", payAcctIds); + const names = (payAccs ?? []).map((a: any) => a.name).filter(Boolean); + if (names.length) categoryLabel = names.join(", "); + } + // 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor). + // A bill payment must clear Accounts Payable, NOT re-hit the expense account: + // the expense was already recognized when the bill was entered (accrual). + // coa_account_id stays null with vendor_id set, so post_transaction_gl posts + // Dr Accounts Payable / Cr Bank (clears the payable, no second P&L hit). The + // expense category is stored in `category` for the journal view only. await accounting.from("transactions").insert({ company_id: cid, account_id: payAccountId, @@ -389,10 +410,10 @@ export default function AccountingBillsPage() { type: "debit", amount: payAmount, description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`, - category: "Bill Payment", + category: categoryLabel, reference: refLabel, - coa_account_id: primaryCoa, // links to expense account → updates COA balance - vendor_id: payBill.vendor_id ?? null, // link to vendor for reporting + coa_account_id: null, // → posts against Accounts Payable (via vendor) + vendor_id: payBill.vendor_id ?? null, // required so the GL clears A/P }); // 2) Bank balance auto-updated by DB trigger trg_sync_account_balance diff --git a/src/pages/accounting/AccountingCheckSetupPage.tsx b/src/pages/accounting/AccountingCheckSetupPage.tsx index a5eaa8c..0d7529a 100644 --- a/src/pages/accounting/AccountingCheckSetupPage.tsx +++ b/src/pages/accounting/AccountingCheckSetupPage.tsx @@ -35,6 +35,8 @@ export default function AccountingCheckSetupPage() { const [offsetX, setOffsetX] = useState(0); const [offsetY, setOffsetY] = useState(0); const [micrOffsetY, setMicrOffsetY] = useState(0); + const [micrGap1, setMicrGap1] = useState(1); + const [micrGap2, setMicrGap2] = useState(1); const { data: settings } = useQuery({ queryKey: ["check-settings", cid], @@ -67,6 +69,8 @@ export default function AccountingCheckSetupPage() { setOffsetX(Number(s.offset_x ?? 0)); setOffsetY(Number(s.offset_y ?? 0)); setMicrOffsetY(Number(s.micr_offset_y ?? 0)); + setMicrGap1(Number(s.micr_gap_1 ?? 1)); + setMicrGap2(Number(s.micr_gap_2 ?? 1)); }, [settings]); const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, "")); @@ -90,6 +94,8 @@ export default function AccountingCheckSetupPage() { offset_x: offsetX, offset_y: offsetY, micr_offset_y: micrOffsetY, + micr_gap_1: micrGap1, + micr_gap_2: micrGap2, }, { onConflict: "company_id" }); setSaving(false); if (error) return toast.error(error.message); @@ -130,7 +136,7 @@ export default function AccountingCheckSetupPage() { style: defaultStyle as any, position: defaultPosition as any, fontSize: fontSize as any, - offsetX, offsetY, micrOffsetY, + offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, }); const w = window.open(""); if (w) w.document.write(``); @@ -141,7 +147,7 @@ export default function AccountingCheckSetupPage() { style: defaultStyle as any, position: defaultPosition as any, fontSize: fontSize as any, - offsetX, offsetY, micrOffsetY, + offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, }); const w = window.open(""); if (w) w.document.write(``); @@ -345,6 +351,13 @@ export default function AccountingCheckSetupPage() {

Run the alignment test first, measure the offset on blank paper, then adjust here and preview again.

+
+

MICR gaps (spaces between segments)

+
+ + +
+
@@ -368,6 +381,35 @@ export default function AccountingCheckSetupPage() { ); } +function GapControl({ label, value, onChange }: { + label: string; value: number; onChange: (v: number) => void; +}) { + const adj = (d: number) => onChange(Math.max(0, Math.round(value + d))); + return ( +
+

{label}

+
+ + onChange(Math.max(0, parseInt(e.target.value) || 0))} + className="h-7 text-center font-mono text-xs" + /> + +
+
+ ); +} + function OffsetControl({ label, hint, value, onChange }: { label: string; hint: string; value: number; onChange: (v: number) => void; }) { diff --git a/src/pages/accounting/AccountingDashboardPage.tsx b/src/pages/accounting/AccountingDashboardPage.tsx index 093573d..80b92cc 100644 --- a/src/pages/accounting/AccountingDashboardPage.tsx +++ b/src/pages/accounting/AccountingDashboardPage.tsx @@ -6,8 +6,30 @@ import { useCompanyId } from "./lib/useCompanyId"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { money, fmtDate } from "./lib/format"; import { generateDashboardPdf } from "./lib/dashboardPdf"; + +type RangePreset = "this-month" | "this-quarter" | "ytd" | "last-6-months" | "custom"; + +function presetRange(p: RangePreset): { from: string; to: string } { + const now = new Date(); + const y = now.getFullYear(); + const iso = (d: Date) => d.toISOString().slice(0, 10); + const today = iso(now); + switch (p) { + case "this-month": return { from: iso(new Date(y, now.getMonth(), 1)), to: today }; + case "this-quarter": return { from: iso(new Date(y, Math.floor(now.getMonth() / 3) * 3, 1)), to: today }; + case "ytd": return { from: `${y}-01-01`, to: today }; + case "custom": return { from: `${y}-01-01`, to: today }; + case "last-6-months": + default: { + const s = new Date(y, now.getMonth() - 5, 1); + return { from: iso(s), to: today }; + } + } +} import { Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown, } from "lucide-react"; @@ -31,15 +53,19 @@ export default function AccountingDashboardPage({ association }: { association?: const logoUrl = (assocMeta as any)?.logo_url || null; const [exporting, setExporting] = useState(false); + // Date range for the charts + recent transactions (receivables/payables/cash + // remain current snapshots — they are point-in-time by nature). + const [preset, setPreset] = useState("last-6-months"); + const [range, setRange] = useState(() => presetRange("last-6-months")); + const from = range.from; + const to = range.to; + const applyPreset = (p: RangePreset) => { setPreset(p); if (p !== "custom") setRange(presetRange(p)); }; + const rangeLabel = `${fmtDate(from)} – ${fmtDate(to)}`; + const { data } = useQuery({ - queryKey: ["dashboard", cid], + queryKey: ["dashboard", cid, from, to], enabled: !!cid, queryFn: async () => { - const sixMonthsAgo = new Date(); - sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5); - sixMonthsAgo.setDate(1); - const sinceIso = sixMonthsAgo.toISOString().slice(0, 10); - const [inv, bills, tx, acc] = await Promise.all([ accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid), accounting.from("bills").select("total,status,issue_date").eq("company_id", cid), @@ -47,8 +73,10 @@ export default function AccountingDashboardPage({ association }: { association?: .from("transactions") .select("amount,type,date,description,category,reference") .eq("company_id", cid) + .gte("date", from) + .lte("date", to) .order("date", { ascending: false }) - .limit(10), + .limit(50), accounting.from("accounts").select("balance,is_bank").eq("company_id", cid), ]); @@ -78,28 +106,32 @@ export default function AccountingDashboardPage({ association }: { association?: } } - // 6 month income/expense + // Income/expense by month across the selected range const months: { key: string; label: string; income: number; expense: number }[] = []; - const now = new Date(); - for (let i = 5; i >= 0; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const cursor = new Date(`${from}T00:00:00`); + cursor.setDate(1); + const lastMonth = new Date(`${to}T00:00:00`); + lastMonth.setDate(1); + while (cursor <= lastMonth && months.length < 36) { months.push({ - key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`, - label: d.toLocaleString("en-US", { month: "short" }), + key: `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`, + label: cursor.toLocaleString("en-US", { month: "short", year: "2-digit" }), income: 0, expense: 0, }); + cursor.setMonth(cursor.getMonth() + 1); } const bucket = (date: string) => date.slice(0, 7); + const inWin = (d?: string | null) => !!d && d >= from && d <= to; for (const i of invoices) { - if (i.issue_date && i.issue_date >= sinceIso) { - const m = months.find((x) => x.key === bucket(i.issue_date)); + if (inWin(i.issue_date)) { + const m = months.find((x) => x.key === bucket(i.issue_date!)); if (m) m.income += Number(i.total); } } for (const b of billsArr) { - if (b.issue_date && b.issue_date >= sinceIso) { - const m = months.find((x) => x.key === bucket(b.issue_date)); + if (inWin(b.issue_date)) { + const m = months.find((x) => x.key === bucket(b.issue_date!)); if (m) m.expense += Number(b.total); } } @@ -124,7 +156,7 @@ export default function AccountingDashboardPage({ association }: { association?: counts, months, topExpenses, - recent: txAll, + recent: txAll.slice(0, 15), }; }, }); @@ -175,25 +207,46 @@ export default function AccountingDashboardPage({ association }: { association?:

Dashboard

-

{associationName}

+

{associationName} · {rangeLabel}

+
+
+ + {preset === "custom" && ( +
+ setRange((r) => ({ ...r, from: e.target.value }))} /> + to + setRange((r) => ({ ...r, to: e.target.value }))} /> +
+ )} +
-
{/* Cash flow summary */} diff --git a/src/pages/accounting/lib/checkPdf.ts b/src/pages/accounting/lib/checkPdf.ts index 1773e20..23299c1 100644 --- a/src/pages/accounting/lib/checkPdf.ts +++ b/src/pages/accounting/lib/checkPdf.ts @@ -51,6 +51,10 @@ export type CheckPrintOptions = { offsetY?: number; /** Extra vertical shift for the MICR line only (fine-tune for check stock) */ micrOffsetY?: number; + /** Spaces between MICR check# and routing segments (default 1) */ + micrGap1?: number; + /** Spaces between MICR routing and account segments (default 1) */ + micrGap2?: number; }; // ── Constants ──────────────────────────────────────────────────────────────── @@ -71,12 +75,15 @@ const CHECK_H = SECTION_H; // check occupies the full top section // Format matches reference check: C000000305C A263191387A 1100034740184C // C{check#}C A{routing}A {account}C -function buildMicr(routing: string, account: string, checkNum: number): string { +function buildMicr(routing: string, account: string, checkNum: number, gap1 = 1, gap2 = 1): string { const r = (routing ?? "").replace(/\D/g, "").slice(0, 9); const a = (account ?? "").replace(/\D/g, ""); const c = String(checkNum).padStart(9, "0"); if (!r && !a) return ""; - return `C${c}C A${r}A ${a}C`; + const g1 = " ".repeat(Math.max(0, Math.round(gap1))); + const g2 = " ".repeat(Math.max(0, Math.round(gap2))); + // C{check#}C {gap1} A{routing}A {gap2} {account}C + return `C${c}C${g1}A${r}A${g2}${a}C`; } // ── Fill a line to width with a pad character (for written-amount security) ─ @@ -103,7 +110,7 @@ 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) { +function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0, micrGap1 = 1, micrGap2 = 1) { const y = (dy: number) => originY + dy; const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate @@ -270,7 +277,9 @@ function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0 const micr = buildMicr( (c.routingNumber ?? "").replace(/\D/g, ""), (c.accountNumber ?? "").replace(/\D/g, ""), - c.checkNumber + c.checkNumber, + micrGap1, + micrGap2 ); if (micr) { @@ -351,19 +360,21 @@ export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions): const ox = opts.offsetX ?? 0; const oy = opts.offsetY ?? 0; const micrOy = opts.micrOffsetY ?? 0; + const g1 = opts.micrGap1 ?? 1; + const g2 = opts.micrGap2 ?? 1; checks.forEach((c, idx) => { if (idx > 0) doc.addPage(); if (opts.style === "voucher") { - drawCheck(doc, 0 + oy, c, ox, micrOy); + drawCheck(doc, 0 + oy, c, ox, micrOy, g1, g2); 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); + drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2); } }); diff --git a/src/pages/accounting/lib/dashboardPdf.ts b/src/pages/accounting/lib/dashboardPdf.ts index 4207c67..1fa01d6 100644 --- a/src/pages/accounting/lib/dashboardPdf.ts +++ b/src/pages/accounting/lib/dashboardPdf.ts @@ -109,6 +109,7 @@ export async function generateDashboardPdf(opts: { logoUrl?: string | null; currency?: string; data: DashboardPdfData; + rangeLabel?: string; }) { const { companyName, data } = opts; const currency = opts.currency || "USD"; @@ -120,8 +121,11 @@ export async function generateDashboardPdf(opts: { const logo = await loadBrandedLogo(opts.logoUrl); let y = drawBrandedHeader(doc, { logo, - title: "Accounting Dashboard", - metaLines: [{ label: "Properties:", value: companyName }], + title: "Financial Overview", + metaLines: [ + { label: "Properties:", value: companyName }, + ...(opts.rangeLabel ? [{ label: "Period:", value: opts.rangeLabel }] : []), + ], }); // ── Summary cards ── @@ -194,7 +198,7 @@ export async function generateDashboardPdf(opts: { t.description ?? "", t.category ?? "—", t.reference ?? "—", - `${t.type === "credit" ? "+" : "−"}${money(Math.abs(Number(t.amount || 0)), currency)}`, + `${t.type === "credit" ? "+" : "-"}${money(Math.abs(Number(t.amount || 0)), currency)}`, ]), styles: { font: "helvetica", fontSize: 8, textColor: TEXT, lineColor: [225, 228, 232], lineWidth: 0.1 }, headStyles: { fillColor: [237, 239, 242], textColor: TEXT, fontStyle: "bold" }, diff --git a/src/pages/accounting/lib/reportPdf.ts b/src/pages/accounting/lib/reportPdf.ts index 7a4e60b..a93dd72 100644 --- a/src/pages/accounting/lib/reportPdf.ts +++ b/src/pages/accounting/lib/reportPdf.ts @@ -236,7 +236,7 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...txt); const msg = ok ? "Balance Sheet is balanced" - : `Balance Sheet is OUT OF BALANCE by ${fmtAmount(report.outOfBalanceAmount ?? 0)} (Assets − Liabilities − Equity)`; + : `Balance Sheet is OUT OF BALANCE by ${fmtAmount(report.outOfBalanceAmount ?? 0)} (Assets - Liabilities - Equity)`; doc.text(msg, ML + 8, y + 14); y += 28; } diff --git a/src/utils/checkPdfGenerator.ts b/src/utils/checkPdfGenerator.ts index f9ced13..841ea02 100644 --- a/src/utils/checkPdfGenerator.ts +++ b/src/utils/checkPdfGenerator.ts @@ -93,6 +93,10 @@ export interface CheckLayout { field_positions?: Partial> | null; /** New: optional logo image URL printed on the check. */ logo_url?: string | null; + /** Spaces between MICR check# and routing segments (default 3). */ + micr_gap_1?: number | null; + /** Spaces between MICR routing and account segments (default 3). */ + micr_gap_2?: number | null; } /* ------------------------------------------------------------------ */ @@ -200,14 +204,16 @@ function registerMicrFont(doc: jsPDF) { doc.addFont("MICRCHECK.ttf", "MICRCHECK", "normal"); } -export function formatMicrLine(routing: string, account: string, checkNumber: string): string { +export function formatMicrLine(routing: string, account: string, checkNumber: string, gap1 = 3, gap2 = 3): string { const r = (routing || "").replace(/\D/g, ""); const a = (account || "").replace(/\D/g, ""); const c = (checkNumber || "").replace(/\D/g, ""); // Pad check number with leading zeros to 9 digits const paddedCheck = c.padStart(9, "0"); - // Order: check number (on-us), space, transit/routing, space, account - return `${MICR_ON_US}${paddedCheck}${MICR_ON_US} ${MICR_TRANSIT}${r}${MICR_TRANSIT} ${a}${MICR_ON_US}`; + const g1 = " ".repeat(Math.max(0, Math.round(gap1))); + const g2 = " ".repeat(Math.max(0, Math.round(gap2))); + // Order: check number (on-us), gap1, transit/routing, gap2, account + return `${MICR_ON_US}${paddedCheck}${MICR_ON_US}${g1}${MICR_TRANSIT}${r}${MICR_TRANSIT}${g2}${a}${MICR_ON_US}`; } export function amountToWords(amount: number): string { @@ -462,7 +468,7 @@ function drawCheckSection( if (includeMicr && c.bank_routing_number && c.bank_account_number) { const p = px("micr"); if (p.visible !== false) { - const micr = formatMicrLine(c.bank_routing_number, c.bank_account_number, c.check_number || ""); + const micr = formatMicrLine(c.bank_routing_number, c.bank_account_number, c.check_number || "", layout.micr_gap_1 ?? 3, layout.micr_gap_2 ?? 3); doc.setFont("MICRCHECK", "normal").setFontSize(p.font_size || 12); doc.text(micr, p.X, p.Y); } From c3d1d86b07bcc4b741beacf6b33116245732f605 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 3 Jun 2026 00:47:11 -0400 Subject: [PATCH 2/4] Reports: account drill-down to GL; bids/quotes PDF attachments - P&L and Balance Sheet account rows are now clickable and open the General Ledger filtered to that account (its transaction list for the COA). Adds StructuredRow.accountId, threaded from the report builders, with a clickable row in StructuredTable and an initialAccountId prop on GeneralLedgerReport. - Bids/Quotes: PDF upload on the New Bid/Quote dialog (bid-attachments bucket + document_url/document_name columns), shown as a link on the bid Details dialog. Migration applied: bids_quotes_pdf_attachments. Co-Authored-By: Claude Opus 4.8 --- src/components/BidQuoteDetailsDialog.jsx | 8 +++ src/components/BidQuoteDialog.jsx | 55 ++++++++++++++++++- .../accounting/AccountingReportsPage.tsx | 27 ++++++--- .../components/GeneralLedgerReport.tsx | 11 +++- src/pages/accounting/lib/reportPdf.ts | 2 + 5 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/components/BidQuoteDetailsDialog.jsx b/src/components/BidQuoteDetailsDialog.jsx index 6fb9e66..9a0121c 100644 --- a/src/components/BidQuoteDetailsDialog.jsx +++ b/src/components/BidQuoteDetailsDialog.jsx @@ -94,6 +94,14 @@ export function BidQuoteDetailsDialog({ open, onOpenChange, bid, onRefresh }) {
Status: {bid.status}
{bid.received_date &&
Received: {format(new Date(bid.received_date), 'MMM d, yyyy')}
}
+ {bid.document_url && ( +
+ + 📄 {bid.document_name || 'View attached PDF'} + +
+ )} diff --git a/src/components/BidQuoteDialog.jsx b/src/components/BidQuoteDialog.jsx index c9e10ce..1069988 100644 --- a/src/components/BidQuoteDialog.jsx +++ b/src/components/BidQuoteDialog.jsx @@ -38,6 +38,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) { const [associations, setAssociations] = useState([]); const [selectedAssociations, setSelectedAssociations] = useState([]); const [uploading, setUploading] = useState(false); + const [fileUploading, setFileUploading] = useState(false); + const [documentUrl, setDocumentUrl] = useState(''); + const [documentName, setDocumentName] = useState(''); const { user } = useAuth(); const { toast } = useToast(); @@ -55,9 +58,34 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) { fetchAssociations(); form.reset(); setSelectedAssociations([]); + setDocumentUrl(''); + setDocumentName(''); } }, [open, form]); + const handleFileUpload = async (e) => { + const f = e.target.files?.[0]; + if (!f) return; + if (f.type !== 'application/pdf') { + toast({ variant: 'destructive', title: 'PDF only', description: 'Please upload a PDF file.' }); + return; + } + setFileUploading(true); + try { + const path = `${user?.id || 'anon'}/${Date.now()}-${f.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`; + const { error } = await supabase.storage.from('bid-attachments').upload(path, f, { upsert: true }); + if (error) throw error; + const { data } = supabase.storage.from('bid-attachments').getPublicUrl(path); + setDocumentUrl(data.publicUrl); + setDocumentName(f.name); + toast({ title: 'PDF attached', description: f.name }); + } catch (err) { + toast({ variant: 'destructive', title: 'Upload failed', description: err.message }); + } finally { + setFileUploading(false); + } + }; + const fetchAssociations = async () => { try { const { data, error } = await supabase @@ -91,7 +119,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) { amount: values.amount || 0, association_id: assocId, created_by: user?.id, - status: 'pending' + status: 'pending', + document_url: documentUrl || null, + document_name: documentName || null, })); const { error } = await supabase.from('bids_quotes').insert(inserts); @@ -191,6 +221,27 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) { )} /> +
+ Attachment (PDF) + {documentName ? ( +
+ + {documentName} + + +
+ ) : ( + + )} +
+
Assign to Associations @@ -232,7 +283,7 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) { -
+ +
+

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); } }); From 3b220a3f269fbcac694026c6beaab34482805431 Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 3 Jun 2026 01:12:52 -0400 Subject: [PATCH 4/4] Checks: drop "Authorized Signer", move signature label below the line Removes the "Authorized Signer" text from the payee/address block, moves the "AUTHORIZED SIGNATURE" label to just below the signature line, and raises the signature image cap (0.42 -> 0.65 in) so the signature renders full size in the now-clear space above the line. Co-Authored-By: Claude Opus 4.8 --- src/pages/accounting/lib/checkPdf.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/pages/accounting/lib/checkPdf.ts b/src/pages/accounting/lib/checkPdf.ts index 727dfab..cb4d673 100644 --- a/src/pages/accounting/lib/checkPdf.ts +++ b/src/pages/accounting/lib/checkPdf.ts @@ -271,11 +271,6 @@ function drawCheck( doc.setFontSize(9); doc.setTextColor(0); doc.text(c.payee, x(addrIndentX + dx), addrBlockY); - doc.setFont("helvetica", "normal"); - doc.setFontSize(7.5); - doc.setTextColor(80); - doc.text("Authorized Signer", x(RIGHT + dx), addrBlockY, { align: "right" }); - doc.setTextColor(0); if (addrLines.length > 0) { doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); @@ -310,16 +305,13 @@ function drawCheck( if (!hid("signature")) { const dx = ax("signature"); const bottomLineY = y(bottomDy + ay("signature")); - doc.setFont("helvetica", "normal"); - doc.setFontSize(7); - doc.setTextColor(80); - doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY - 0.36, { align: "center" }); - doc.setTextColor(0); + // Signature image — rendered full size above the line (bottom edge sits ON + // the line). With the label moved below the line, it can use the full height. if (c.printSignature && c.signatureDataUrl) { try { const props = doc.getImageProperties(c.signatureDataUrl); const maxW = RIGHT - sigLabelX - 0.1; - const maxH = 0.42; + const maxH = 0.65; const ratio = props.width / props.height; let sigW = maxW; let sigH = sigW / ratio; @@ -329,7 +321,14 @@ function drawCheck( doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH); } catch { /* silent */ } } + // Signature line hline(doc, x(sigLabelX + dx), bottomLineY, x(RIGHT + dx)); + // "AUTHORIZED SIGNATURE" label below the line + doc.setFont("helvetica", "normal"); + doc.setFontSize(7); + doc.setTextColor(80); + doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY + 0.13, { align: "center" }); + doc.setTextColor(0); } // ── MICR line (X offset + separate MICR Y fine-tune) ──