mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
7ccfc133f8
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>
485 lines
21 KiB
TypeScript
485 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|