mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: period presets (Month/YTD/Prev-Year/Custom) + logo-free reports
- Shared PeriodPicker (default This Month); wired into General Ledger report, Journal Entries page, and bank registers (Banking shows a Balance forward row carrying the running balance at period start) - Financial reports are now logo-free: drawBrandedHeader/reportPdf skip the logo block; ReportSheet drops the on-screen logo - reportPdf: appendStructuredReportPdf + skipFooter scaffolding for upcoming report batches (not yet wired) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Plus, Trash2, Pencil, ArrowLeftRight, Printer, Link2, RefreshCw, Unlink, FileUp, Download, Loader2 } from "lucide-react";
|
import { Plus, Trash2, Pencil, ArrowLeftRight, Printer, Link2, RefreshCw, Unlink, FileUp, Download, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { money, fmtDate } from "./lib/format";
|
import { money, fmtDate } from "./lib/format";
|
||||||
|
import { PeriodPicker, periodRange, type PeriodPreset } from "./components/PeriodPicker";
|
||||||
import { generateCheckPDF } from "./lib/checkPdf";
|
import { generateCheckPDF } from "./lib/checkPdf";
|
||||||
import { parseCsv, pick, parseDateStr } from "./lib/csv";
|
import { parseCsv, pick, parseDateStr } from "./lib/csv";
|
||||||
import { usePlaidLink } from "react-plaid-link";
|
import { usePlaidLink } from "react-plaid-link";
|
||||||
@@ -129,6 +130,9 @@ export default function AccountingBankingPage() {
|
|||||||
const [acctDialog, setAcctDialog] = useState(false);
|
const [acctDialog, setAcctDialog] = useState(false);
|
||||||
const [acctForm, setAcctForm] = useState({ name: "", code: "", type: "asset" as const, is_bank: true });
|
const [acctForm, setAcctForm] = useState({ name: "", code: "", type: "asset" as const, is_bank: true });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("month");
|
||||||
|
const [periodFrom, setPeriodFrom] = useState(() => periodRange("month").from);
|
||||||
|
const [periodTo, setPeriodTo] = useState(() => periodRange("month").to);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<{ inserted: number; skipped: number } | null>(null);
|
const [importResult, setImportResult] = useState<{ inserted: number; skipped: number } | null>(null);
|
||||||
@@ -208,16 +212,31 @@ export default function AccountingBankingPage() {
|
|||||||
});
|
});
|
||||||
}, [txs]);
|
}, [txs]);
|
||||||
|
|
||||||
|
// Period view: running balances accumulate from inception (computed above),
|
||||||
|
// but only rows inside the period are shown; everything earlier rolls into
|
||||||
|
// the balance-forward figure.
|
||||||
|
const { periodRegister, balanceForward } = useMemo(() => {
|
||||||
|
let fwd = 0;
|
||||||
|
const rows: any[] = [];
|
||||||
|
for (const r of register) {
|
||||||
|
const d = String(r.date ?? "").slice(0, 10);
|
||||||
|
if (d < periodFrom) { fwd = r.running; continue; }
|
||||||
|
if (d > periodTo) continue;
|
||||||
|
rows.push(r);
|
||||||
|
}
|
||||||
|
return { periodRegister: rows, balanceForward: fwd };
|
||||||
|
}, [register, periodFrom, periodTo]);
|
||||||
|
|
||||||
const filteredRegister = useMemo(() => {
|
const filteredRegister = useMemo(() => {
|
||||||
if (!search.trim()) return register;
|
if (!search.trim()) return periodRegister;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return register.filter(
|
return periodRegister.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.description?.toLowerCase().includes(q) ||
|
r.description?.toLowerCase().includes(q) ||
|
||||||
r.category?.toLowerCase().includes(q) ||
|
r.category?.toLowerCase().includes(q) ||
|
||||||
r.reference?.toLowerCase().includes(q)
|
r.reference?.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}, [register, search]);
|
}, [periodRegister, search]);
|
||||||
|
|
||||||
const computedBalance = register.length > 0 ? register[register.length - 1].running : 0;
|
const computedBalance = register.length > 0 ? register[register.length - 1].running : 0;
|
||||||
const activeAccount = (accounts as any[]).find((a) => a.id === activeAccountId);
|
const activeAccount = (accounts as any[]).find((a) => a.id === activeAccountId);
|
||||||
@@ -731,12 +750,18 @@ export default function AccountingBankingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<div className="flex flex-wrap items-end gap-4 mt-2">
|
||||||
placeholder="Search description, category, reference…"
|
<Input
|
||||||
value={search}
|
placeholder="Search description, category, reference…"
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
className="max-w-sm mt-2"
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<PeriodPicker
|
||||||
|
preset={periodPreset} from={periodFrom} to={periodTo}
|
||||||
|
onChange={(n) => { setPeriodPreset(n.preset); setPeriodFrom(n.from); setPeriodTo(n.to); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
@@ -797,6 +822,16 @@ export default function AccountingBankingPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{!search.trim() && (
|
||||||
|
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||||
|
<TableCell className="px-2" />
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{fmtDate(periodFrom)}</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
<TableCell className="text-sm font-medium text-muted-foreground" colSpan={4}>Balance forward</TableCell>
|
||||||
|
<TableCell className="text-right text-sm font-medium tabular-nums">{money(balanceForward, cur)}</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{filteredRegister.map((row) => (
|
{filteredRegister.map((row) => (
|
||||||
<TableRow key={row.id} className={`hover:bg-muted/40 ${selected.has(row.id) ? "bg-primary/5" : ""}`}>
|
<TableRow key={row.id} className={`hover:bg-muted/40 ${selected.has(row.id) ? "bg-primary/5" : ""}`}>
|
||||||
<TableCell className="px-2">
|
<TableCell className="px-2">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
|
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
|
||||||
|
import { PeriodPicker, periodRange, type PeriodPreset } from "./components/PeriodPicker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -40,6 +41,11 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Period filter — display only (all entries stay loaded for detail/edit)
|
||||||
|
const [preset, setPreset] = useState<PeriodPreset>("month");
|
||||||
|
const [periodFrom, setPeriodFrom] = useState(() => periodRange("month").from);
|
||||||
|
const [periodTo, setPeriodTo] = useState(() => periodRange("month").to);
|
||||||
|
|
||||||
const { data: entries = [], error: entriesError } = useQuery({
|
const { data: entries = [], error: entriesError } = useQuery({
|
||||||
queryKey: ["journal-entries", cid],
|
queryKey: ["journal-entries", cid],
|
||||||
enabled: !!cid,
|
enabled: !!cid,
|
||||||
@@ -53,6 +59,14 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
(await accounting.from("accounts").select("*").eq("company_id", cid).eq("is_archived", false).order("code")).data ?? [],
|
(await accounting.from("accounts").select("*").eq("company_id", cid).eq("is_archived", false).order("code")).data ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const periodEntries = useMemo(
|
||||||
|
() => (entries as any[]).filter((e) => {
|
||||||
|
const d = String(e.date ?? "").slice(0, 10);
|
||||||
|
return d >= periodFrom && d <= periodTo;
|
||||||
|
}),
|
||||||
|
[entries, periodFrom, periodTo],
|
||||||
|
);
|
||||||
|
|
||||||
const detailEntry = useMemo(
|
const detailEntry = useMemo(
|
||||||
() => (detailId ? (entries as any[]).find((e) => e.id === detailId) : null),
|
() => (detailId ? (entries as any[]).find((e) => e.id === detailId) : null),
|
||||||
[entries, detailId]
|
[entries, detailId]
|
||||||
@@ -199,6 +213,15 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<PeriodPicker
|
||||||
|
preset={preset} from={periodFrom} to={periodTo}
|
||||||
|
onChange={(n) => { setPreset(n.preset); setPeriodFrom(n.from); setPeriodTo(n.to); }}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -213,7 +236,7 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(entries as any[]).map((e) => {
|
{periodEntries.map((e) => {
|
||||||
const debits = (e.journal_entry_lines ?? []).reduce((s: number, l: any) => s + Number(l.debit), 0);
|
const debits = (e.journal_entry_lines ?? []).reduce((s: number, l: any) => s + Number(l.debit), 0);
|
||||||
const credits = (e.journal_entry_lines ?? []).reduce((s: number, l: any) => s + Number(l.credit), 0);
|
const credits = (e.journal_entry_lines ?? []).reduce((s: number, l: any) => s + Number(l.credit), 0);
|
||||||
return (
|
return (
|
||||||
@@ -248,10 +271,10 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{entries.length === 0 && (
|
{periodEntries.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
|
||||||
No journal entries yet.
|
{entries.length === 0 ? "No journal entries yet." : "No journal entries in this period."}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import jsPDF from "jspdf";
|
import jsPDF from "jspdf";
|
||||||
import autoTable from "jspdf-autotable";
|
import autoTable from "jspdf-autotable";
|
||||||
import { ReportSheet } from "./ReportSheet";
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { PeriodPicker, periodRange, type PeriodPreset } from "./PeriodPicker";
|
||||||
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
|
||||||
const TEAL: [number, number, number] = [0, 137, 123];
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
@@ -38,8 +39,9 @@ type Txn = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAccountId }: { companyId: string; companyName: string; logoUrl?: string | null; initialAccountId?: 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 [preset, setPreset] = useState<PeriodPreset>("month");
|
||||||
const [to, setTo] = useState(today());
|
const [from, setFrom] = useState(() => periodRange("month").from);
|
||||||
|
const [to, setTo] = useState(() => periodRange("month").to);
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(initialAccountId ? [initialAccountId] : []);
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(initialAccountId ? [initialAccountId] : []);
|
||||||
|
|
||||||
// When opened via a report drill-down, focus the chosen account.
|
// When opened via a report drill-down, focus the chosen account.
|
||||||
@@ -285,14 +287,10 @@ export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAc
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||||
<div>
|
<PeriodPicker
|
||||||
<Label className="text-xs text-muted-foreground">From</Label>
|
preset={preset} from={from} to={to}
|
||||||
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="w-40 mt-1" />
|
onChange={(n) => { setPreset(n.preset); setFrom(n.from); setTo(n.to); }}
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">To</Label>
|
|
||||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="w-40 mt-1" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">Accounts</Label>
|
<Label className="text-xs text-muted-foreground">Accounts</Label>
|
||||||
<Popover>
|
<Popover>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
export type PeriodPreset = "month" | "ytd" | "prev-year" | "custom";
|
||||||
|
|
||||||
|
const TZ_ET = "America/New_York";
|
||||||
|
const etToday = () => new Date().toLocaleDateString("en-CA", { timeZone: TZ_ET }); // YYYY-MM-DD
|
||||||
|
|
||||||
|
/** Date range for a preset (ET). Month = current calendar month to date. */
|
||||||
|
export function periodRange(preset: Exclude<PeriodPreset, "custom">): { from: string; to: string } {
|
||||||
|
const today = etToday();
|
||||||
|
const [y, m] = today.split("-").map(Number);
|
||||||
|
if (preset === "month") return { from: `${today.slice(0, 7)}-01`, to: today };
|
||||||
|
if (preset === "ytd") return { from: `${y}-01-01`, to: today };
|
||||||
|
// prev-year
|
||||||
|
return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERIOD_LABELS: Record<PeriodPreset, string> = {
|
||||||
|
month: "This Month",
|
||||||
|
ytd: "Year to Date",
|
||||||
|
"prev-year": "Previous Year",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared period control for ledgers/registers: Month (default) / YTD /
|
||||||
|
* Previous Year / Custom. The owner holds preset+from+to state; on preset
|
||||||
|
* change the range is recomputed and pushed up.
|
||||||
|
*/
|
||||||
|
export function PeriodPicker({
|
||||||
|
preset, from, to, onChange, className,
|
||||||
|
}: {
|
||||||
|
preset: PeriodPreset;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
onChange: (next: { preset: PeriodPreset; from: string; to: string }) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const setPreset = (p: PeriodPreset) => {
|
||||||
|
if (p === "custom") onChange({ preset: p, from, to });
|
||||||
|
else onChange({ preset: p, ...periodRange(p) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-end gap-3 ${className ?? ""}`}>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Period</Label>
|
||||||
|
<Select value={preset} onValueChange={(v) => setPreset(v as PeriodPreset)}>
|
||||||
|
<SelectTrigger className="w-40 mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(PERIOD_LABELS) as PeriodPreset[]).map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>{PERIOD_LABELS[p]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{preset === "custom" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">From</Label>
|
||||||
|
<Input type="date" value={from} onChange={(e) => e.target.value && onChange({ preset, from: e.target.value, to })} className="w-40 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">To</Label>
|
||||||
|
<Input type="date" value={to} onChange={(e) => e.target.value && onChange({ preset, to: e.target.value, from })} className="w-40 mt-1" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import acmLogoFull from "@/assets/acm-logo-full.png";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Branded on-screen "sheet" for every accounting report, mirroring the printed
|
* Branded on-screen "sheet" for every accounting report, mirroring the printed
|
||||||
@@ -27,12 +26,7 @@ export function ReportSheet({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-5">
|
||||||
<img
|
{/* Logos intentionally omitted — financial reports are logo-free. */}
|
||||||
src={logoUrl || (acmLogoFull as unknown as string)}
|
|
||||||
alt=""
|
|
||||||
className="absolute left-0 top-0 h-10 w-auto object-contain"
|
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||||
|
|||||||
@@ -43,13 +43,8 @@ export function drawBrandedHeader(doc: jsPDF, opts: BrandedHeaderOpts): number {
|
|||||||
const W = doc.internal.pageSize.getWidth();
|
const W = doc.internal.pageSize.getWidth();
|
||||||
const ML = 40;
|
const ML = 40;
|
||||||
|
|
||||||
if (opts.logo) {
|
// Logos intentionally NOT drawn — financial reports are logo-free per user
|
||||||
try {
|
// preference. The `logo` opt is kept for caller compatibility.
|
||||||
const h = 46;
|
|
||||||
const w = Math.min(170, h * (opts.logo.w / opts.logo.h));
|
|
||||||
doc.addImage(opts.logo.dataURL, "PNG", ML, 28, w, h, undefined, "FAST");
|
|
||||||
} catch { /* ignore logo failures */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(18); doc.setTextColor(20);
|
doc.setFont("helvetica", "bold"); doc.setFontSize(18); doc.setTextColor(20);
|
||||||
doc.text(opts.title, W / 2, 52, { align: "center" });
|
doc.text(opts.title, W / 2, 52, { align: "center" });
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ export type RenderOpts = {
|
|||||||
showZero: boolean;
|
showZero: boolean;
|
||||||
/** Optional metadata lines for the header block (bold label + value). */
|
/** Optional metadata lines for the header block (bold label + value). */
|
||||||
meta?: { label: string; value: string }[];
|
meta?: { label: string; value: string }[];
|
||||||
/** Preloaded logo (dataURL + dimensions) for the branded header. */
|
/** Preloaded logo (dataURL + dimensions) — kept for compatibility; logos are no longer drawn. */
|
||||||
logo?: { dataURL: string; w: number; h: number } | null;
|
logo?: { dataURL: string; w: number; h: number } | null;
|
||||||
|
/** Batch mode: skip the per-report footer (the batch stamps one global footer). */
|
||||||
|
skipFooter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RGB = [number, number, number];
|
type RGB = [number, number, number];
|
||||||
@@ -93,15 +95,9 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
|
|||||||
y += h;
|
y += h;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full header (page 1): title + metadata block + column header
|
// Full header (page 1): title + metadata block + column header.
|
||||||
|
// (Logos intentionally not drawn — financial reports are logo-free.)
|
||||||
const drawFullHeader = () => {
|
const drawFullHeader = () => {
|
||||||
// Branded logo (top-left) + centered title — matches the main reports.
|
|
||||||
if (opts.logo) {
|
|
||||||
try {
|
|
||||||
const lh = 40; const lw = Math.min(150, lh * (opts.logo.w / opts.logo.h));
|
|
||||||
doc.addImage(opts.logo.dataURL, "PNG", ML, 26, lw, lh, undefined, "FAST");
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.setTextColor(...TEXT);
|
doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.setTextColor(...TEXT);
|
||||||
doc.text(report.title, W / 2, 48, { align: "center" });
|
doc.text(report.title, W / 2, 48, { align: "center" });
|
||||||
y = 82;
|
y = 82;
|
||||||
@@ -257,7 +253,7 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
|
|||||||
y += 28;
|
y += 28;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawFooter();
|
if (!opts.skipFooter) drawFooter();
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +264,11 @@ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsP
|
|||||||
return buildReport(doc, report, opts);
|
return buildReport(doc, report, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append a structured financial report to an EXISTING doc, starting on its current page (batch packets). */
|
||||||
|
export function appendStructuredReportPdf(doc: jsPDF, report: StructuredReport, opts: RenderOpts): jsPDF {
|
||||||
|
return buildReport(doc, report, opts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a financial report PDF that opens with the shared branded cover page
|
* Render a financial report PDF that opens with the shared branded cover page
|
||||||
* (same look as the general Report Generator), followed by the report body.
|
* (same look as the general Report Generator), followed by the report body.
|
||||||
|
|||||||
Reference in New Issue
Block a user