Files
acmcc/src/pages/accounting/AccountingCheckSetupPage.tsx
T
admin 7ccfc133f8 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>
2026-06-03 01:05:10 -04:00

485 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, useEffect } from "react";
import { accounting } from "@/lib/accountingClient";
import { supabase } from "@/integrations/supabase/client";
import { useCompanyId } from "./lib/useCompanyId";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { generateCheckPDF, generateTestAlignmentPDF, CHECK_FIELD_KEYS, CHECK_FIELD_LABELS } from "./lib/checkPdf";
export default function AccountingCheckSetupPage() {
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
const cid = companyId ?? "";
const qc = useQueryClient();
const sigRef = useRef<HTMLInputElement>(null);
const [saving, setSaving] = useState(false);
const [uploadingSig, setUploadingSig] = useState(false);
const [bankName, setBankName] = useState("");
const [bankAddress, setBankAddress] = useState("");
const [routingNumber, setRoutingNumber] = useState("");
const [accountNumber, setAccountNumber] = useState("");
const [fractionalRouting, setFractionalRouting] = useState("");
const [printSignature, setPrintSignature] = useState(false);
const [signatureUrl, setSignatureUrl] = useState("");
const [defaultStyle, setDefaultStyle] = useState("voucher");
const [defaultPosition, setDefaultPosition] = useState("top");
const [fontSize, setFontSize] = useState("medium");
const [nextCheckNumber, setNextCheckNumber] = useState(1001);
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],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("check_settings").select("*").eq("company_id", cid).maybeSingle()).data,
});
const { data: company } = useQuery({
queryKey: ["company-detail", cid],
enabled: !!cid,
queryFn: async () =>
(await accounting.from("companies").select("name,address,logo_url").eq("id", cid).single()).data,
});
useEffect(() => {
if (!settings) return;
const s = settings as any;
setBankName(s.bank_name ?? "");
setBankAddress(s.bank_address ?? "");
setRoutingNumber(s.routing_number ?? "");
setAccountNumber(s.account_number ?? "");
setFractionalRouting(s.fractional_routing ?? "");
setPrintSignature(s.print_signature ?? false);
setSignatureUrl(s.signature_url ?? "");
setDefaultStyle(s.default_style ?? "voucher");
setDefaultPosition(s.default_position ?? "top");
setFontSize(s.font_size ?? "medium");
setNextCheckNumber(s.next_check_number ?? 1001);
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, ""));
const accountValid = accountNumber.replace(/\D/g, "").length >= 4;
const save = async () => {
setSaving(true);
const { error } = await accounting.from("check_settings").upsert({
company_id: cid,
bank_name: bankName || null,
bank_address: bankAddress || null,
routing_number: routingNumber.replace(/\D/g, "") || null,
account_number: accountNumber.replace(/\D/g, "") || null,
fractional_routing: fractionalRouting || null,
print_signature: printSignature,
signature_url: signatureUrl || null,
default_style: defaultStyle,
default_position: defaultPosition,
font_size: fontSize,
next_check_number: nextCheckNumber,
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);
toast.success("Check settings saved");
qc.invalidateQueries({ queryKey: ["check-settings", cid] });
};
const uploadSignature = async (file: File) => {
setUploadingSig(true);
const path = `${cid}/signature.png`;
const { error } = await supabase.storage.from("signatures").upload(path, file, {
contentType: file.type, upsert: true,
});
if (error) { toast.error(error.message); setUploadingSig(false); return; }
const { data } = supabase.storage.from("signatures").getPublicUrl(path);
setSignatureUrl(data.publicUrl + `?t=${Date.now()}`);
toast.success("Signature uploaded — save to keep it");
setUploadingSig(false);
};
const previewCheck = () => {
const dataUrl = generateCheckPDF([{
companyName: (company as any)?.name ?? "Association Name",
companyAddress: (company as any)?.address ?? undefined,
bankName: bankName || undefined,
bankAddress: bankAddress || undefined,
routingNumber: routingNumber.replace(/\D/g, "") || undefined,
accountNumber: accountNumber.replace(/\D/g, "") || undefined,
fractionalRouting: fractionalRouting || undefined,
checkNumber: nextCheckNumber,
date: new Date().toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" }),
payee: "Sample Payee Name",
amount: 1234.56,
memo: "Sample memo line",
printSignature,
signatureDataUrl: signatureUrl || undefined,
}], {
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
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>`);
};
const printAlignment = () => {
const dataUrl = generateTestAlignmentPDF({
style: defaultStyle as any,
position: defaultPosition as any,
fontSize: fontSize as any,
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>;
return (
<div className="space-y-6 max-w-2xl">
{/* ── Bank Account ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">Bank Account</CardTitle>
<CardDescription>Printed on the check face and MICR line.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Bank name</Label>
<Input value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="First National Bank" />
</div>
<div className="space-y-1">
<Label>Bank city / address</Label>
<Input value={bankAddress} onChange={(e) => setBankAddress(e.target.value)} placeholder="Largo, FL" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label>Routing number (ABA)</Label>
{routingNumber.length > 0 && (
<span className={`text-xs font-medium ${routingValid ? "text-emerald-600" : "text-red-500"}`}>
{routingValid ? "✓ Valid" : "Must be 9 digits"}
</span>
)}
</div>
<Input
value={routingNumber}
onChange={(e) => setRoutingNumber(e.target.value.replace(/\D/g, "").slice(0, 9))}
placeholder="123456789"
className="font-mono"
maxLength={9}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label>Account number</Label>
{accountNumber.length > 0 && (
<span className={`text-xs font-medium ${accountValid ? "text-emerald-600" : "text-amber-600"}`}>
{accountValid ? "✓ Set" : "Too short"}
</span>
)}
</div>
<Input
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value.replace(/\D/g, ""))}
placeholder="1234567890"
className="font-mono"
/>
</div>
</div>
<div className="space-y-1">
<Label>
Fractional routing{" "}
<span className="text-muted-foreground text-xs font-normal">(optional e.g. 12-3456/0789)</span>
</Label>
<Input
value={fractionalRouting}
onChange={(e) => setFractionalRouting(e.target.value)}
placeholder="12-3456/0789"
className="font-mono max-w-[200px]"
/>
</div>
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
These numbers print in the MICR band at the bottom of every check. Ensure they exactly match your bank account.
</p>
</CardContent>
</Card>
{/* ── Signature ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">Authorized Signature</CardTitle>
<CardDescription>Upload a signature image to auto-print on checks.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-5">
<div className="shrink-0 w-52 h-20 rounded-md border-2 border-dashed border-border bg-muted/30 flex items-center justify-center overflow-hidden">
{signatureUrl
? <img src={signatureUrl} alt="Signature preview" className="max-h-full max-w-full object-contain p-1" />
: <span className="text-xs text-muted-foreground">No signature uploaded</span>
}
</div>
<div className="space-y-2 pt-1">
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => sigRef.current?.click()} disabled={uploadingSig}>
<Upload className="h-3.5 w-3.5 mr-1" />
{uploadingSig ? "Uploading…" : "Upload signature"}
</Button>
{signatureUrl && (
<Button variant="ghost" size="sm" onClick={() => { setSignatureUrl(""); setPrintSignature(false); }}>
<X className="h-3.5 w-3.5 mr-1" /> Remove
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">PNG with transparent background recommended. Max 400×100px.</p>
<input
ref={sigRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadSignature(f); e.target.value = ""; }}
/>
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<Switch
id="print-sig"
checked={printSignature}
onCheckedChange={setPrintSignature}
disabled={!signatureUrl}
/>
<Label htmlFor="print-sig" className="cursor-pointer">
Auto-print signature on checks
{!signatureUrl && <span className="ml-1 text-xs text-muted-foreground">(upload a signature first)</span>}
</Label>
</div>
</CardContent>
</Card>
{/* ── Print Settings ── */}
<Card>
<CardHeader>
<CardTitle className="text-base">Print Settings</CardTitle>
<CardDescription>Default layout applied when printing from Bills or Banking.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Check style</Label>
<Select value={defaultStyle} onValueChange={setDefaultStyle}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="voucher">Voucher (3-part)</SelectItem>
<SelectItem value="standard">Check only</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Position</Label>
<Select value={defaultPosition} onValueChange={setDefaultPosition}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top">Top of page</SelectItem>
<SelectItem value="middle">Middle</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Font size</Label>
<Select value={fontSize} onValueChange={setFontSize}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="small">Small</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="large">Large</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1 max-w-[160px]">
<Label>Next check number</Label>
<Input
type="number"
min={1}
value={nextCheckNumber}
onChange={(e) => setNextCheckNumber(parseInt(e.target.value) || 1001)}
className="font-mono"
/>
</div>
<div className="rounded-md border bg-muted/20 p-4 space-y-3">
<p className="text-sm font-medium">Position adjustments <span className="text-xs font-normal text-muted-foreground">(inches)</span></p>
<div className="grid grid-cols-3 gap-6">
<OffsetControl label="X offset (←/→)" hint="+right / left" value={offsetX} onChange={setOffsetX} />
<OffsetControl label="Y offset (↑/↓)" hint="+down / up" value={offsetY} onChange={setOffsetY} />
<OffsetControl label="MICR Y only" hint="Fine-tune MICR band" value={micrOffsetY} onChange={setMicrOffsetY} />
</div>
<p className="text-xs text-muted-foreground">
Run the alignment test first, measure the offset on blank paper, then adjust here and preview again.
</p>
<div className="border-t pt-3">
<p className="text-sm font-medium">MICR gaps <span className="text-xs font-normal text-muted-foreground">(spaces between segments)</span></p>
<div className="grid grid-cols-2 gap-6 mt-2">
<GapControl label="Check # → Routing" value={micrGap1} onChange={setMicrGap1} />
<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">
<Button variant="outline" onClick={previewCheck}>
<Eye className="h-4 w-4 mr-1" /> Preview sample check
</Button>
<Button variant="outline" onClick={printAlignment}>
<Printer className="h-4 w-4 mr-1" /> Print alignment test
</Button>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={save} disabled={saving}>
<Save className="h-4 w-4 mr-1" />
{saving ? "Saving…" : "Save check settings"}
</Button>
</div>
</div>
);
}
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 (
<div className="space-y-1.5">
<p className="text-xs font-medium text-foreground">{label}</p>
<div className="flex items-center gap-1">
<button onClick={() => adj(-1)}
className="h-7 w-7 rounded border bg-background hover:bg-muted flex items-center justify-center font-bold text-sm shrink-0">
</button>
<Input
type="number"
min={0}
step={1}
value={value}
onChange={(e) => onChange(Math.max(0, parseInt(e.target.value) || 0))}
className="h-7 text-center font-mono text-xs"
/>
<button onClick={() => adj(1)}
className="h-7 w-7 rounded border bg-background hover:bg-muted flex items-center justify-center font-bold text-sm shrink-0">
+
</button>
</div>
</div>
);
}
function OffsetControl({ label, hint, value, onChange }: {
label: string; hint: string; value: number; onChange: (v: number) => void;
}) {
const STEP = 0.05;
const adj = (d: number) => onChange(Math.round((value + d) * 1000) / 1000);
return (
<div className="space-y-1.5">
<p className="text-xs font-medium text-foreground">{label}</p>
<div className="flex items-center gap-1">
<button onClick={() => adj(-STEP)}
className="h-7 w-7 rounded border bg-background hover:bg-muted flex items-center justify-center font-bold text-sm shrink-0">
</button>
<Input
type="number"
step={STEP}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
className="h-7 text-center font-mono text-xs"
/>
<button onClick={() => adj(STEP)}
className="h-7 w-7 rounded border bg-background hover:bg-muted flex items-center justify-center font-bold text-sm shrink-0">
+
</button>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">{hint}</p>
</div>
);
}