Bill-payment checks: per-element positioning + visibility

The bill-payment check generator (checkPdf.ts) now supports per-field
position offsets (X/Y inches) and show/hide for every element — check
number, return address, bank block, date, pay-to, amounts, payee address,
memo, signature, and MICR — layered on the existing layout (defaults render
identically). Edited in Settings → Check Setup ("Element positions").
Stored in accounting.check_settings.field_positions (jsonb). Also replaced a
"→" with ">" in the MICR placeholder to avoid the UTF-16 spacing artifact.

Migration applied: check_settings.field_positions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 01:05:10 -04:00
parent c3d1d86b07
commit 7ccfc133f8
3 changed files with 261 additions and 155 deletions
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch";
import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { generateCheckPDF, generateTestAlignmentPDF } from "./lib/checkPdf";
import { generateCheckPDF, generateTestAlignmentPDF, CHECK_FIELD_KEYS, CHECK_FIELD_LABELS } from "./lib/checkPdf";
export default function AccountingCheckSetupPage() {
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
@@ -37,6 +37,7 @@ export default function AccountingCheckSetupPage() {
const [micrOffsetY, setMicrOffsetY] = useState(0);
const [micrGap1, setMicrGap1] = useState(1);
const [micrGap2, setMicrGap2] = useState(1);
const [fieldPositions, setFieldPositions] = useState({});
const { data: settings } = useQuery({
queryKey: ["check-settings", cid],
@@ -71,6 +72,7 @@ export default function AccountingCheckSetupPage() {
setMicrOffsetY(Number(s.micr_offset_y ?? 0));
setMicrGap1(Number(s.micr_gap_1 ?? 1));
setMicrGap2(Number(s.micr_gap_2 ?? 1));
setFieldPositions(s.field_positions ?? {});
}, [settings]);
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
@@ -96,6 +98,7 @@ export default function AccountingCheckSetupPage() {
micr_offset_y: micrOffsetY,
micr_gap_1: micrGap1,
micr_gap_2: micrGap2,
field_positions: fieldPositions,
}, { onConflict: "company_id" });
setSaving(false);
if (error) return toast.error(error.message);
@@ -136,7 +139,7 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
});
const w = window.open("");
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`);
@@ -147,12 +150,17 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2,
offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
});
const w = window.open("");
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`);
};
const updateFieldPos = (key, patch) =>
setFieldPositions((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), ...patch } }));
const resetFieldPos = (key) =>
setFieldPositions((prev) => { const n = { ...prev }; delete n[key]; return n; });
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
@@ -358,6 +366,40 @@ export default function AccountingCheckSetupPage() {
<GapControl label="Routing → Account" value={micrGap2} onChange={setMicrGap2} />
</div>
</div>
<div className="border-t pt-3">
<p className="text-sm font-medium">Element positions <span className="text-xs font-normal text-muted-foreground">(nudge any element; inches, +right / +down)</span></p>
<div className="mt-2 grid grid-cols-12 gap-2 text-xs font-semibold text-muted-foreground px-1">
<div className="col-span-5">Element</div>
<div className="col-span-2 text-center">Show</div>
<div className="col-span-2">X</div>
<div className="col-span-2">Y</div>
<div className="col-span-1" />
</div>
{CHECK_FIELD_KEYS.map((key) => {
const fp = fieldPositions[key] || {};
return (
<div key={key} className="grid grid-cols-12 gap-2 items-center px-1 py-1 rounded hover:bg-background">
<div className="col-span-5 text-sm">{CHECK_FIELD_LABELS[key]}</div>
<div className="col-span-2 flex justify-center">
<Switch checked={fp.hidden !== true} onCheckedChange={(v) => updateFieldPos(key, { hidden: !v })} />
</div>
<div className="col-span-2">
<Input type="number" step="0.05" className="h-7 text-xs font-mono" value={fp.dx ?? 0}
onChange={(e) => updateFieldPos(key, { dx: parseFloat(e.target.value) || 0 })} />
</div>
<div className="col-span-2">
<Input type="number" step="0.05" className="h-7 text-xs font-mono" value={fp.dy ?? 0}
onChange={(e) => updateFieldPos(key, { dy: parseFloat(e.target.value) || 0 })} />
</div>
<div className="col-span-1 text-right">
<button type="button" onClick={() => resetFieldPos(key)} title="Reset"
className="text-xs text-muted-foreground hover:text-foreground"></button>
</div>
</div>
);
})}
</div>
</div>
<div className="flex gap-2 flex-wrap">