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:
2026-06-12 22:40:58 -04:00
parent b4014f378c
commit b9235f644f
7 changed files with 165 additions and 46 deletions
+39 -4
View File
@@ -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>
<div className="flex flex-wrap items-end gap-4 mt-2">
<Input <Input
placeholder="Search description, category, reference…" placeholder="Search description, category, reference…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="max-w-sm mt-2" 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>}
+2 -7
View File
@@ -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" });
+11 -10
View File
@@ -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.