mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user