Merge pull request #4 from renee-png/accounting-bill-payment-ap

Accounting: A/P-clearing payments, check return address + MICR gaps, dashboard fixes
This commit is contained in:
2026-06-03 01:18:14 -04:00
committed by GitHub
13 changed files with 567 additions and 224 deletions
+8
View File
@@ -94,6 +94,14 @@ export function BidQuoteDetailsDialog({ open, onOpenChange, bid, onRefresh }) {
<div><span className="text-muted-foreground">Status:</span> {bid.status}</div> <div><span className="text-muted-foreground">Status:</span> {bid.status}</div>
{bid.received_date && <div><span className="text-muted-foreground">Received:</span> {format(new Date(bid.received_date), 'MMM d, yyyy')}</div>} {bid.received_date && <div><span className="text-muted-foreground">Received:</span> {format(new Date(bid.received_date), 'MMM d, yyyy')}</div>}
</div> </div>
{bid.document_url && (
<div className="mt-3 pt-3 border-t">
<a href={bid.document_url} target="_blank" rel="noreferrer"
className="text-sm text-primary underline">
📄 {bid.document_name || 'View attached PDF'}
</a>
</div>
)}
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
+53 -2
View File
@@ -38,6 +38,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
const [associations, setAssociations] = useState([]); const [associations, setAssociations] = useState([]);
const [selectedAssociations, setSelectedAssociations] = useState([]); const [selectedAssociations, setSelectedAssociations] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [fileUploading, setFileUploading] = useState(false);
const [documentUrl, setDocumentUrl] = useState('');
const [documentName, setDocumentName] = useState('');
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
@@ -55,9 +58,34 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
fetchAssociations(); fetchAssociations();
form.reset(); form.reset();
setSelectedAssociations([]); setSelectedAssociations([]);
setDocumentUrl('');
setDocumentName('');
} }
}, [open, form]); }, [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 () => { const fetchAssociations = async () => {
try { try {
const { data, error } = await supabase const { data, error } = await supabase
@@ -91,7 +119,9 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
amount: values.amount || 0, amount: values.amount || 0,
association_id: assocId, association_id: assocId,
created_by: user?.id, 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); const { error } = await supabase.from('bids_quotes').insert(inserts);
@@ -191,6 +221,27 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
)} )}
/> />
<div className="space-y-2">
<FormLabel>Attachment (PDF)</FormLabel>
{documentName ? (
<div className="flex items-center gap-3 p-3 border rounded-md bg-muted/30">
<a href={documentUrl} target="_blank" rel="noreferrer" className="text-sm text-primary underline truncate flex-1">
{documentName}
</a>
<Button type="button" variant="ghost" size="sm" className="gap-1 text-destructive"
onClick={() => { setDocumentUrl(''); setDocumentName(''); }}>
<X className="h-4 w-4" /> Remove
</Button>
</div>
) : (
<label className="inline-flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-muted text-sm">
{fileUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{fileUploading ? 'Uploading…' : 'Upload PDF'}
<input type="file" accept="application/pdf" className="hidden" onChange={handleFileUpload} />
</label>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<FormLabel>Assign to Associations</FormLabel> <FormLabel>Assign to Associations</FormLabel>
@@ -232,7 +283,7 @@ export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={uploading}> <Button type="submit" disabled={uploading || fileUploading}>
{uploading ? ( {uploading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+19
View File
@@ -53,6 +53,8 @@ const DEFAULTS: CheckLayout = {
font_family: "helvetica", font_family: "helvetica",
field_positions: {}, field_positions: {},
logo_url: "", logo_url: "",
micr_gap_1: 3,
micr_gap_2: 3,
}; };
const FIELD_ORDER: CheckFieldKey[] = [ const FIELD_ORDER: CheckFieldKey[] = [
@@ -107,6 +109,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: (data as any).font_family || "helvetica", font_family: (data as any).font_family || "helvetica",
field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {}, field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
logo_url: (data as any).logo_url || "", 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 { } else {
setLayoutId(null); setLayoutId(null);
@@ -178,6 +182,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: (data as any).font_family || "helvetica", font_family: (data as any).font_family || "helvetica",
field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {}, field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
logo_url: (data as any).logo_url || "", 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({ toast({
title: "Settings copied", title: "Settings copied",
@@ -255,6 +261,8 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
font_family: layout.font_family || "helvetica", font_family: layout.font_family || "helvetica",
field_positions: (layout.field_positions || {}) as any, field_positions: (layout.field_positions || {}) as any,
logo_url: layout.logo_url || null, 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 const payload: any = isCompany
? basePayload ? basePayload
@@ -412,6 +420,17 @@ export default function CheckLayoutEditor({ associationId, associationName, mode
<Input type="number" step="0.05" value={layout.offset_y ?? 0} <Input type="number" step="0.05" value={layout.offset_y ?? 0}
onChange={(e) => update("offset_y", Number(e.target.value))} /> onChange={(e) => update("offset_y", Number(e.target.value))} />
</div> </div>
<div>
<Label>MICR gap: Check # → Routing</Label>
<Input type="number" min={0} step={1} value={layout.micr_gap_1 ?? 3}
onChange={(e) => update("micr_gap_1", Math.max(0, parseInt(e.target.value) || 0))} />
<p className="text-xs text-muted-foreground mt-1">Spaces between segments in the MICR line.</p>
</div>
<div>
<Label>MICR gap: Routing → Account</Label>
<Input type="number" min={0} step={1} value={layout.micr_gap_2 ?? 3}
onChange={(e) => update("micr_gap_2", Math.max(0, parseInt(e.target.value) || 0))} />
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -256,9 +256,14 @@ export default function AccountingBankingPage() {
const description = [partyName, coaName, memo].filter(Boolean).join(" · "); const description = [partyName, coaName, memo].filter(Boolean).join(" · ");
const category = coaName; 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 = { const payload: any = {
account_id, date, description, amount, type, category, reference: reference || null, 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, vendor_id: vendor_id || null,
customer_id: customer_id || null, customer_id: customer_id || null,
}; };
+36 -14
View File
@@ -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 { data: billItems } = await accounting.from("bill_items").select("description,amount").eq("bill_id", payBill?.id ?? "");
const vendorAddress = payBill?.vendors?.address ?? undefined; 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( const dataUrl = generateCheckPDF(
[{ [{
companyName: associationName ?? "Company", companyName: (payerName || associationName) ?? "Company",
companyAddress: undefined, companyAddress: returnAddress,
bankName: cs?.bank_name ?? bankAccount?.name ?? undefined, bankName: cs?.bank_name ?? bankAccount?.name ?? undefined,
bankAddress: cs?.bank_address ?? undefined, bankAddress: cs?.bank_address ?? undefined,
routingNumber: cs?.routing_number ?? undefined, routingNumber: cs?.routing_number ?? undefined,
@@ -355,6 +366,9 @@ export default function AccountingBillsPage() {
offsetX: (cs as any)?.offset_x ?? 0, offsetX: (cs as any)?.offset_x ?? 0,
offsetY: (cs as any)?.offset_y ?? 0, offsetY: (cs as any)?.offset_y ?? 0,
micrOffsetY: (cs as any)?.micr_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(""); const w = window.open("");
@@ -372,16 +386,24 @@ export default function AccountingBillsPage() {
const vendorName = payBill.vendors?.name ?? "Vendor"; const vendorName = payBill.vendors?.name ?? "Vendor";
const refLabel = payReference || payMethod.toUpperCase(); const refLabel = payReference || payMethod.toUpperCase();
// 1) Bank ledger transaction — debit = money OUT of the bank (payment to vendor) // Expense category name(s) from the bill — shown on the payment line in the
// Pull the primary expense account from the first bill item so the COA balance updates // transaction journal for visibility (display only; does not affect the GL).
const { data: billItems } = await accounting const { data: payItems } = await accounting
.from("bill_items") .from("bill_items").select("account_id").eq("bill_id", payBill.id).not("account_id", "is", null);
.select("account_id") const payAcctIds = Array.from(new Set((payItems ?? []).map((i: any) => i.account_id)));
.eq("bill_id", payBill.id) let categoryLabel = "Bill Payment";
.not("account_id", "is", null) if (payAcctIds.length) {
.limit(1); const { data: payAccs } = await accounting.from("accounts").select("name").in("id", payAcctIds);
const primaryCoa = billItems?.[0]?.account_id ?? null; 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({ await accounting.from("transactions").insert({
company_id: cid, company_id: cid,
account_id: payAccountId, account_id: payAccountId,
@@ -389,10 +411,10 @@ export default function AccountingBillsPage() {
type: "debit", type: "debit",
amount: payAmount, amount: payAmount,
description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`, description: `Bill Payment · ${vendorName} · Bill ${payBill.number}`,
category: "Bill Payment", category: categoryLabel,
reference: refLabel, reference: refLabel,
coa_account_id: primaryCoa, // links to expense account → updates COA balance coa_account_id: null, // → posts against Accounts Payable (via vendor)
vendor_id: payBill.vendor_id ?? null, // link to vendor for reporting 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 // 2) Bank balance auto-updated by DB trigger trg_sync_account_balance
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react"; import { Upload, X, Eye, Printer, Save, Info, Loader2 } from "lucide-react";
import { toast } from "sonner"; 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() { export default function AccountingCheckSetupPage() {
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId(); const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
@@ -35,6 +35,9 @@ export default function AccountingCheckSetupPage() {
const [offsetX, setOffsetX] = useState(0); const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0); const [offsetY, setOffsetY] = useState(0);
const [micrOffsetY, setMicrOffsetY] = 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({ const { data: settings } = useQuery({
queryKey: ["check-settings", cid], queryKey: ["check-settings", cid],
@@ -67,6 +70,9 @@ export default function AccountingCheckSetupPage() {
setOffsetX(Number(s.offset_x ?? 0)); setOffsetX(Number(s.offset_x ?? 0));
setOffsetY(Number(s.offset_y ?? 0)); setOffsetY(Number(s.offset_y ?? 0));
setMicrOffsetY(Number(s.micr_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]); }, [settings]);
const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, "")); const routingValid = /^\d{9}$/.test(routingNumber.replace(/\D/g, ""));
@@ -90,6 +96,9 @@ export default function AccountingCheckSetupPage() {
offset_x: offsetX, offset_x: offsetX,
offset_y: offsetY, offset_y: offsetY,
micr_offset_y: micrOffsetY, micr_offset_y: micrOffsetY,
micr_gap_1: micrGap1,
micr_gap_2: micrGap2,
field_positions: fieldPositions,
}, { onConflict: "company_id" }); }, { onConflict: "company_id" });
setSaving(false); setSaving(false);
if (error) return toast.error(error.message); if (error) return toast.error(error.message);
@@ -130,7 +139,7 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any, style: defaultStyle as any,
position: defaultPosition as any, position: defaultPosition as any,
fontSize: fontSize as any, fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY, offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
}); });
const w = window.open(""); const w = window.open("");
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`); if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh"></iframe>`);
@@ -141,12 +150,17 @@ export default function AccountingCheckSetupPage() {
style: defaultStyle as any, style: defaultStyle as any,
position: defaultPosition as any, position: defaultPosition as any,
fontSize: fontSize as any, fontSize: fontSize as any,
offsetX, offsetY, micrOffsetY, offsetX, offsetY, micrOffsetY, micrGap1, micrGap2, fieldPositions,
}); });
const w = window.open(""); const w = window.open("");
if (w) w.document.write(`<iframe src="${dataUrl}" style="border:0;width:100%;height:100vh" onload="this.contentWindow.print()"></iframe>`); 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 (!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 (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>; if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
@@ -345,6 +359,47 @@ export default function AccountingCheckSetupPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Run the alignment test first, measure the offset on blank paper, then adjust here and preview again. Run the alignment test first, measure the offset on blank paper, then adjust here and preview again.
</p> </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>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -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 (
<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 }: { function OffsetControl({ label, hint, value, onChange }: {
label: string; hint: string; value: number; onChange: (v: number) => void; label: string; hint: string; value: number; onChange: (v: number) => void;
}) { }) {
@@ -6,8 +6,30 @@ import { useCompanyId } from "./lib/useCompanyId";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { money, fmtDate } from "./lib/format";
import { generateDashboardPdf } from "./lib/dashboardPdf"; 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 { import {
Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown, Receipt, FileText, Landmark, ArrowUpRight, ArrowDownRight, Loader2, FileDown,
} from "lucide-react"; } from "lucide-react";
@@ -31,15 +53,19 @@ export default function AccountingDashboardPage({ association }: { association?:
const logoUrl = (assocMeta as any)?.logo_url || null; const logoUrl = (assocMeta as any)?.logo_url || null;
const [exporting, setExporting] = useState(false); 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<RangePreset>("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({ const { data } = useQuery({
queryKey: ["dashboard", cid], queryKey: ["dashboard", cid, from, to],
enabled: !!cid, enabled: !!cid,
queryFn: async () => { 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([ const [inv, bills, tx, acc] = await Promise.all([
accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid), accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid),
accounting.from("bills").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") .from("transactions")
.select("amount,type,date,description,category,reference") .select("amount,type,date,description,category,reference")
.eq("company_id", cid) .eq("company_id", cid)
.gte("date", from)
.lte("date", to)
.order("date", { ascending: false }) .order("date", { ascending: false })
.limit(10), .limit(50),
accounting.from("accounts").select("balance,is_bank").eq("company_id", cid), 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 months: { key: string; label: string; income: number; expense: number }[] = [];
const now = new Date(); const cursor = new Date(`${from}T00:00:00`);
for (let i = 5; i >= 0; i--) { cursor.setDate(1);
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const lastMonth = new Date(`${to}T00:00:00`);
lastMonth.setDate(1);
while (cursor <= lastMonth && months.length < 36) {
months.push({ months.push({
key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`, key: `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`,
label: d.toLocaleString("en-US", { month: "short" }), label: cursor.toLocaleString("en-US", { month: "short", year: "2-digit" }),
income: 0, income: 0,
expense: 0, expense: 0,
}); });
cursor.setMonth(cursor.getMonth() + 1);
} }
const bucket = (date: string) => date.slice(0, 7); const bucket = (date: string) => date.slice(0, 7);
const inWin = (d?: string | null) => !!d && d >= from && d <= to;
for (const i of invoices) { for (const i of invoices) {
if (i.issue_date && i.issue_date >= sinceIso) { if (inWin(i.issue_date)) {
const m = months.find((x) => x.key === bucket(i.issue_date)); const m = months.find((x) => x.key === bucket(i.issue_date!));
if (m) m.income += Number(i.total); if (m) m.income += Number(i.total);
} }
} }
for (const b of billsArr) { for (const b of billsArr) {
if (b.issue_date && b.issue_date >= sinceIso) { if (inWin(b.issue_date)) {
const m = months.find((x) => x.key === bucket(b.issue_date)); const m = months.find((x) => x.key === bucket(b.issue_date!));
if (m) m.expense += Number(b.total); if (m) m.expense += Number(b.total);
} }
} }
@@ -124,7 +156,7 @@ export default function AccountingDashboardPage({ association }: { association?:
counts, counts,
months, months,
topExpenses, topExpenses,
recent: txAll, recent: txAll.slice(0, 15),
}; };
}, },
}); });
@@ -175,25 +207,46 @@ export default function AccountingDashboardPage({ association }: { association?:
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">{associationName}</p> <p className="text-sm text-muted-foreground">{associationName} · {rangeLabel}</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Select value={preset} onValueChange={(v) => applyPreset(v as RangePreset)}>
<SelectTrigger className="h-9 w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="this-month">This Month</SelectItem>
<SelectItem value="this-quarter">This Quarter</SelectItem>
<SelectItem value="ytd">Year to Date</SelectItem>
<SelectItem value="last-6-months">Last 6 Months</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{preset === "custom" && (
<div className="flex items-center gap-1">
<Input type="date" value={from} className="h-9 w-[140px]"
onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))} />
<span className="text-muted-foreground text-xs">to</span>
<Input type="date" value={to} className="h-9 w-[140px]"
onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))} />
</div>
)}
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={!data || exporting}
onClick={async () => {
if (!data) return;
setExporting(true);
try {
await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data, rangeLabel });
} finally {
setExporting(false);
}
}}
>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />} Export PDF
</Button>
</div> </div>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={!data || exporting}
onClick={async () => {
if (!data) return;
setExporting(true);
try {
await generateDashboardPdf({ companyName: associationName ?? "Company", logoUrl, currency: c, data });
} finally {
setExporting(false);
}
}}
>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />} Export PDF
</Button>
</div> </div>
{/* Cash flow summary */} {/* Cash flow summary */}
+19 -8
View File
@@ -202,6 +202,10 @@ export default function AccountingReportsPage({ association }: { association?: {
const cid = companyId ?? ""; const cid = companyId ?? "";
const cur = "USD"; const cur = "USD";
const [active, setActive] = useState<ReportId>("pnl"); const [active, setActive] = useState<ReportId>("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<string | null>(null);
const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); };
// Period // Period
const [preset, setPreset] = useState<Preset>("ytd"); const [preset, setPreset] = useState<Preset>("ytd");
@@ -548,7 +552,7 @@ export default function AccountingReportsPage({ association }: { association?: {
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} /> <TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
)} )}
{active === "general-ledger" && ( {active === "general-ledger" && (
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} /> <GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} />
)} )}
{active === "reserve-fund" && ( {active === "reserve-fund" && (
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} /> <ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
@@ -563,7 +567,7 @@ export default function AccountingReportsPage({ association }: { association?: {
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading</div></CardContent></Card> <Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading</div></CardContent></Card>
) : structured ? ( ) : structured ? (
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}> <ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} /> <StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} onDrill={drillToAccount} />
</ReportSheet> </ReportSheet>
) : ( ) : (
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card> <Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
@@ -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; report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
onDrill?: (accountId: string, label: string) => void;
}) { }) {
let alt = false; let alt = false;
const span = showCompare ? 5 : 2; const span = showCompare ? 5 : 2;
@@ -666,14 +671,19 @@ function StructuredTable({ report, showCodes, showCompare, showZero, currency }:
const shaded = r.kind === "sub" && alt; const shaded = r.kind === "sub" && alt;
if (r.kind === "sub") alt = !alt; if (r.kind === "sub") alt = !alt;
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined; const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
const drillable = r.kind === "sub" && !!r.accountId && !!onDrill;
return ( return (
<tr key={i} className={[ <tr key={i}
onClick={drillable ? () => onDrill!(r.accountId!, r.label) : undefined}
title={drillable ? "View transactions for this account" : undefined}
className={[
shaded ? "bg-muted/40" : "", shaded ? "bg-muted/40" : "",
bold ? "border-t font-semibold" : "", bold ? "border-t font-semibold" : "",
drillable ? "cursor-pointer hover:bg-primary/5" : "",
].join(" ")}> ].join(" ")}>
<td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}> <td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
{showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>} {showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>}
{r.label} <span className={drillable ? "text-primary underline-offset-2 hover:underline" : ""}>{r.label}</span>
</td> </td>
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} /> <AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
{showCompare && <AmountCell n={r.compare} />} {showCompare && <AmountCell n={r.compare} />}
@@ -1036,6 +1046,7 @@ function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredRe
rows.push({ rows.push({
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign), kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined, 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 // Assets
rows.push({ kind: "section", label: "Assets" }); rows.push({ kind: "section", label: "Assets" });
const assets = byType("asset"); 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); const totalA = sumBal(assets);
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) }); rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
rows.push({ kind: "spacer", label: "" }); rows.push({ kind: "spacer", label: "" });
@@ -1152,7 +1163,7 @@ function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredRep
// Liabilities // Liabilities
rows.push({ kind: "section", label: "Liabilities" }); rows.push({ kind: "section", label: "Liabilities" });
const liabs = byType("liability"); 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); const totalL = sumBal(liabs);
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) }); rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
rows.push({ kind: "spacer", label: "" }); 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 // Equity — equity accounts + calculated RE / current-year earnings
rows.push({ kind: "section", label: "Equity" }); rows.push({ kind: "section", label: "Equity" });
const equityAccs = byType("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: "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) }); rows.push({ kind: "sub", label: "Current Year Earnings", amount: cur.cye, compare: cmp(prev?.cye) });
const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye; const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye;
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { accounting } from "@/lib/accountingClient"; import { accounting } from "@/lib/accountingClient";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -37,10 +37,15 @@ type Txn = {
debit: number; credit: number; balance: number; abnormal?: boolean; 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 [from, setFrom] = useState(startOfYear());
const [to, setTo] = useState(today()); const [to, setTo] = useState(today());
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]); const [selectedAccounts, setSelectedAccounts] = useState<string[]>(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 [basis, setBasis] = useState<"accrual" | "cash">("accrual");
const [showOpening, setShowOpening] = useState(true); const [showOpening, setShowOpening] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
+222 -149
View File
@@ -51,6 +51,32 @@ export type CheckPrintOptions = {
offsetY?: number; offsetY?: number;
/** Extra vertical shift for the MICR line only (fine-tune for check stock) */ /** Extra vertical shift for the MICR line only (fine-tune for check stock) */
micrOffsetY?: number; 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<string, { dx?: number; dy?: number; hidden?: boolean }>;
};
/** 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<string, string> = {
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 ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
@@ -71,12 +97,15 @@ const CHECK_H = SECTION_H; // check occupies the full top section
// Format matches reference check: C000000305C A263191387A 1100034740184C // Format matches reference check: C000000305C A263191387A 1100034740184C
// C{check#}C A{routing}A {account}C // 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 r = (routing ?? "").replace(/\D/g, "").slice(0, 9);
const a = (account ?? "").replace(/\D/g, ""); const a = (account ?? "").replace(/\D/g, "");
const c = String(checkNum).padStart(9, "0"); const c = String(checkNum).padStart(9, "0");
if (!r && !a) return ""; 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) ─ // ── 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 ─────────────────────────────────────────────────────────────── // ── 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<string, { dx?: number; dy?: number; hidden?: boolean }> = {}
) {
const y = (dy: number) => originY + dy; const y = (dy: number) => originY + dy;
const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate 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 // VOID watermark
if (c.voided) { if (c.voided) {
doc.saveGraphicsState(); doc.saveGraphicsState();
@@ -118,174 +163,199 @@ function drawCheck(doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0
doc.setTextColor(0); doc.setTextColor(0);
} }
// ── Top row ────────────────────────────────────────────────────────────── // ── Check number (top-right) ──
doc.setFont("helvetica", "bold"); if (!hid("check_number")) {
doc.setFontSize(12); doc.setFont("helvetica", "bold");
doc.setTextColor(0); doc.setFontSize(12);
doc.text(String(c.checkNumber), x(RIGHT), y(0.18), { align: "right" }); 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"); // ── Company / return address ──
doc.setFontSize(8.5); if (!hid("company")) {
doc.text(c.companyName, x(ML), y(0.18)); const dx = ax("company"), dy = ay("company");
doc.setFont("helvetica", "bold");
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5);
doc.setFontSize(7.5); doc.setTextColor(0);
const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean); doc.text(c.companyName, x(ML + dx), y(0.18 + dy));
companyAddrLines.slice(0, 4).forEach((line, i) => { doc.setFont("helvetica", "normal");
doc.text(line, x(ML), y(0.32 + i * 0.135)); 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; const bankX = ML + CW * 0.52;
doc.setFont("helvetica", "bold"); if (!hid("bank")) {
doc.setFontSize(9); const dx = ax("bank"), dy = ay("bank");
doc.text(c.bankName ?? "", x(bankX), y(0.18)); doc.setFont("helvetica", "bold");
doc.setFont("helvetica", "normal"); doc.setFontSize(9);
doc.setFontSize(7.5); doc.setTextColor(0);
if (c.bankAddress) doc.text(c.bankAddress, x(bankX), y(0.32)); 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 ──────────────────────────────────────────────────────────── // ── Date + date line ──
const dateLineY = y(0.78); if (!hid("date")) {
doc.setFont("helvetica", "normal"); const dx = ax("date");
doc.setFontSize(9); const dateLineY = y(0.78 + ay("date"));
doc.text(c.date, x(RIGHT), dateLineY - 0.04, { align: "right" }); doc.setFont("helvetica", "normal");
hline(doc, x(ML + CW * 0.62), dateLineY, x(RIGHT)); 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 ────────────────────────────────────────────────── // ── Pay to the order of ──
const payY = y(1.10);
const payeeX = ML + 0.78; 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"); // ── Numeric amount box ──
doc.setFontSize(7); if (!hid("amount_box")) {
doc.text("PAY TO THE", x(ML), payY - 0.10); const dx = ax("amount_box");
doc.text("ORDER OF", x(ML), payY + 0.03); 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"); // ── Written amount ──
doc.setFontSize(10); if (!hid("amount_words")) {
doc.text(c.payee, x(payeeX), payY); const dx = ax("amount_words");
hline(doc, x(payeeX - 0.02), payY + 0.06, x(RIGHT - 1.55)); 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 // ── Envelope address block ──
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)
const addrLines = c.payeeAddress const addrLines = c.payeeAddress
? c.payeeAddress.split(/\n/).map(s => s.trim()).filter(Boolean) ? 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; const addrIndentX = ML + 0.35;
if (!hid("address_block")) {
// Payee name bold (envelope window — first line) const dx = ax("address_block");
doc.setFont("helvetica", "bold"); const addrBlockY = y(1.68 + ay("address_block"));
doc.setFontSize(9); doc.setFont("helvetica", "bold");
doc.text(c.payee, x(addrIndentX), addrBlockY); doc.setFontSize(9);
// "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);
});
doc.setTextColor(0); 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 ──────── // ── Memo + signature (flow below the address block) ──
const addrEndY = addrBlockY + 0.155 + Math.max(addrLines.length, 0) * 0.145; const addrEndDy = 1.68 + 0.155 + Math.max(addrLines.length, 0) * 0.145;
const bottomLineY = addrEndY + 0.30; // generous gap, no grey line between const bottomDy = addrEndDy + 0.30;
const sigLabelX = PW / 2 + 0.2; 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);
const sigCenterX = sigLabelX + (RIGHT - sigLabelX) / 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 (!hid("memo")) {
if (c.printSignature && c.signatureDataUrl) { const dx = ax("memo");
try { const bottomLineY = y(bottomDy + ay("memo"));
const props = doc.getImageProperties(c.signatureDataUrl); doc.setFont("helvetica", "normal");
const maxW = RIGHT - sigLabelX - 0.1; doc.setFontSize(8);
const maxH = 0.42; doc.setTextColor(0);
const ratio = props.width / props.height; doc.text("MEMO", x(ML + dx), bottomLineY);
let sigW = maxW; hline(doc, x(ML + 0.5 + dx), bottomLineY, x(ML + 2.8 + dx));
let sigH = sigW / ratio; if (c.memo) {
if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; } doc.setFontSize(7.5);
const sigX = x(sigCenterX - sigW / 2); doc.text(c.memo, x(ML + 0.55 + dx), bottomLineY - 0.04);
const sigY = bottomLineY - sigH; }
doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH);
} catch { /* silent */ }
} }
// Signature LINE if (!hid("signature")) {
hline(doc, x(sigLabelX), bottomLineY, x(RIGHT)); const dx = ax("signature");
const bottomLineY = y(bottomDy + ay("signature"));
// ── MICR line (X offset + separate MICR Y fine-tune) ───────────────────── // Signature image — rendered full size above the line (bottom edge sits ON
const micrY = y(CHECK_H - 0.22) + micrOy; // the line). With the label moved below the line, it can use the full height.
const micr = buildMicr( if (c.printSignature && c.signatureDataUrl) {
(c.routingNumber ?? "").replace(/\D/g, ""), try {
(c.accountNumber ?? "").replace(/\D/g, ""), const props = doc.getImageProperties(c.signatureDataUrl);
c.checkNumber const maxW = RIGHT - sigLabelX - 0.1;
); const maxH = 0.65;
const ratio = props.width / props.height;
if (micr) { let sigW = maxW;
ensureMicrFont(doc); let sigH = sigW / ratio;
doc.setFontSize(11); if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; }
doc.setTextColor(0); const sigX = x(sigCenterX + dx - sigW / 2);
doc.text(micr, x(PW / 2), micrY, { align: "center" }); const sigY = bottomLineY - sigH;
doc.setFont("helvetica", "normal"); doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH);
} else { } catch { /* silent */ }
}
// Signature line
hline(doc, x(sigLabelX + dx), bottomLineY, x(RIGHT + dx));
// "AUTHORIZED SIGNATURE" label below the line
doc.setFont("helvetica", "normal"); doc.setFont("helvetica", "normal");
doc.setFontSize(7); doc.setFontSize(7);
doc.setTextColor(160); doc.setTextColor(80);
doc.text("[ Configure routing & account numbers in Settings → Check Setup ]", x(PW / 2), micrY, { align: "center" }); doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY + 0.13, { align: "center" });
doc.setTextColor(0); 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 ───────────────────────────────────────────────────────────── // ── Voucher stub ─────────────────────────────────────────────────────────────
@@ -351,19 +421,22 @@ export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions):
const ox = opts.offsetX ?? 0; const ox = opts.offsetX ?? 0;
const oy = opts.offsetY ?? 0; const oy = opts.offsetY ?? 0;
const micrOy = opts.micrOffsetY ?? 0; const micrOy = opts.micrOffsetY ?? 0;
const g1 = opts.micrGap1 ?? 1;
const g2 = opts.micrGap2 ?? 1;
const fp = opts.fieldPositions || {};
checks.forEach((c, idx) => { checks.forEach((c, idx) => {
if (idx > 0) doc.addPage(); if (idx > 0) doc.addPage();
if (opts.style === "voucher") { 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 + oy, c, ox);
drawStub(doc, SECTION_H * 2 + oy, c, ox); drawStub(doc, SECTION_H * 2 + oy, c, ox);
} else { } else {
let posY = 0; let posY = 0;
if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2; if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2;
if (opts.position === "bottom") posY = PH - CHECK_H; 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);
} }
}); });
+7 -3
View File
@@ -109,6 +109,7 @@ export async function generateDashboardPdf(opts: {
logoUrl?: string | null; logoUrl?: string | null;
currency?: string; currency?: string;
data: DashboardPdfData; data: DashboardPdfData;
rangeLabel?: string;
}) { }) {
const { companyName, data } = opts; const { companyName, data } = opts;
const currency = opts.currency || "USD"; const currency = opts.currency || "USD";
@@ -120,8 +121,11 @@ export async function generateDashboardPdf(opts: {
const logo = await loadBrandedLogo(opts.logoUrl); const logo = await loadBrandedLogo(opts.logoUrl);
let y = drawBrandedHeader(doc, { let y = drawBrandedHeader(doc, {
logo, logo,
title: "Accounting Dashboard", title: "Financial Overview",
metaLines: [{ label: "Properties:", value: companyName }], metaLines: [
{ label: "Properties:", value: companyName },
...(opts.rangeLabel ? [{ label: "Period:", value: opts.rangeLabel }] : []),
],
}); });
// ── Summary cards ── // ── Summary cards ──
@@ -194,7 +198,7 @@ export async function generateDashboardPdf(opts: {
t.description ?? "", t.description ?? "",
t.category ?? "—", t.category ?? "—",
t.reference ?? "—", 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 }, styles: { font: "helvetica", fontSize: 8, textColor: TEXT, lineColor: [225, 228, 232], lineWidth: 0.1 },
headStyles: { fillColor: [237, 239, 242], textColor: TEXT, fontStyle: "bold" }, headStyles: { fillColor: [237, 239, 242], textColor: TEXT, fontStyle: "bold" },
+3 -1
View File
@@ -9,6 +9,8 @@ export type StructuredRow = {
code?: string; code?: string;
amount?: number; amount?: number;
compare?: number; compare?: number;
/** GL account id for per-account rows — enables drill-down from on-screen reports. */
accountId?: string;
}; };
export type StructuredReport = { 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); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.setTextColor(...txt);
const msg = ok const msg = ok
? "Balance Sheet is balanced" ? "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); doc.text(msg, ML + 8, y + 14);
y += 28; y += 28;
} }
+10 -4
View File
@@ -93,6 +93,10 @@ export interface CheckLayout {
field_positions?: Partial<Record<CheckFieldKey, CheckFieldPosition>> | null; field_positions?: Partial<Record<CheckFieldKey, CheckFieldPosition>> | null;
/** New: optional logo image URL printed on the check. */ /** New: optional logo image URL printed on the check. */
logo_url?: string | null; 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"); 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 r = (routing || "").replace(/\D/g, "");
const a = (account || "").replace(/\D/g, ""); const a = (account || "").replace(/\D/g, "");
const c = (checkNumber || "").replace(/\D/g, ""); const c = (checkNumber || "").replace(/\D/g, "");
// Pad check number with leading zeros to 9 digits // Pad check number with leading zeros to 9 digits
const paddedCheck = c.padStart(9, "0"); const paddedCheck = c.padStart(9, "0");
// Order: check number (on-us), space, transit/routing, space, account const g1 = " ".repeat(Math.max(0, Math.round(gap1)));
return `${MICR_ON_US}${paddedCheck}${MICR_ON_US} ${MICR_TRANSIT}${r}${MICR_TRANSIT} ${a}${MICR_ON_US}`; 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 { export function amountToWords(amount: number): string {
@@ -462,7 +468,7 @@ function drawCheckSection(
if (includeMicr && c.bank_routing_number && c.bank_account_number) { if (includeMicr && c.bank_routing_number && c.bank_account_number) {
const p = px("micr"); const p = px("micr");
if (p.visible !== false) { 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.setFont("MICRCHECK", "normal").setFontSize(p.font_size || 12);
doc.text(micr, p.X, p.Y); doc.text(micr, p.X, p.Y);
} }