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 }) { -
+
+ + 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..69d9f71 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,9 @@ 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, + fieldPositions: (cs as any)?.field_positions ?? {}, } ); const w = window.open(""); @@ -372,16 +386,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 +411,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..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(); @@ -35,6 +35,9 @@ 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 [fieldPositions, setFieldPositions] = useState({}); const { data: settings } = useQuery({ queryKey: ["check-settings", cid], @@ -67,6 +70,9 @@ 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)); + setFieldPositions(s.field_positions ?? {}); }, [settings]); const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, "")); @@ -90,6 +96,9 @@ export default function AccountingCheckSetupPage() { offset_x: offsetX, offset_y: offsetY, 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); @@ -130,7 +139,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, fieldPositions, }); const w = window.open(""); if (w) w.document.write(``); @@ -141,12 +150,17 @@ export default function AccountingCheckSetupPage() { style: defaultStyle as any, position: defaultPosition as any, fontSize: fontSize as any, - offsetX, offsetY, micrOffsetY, + 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."}

; @@ -345,6 +359,47 @@ 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)

+
+ + +
+
+ +
+

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 })} /> +
+
+ +
+
+ ); + })} +
@@ -368,6 +423,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/AccountingReportsPage.tsx b/src/pages/accounting/AccountingReportsPage.tsx index 665f180..4ed9eb5 100644 --- a/src/pages/accounting/AccountingReportsPage.tsx +++ b/src/pages/accounting/AccountingReportsPage.tsx @@ -202,6 +202,10 @@ export default function AccountingReportsPage({ association }: { association?: { const cid = companyId ?? ""; const cur = "USD"; const [active, setActive] = useState("pnl"); + // Drill-down: clicking an account amount in a financial report opens the + // General Ledger focused on that account (its transaction list for the COA). + const [drillAccountId, setDrillAccountId] = useState(null); + const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); }; // Period const [preset, setPreset] = useState("ytd"); @@ -548,7 +552,7 @@ export default function AccountingReportsPage({ association }: { association?: { )} {active === "general-ledger" && ( - + )} {active === "reserve-fund" && ( @@ -563,7 +567,7 @@ export default function AccountingReportsPage({ association }: { association?: {
Loading…
) : structured ? ( - + ) : (
No data for this report in the selected range.
@@ -622,8 +626,9 @@ function Toggle({ id, checked, onChange, label, disabled }: { id: string; checke ); } -function StructuredTable({ report, showCodes, showCompare, showZero, currency }: { +function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: { report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string; + onDrill?: (accountId: string, label: string) => void; }) { let alt = false; const span = showCompare ? 5 : 2; @@ -666,14 +671,19 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }: const shaded = r.kind === "sub" && alt; if (r.kind === "sub") alt = !alt; const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined; + const drillable = r.kind === "sub" && !!r.accountId && !!onDrill; return ( - onDrill!(r.accountId!, r.label) : undefined} + title={drillable ? "View transactions for this account" : undefined} + className={[ shaded ? "bg-muted/40" : "", bold ? "border-t font-semibold" : "", + drillable ? "cursor-pointer hover:bg-primary/5" : "", ].join(" ")}> {showCodes && r.code && {r.code}} - {r.label} + {r.label} {showCompare && } @@ -1036,6 +1046,7 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe rows.push({ kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign), compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined, + accountId: l.accountId, }); } }; @@ -1144,7 +1155,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep // Assets rows.push({ kind: "section", label: "Assets" }); const assets = byType("asset"); - for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) }); + for (const a of assets) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id }); const totalA = sumBal(assets); rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) }); rows.push({ kind: "spacer", label: "" }); @@ -1152,7 +1163,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep // Liabilities rows.push({ kind: "section", label: "Liabilities" }); const liabs = byType("liability"); - for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) }); + for (const a of liabs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id }); const totalL = sumBal(liabs); rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) }); rows.push({ kind: "spacer", label: "" }); @@ -1160,7 +1171,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep // Equity β€” equity accounts + calculated RE / current-year earnings rows.push({ kind: "section", label: "Equity" }); const equityAccs = byType("equity"); - for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)) }); + for (const a of equityAccs) rows.push({ kind: "sub", label: a.name, code: a.code ?? undefined, amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id }); rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) }); rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) }); const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye; diff --git a/src/pages/accounting/components/GeneralLedgerReport.tsx b/src/pages/accounting/components/GeneralLedgerReport.tsx index d81b73c..9a10a09 100644 --- a/src/pages/accounting/components/GeneralLedgerReport.tsx +++ b/src/pages/accounting/components/GeneralLedgerReport.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { accounting } from "@/lib/accountingClient"; import { Card, CardContent } from "@/components/ui/card"; @@ -37,10 +37,15 @@ type Txn = { debit: number; credit: number; balance: number; abnormal?: boolean; }; -export function GeneralLedgerReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) { +export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAccountId }: { companyId: string; companyName: string; logoUrl?: string | null; initialAccountId?: string | null }) { const [from, setFrom] = useState(startOfYear()); const [to, setTo] = useState(today()); - const [selectedAccounts, setSelectedAccounts] = useState([]); + const [selectedAccounts, setSelectedAccounts] = useState(initialAccountId ? [initialAccountId] : []); + + // When opened via a report drill-down, focus the chosen account. + useEffect(() => { + if (initialAccountId) setSelectedAccounts([initialAccountId]); + }, [initialAccountId]); const [basis, setBasis] = useState<"accrual" | "cash">("accrual"); const [showOpening, setShowOpening] = useState(true); const [search, setSearch] = useState(""); diff --git a/src/pages/accounting/lib/checkPdf.ts b/src/pages/accounting/lib/checkPdf.ts index 1773e20..cb4d673 100644 --- a/src/pages/accounting/lib/checkPdf.ts +++ b/src/pages/accounting/lib/checkPdf.ts @@ -51,6 +51,32 @@ 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; + /** 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 ──────────────────────────────────────────────────────────────── @@ -71,12 +97,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,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) { +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(); @@ -118,174 +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); + doc.text(c.payee, x(addrIndentX + dx), addrBlockY); + 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 / 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.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); + // ── 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; - 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 */ } + if (!hid("memo")) { + const dx = ax("memo"); + const bottomLineY = y(bottomDy + ay("memo")); + doc.setFont("helvetica", "normal"); + 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); + } } - // 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 - ); - - if (micr) { - ensureMicrFont(doc); - doc.setFontSize(11); - doc.setTextColor(0); - doc.text(micr, x(PW / 2), micrY, { align: "center" }); - doc.setFont("helvetica", "normal"); - } else { + if (!hid("signature")) { + const dx = ax("signature"); + const bottomLineY = y(bottomDy + ay("signature")); + // 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.65; + 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 */ } + } + // 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(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.13, { align: "center" }); doc.setTextColor(0); } + + // ── 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); + } + } } // ── Voucher stub ───────────────────────────────────────────────────────────── @@ -351,19 +421,22 @@ 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; + const fp = opts.fieldPositions || {}; 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, 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); + drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2, fp); } }); 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..820dbc1 100644 --- a/src/pages/accounting/lib/reportPdf.ts +++ b/src/pages/accounting/lib/reportPdf.ts @@ -9,6 +9,8 @@ export type StructuredRow = { code?: string; amount?: number; compare?: number; + /** GL account id for per-account rows β€” enables drill-down from on-screen reports. */ + accountId?: string; }; export type StructuredReport = { @@ -236,7 +238,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); }