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 { toast } from "sonner";
import { money, fmtDate } from "./lib/format";
import { PeriodPicker, periodRange, type PeriodPreset } from "./components/PeriodPicker";
import { generateCheckPDF } from "./lib/checkPdf";
import { parseCsv, pick, parseDateStr } from "./lib/csv";
import { usePlaidLink } from "react-plaid-link";
@@ -129,6 +130,9 @@ export default function AccountingBankingPage() {
const [acctDialog, setAcctDialog] = useState(false);
const [acctForm, setAcctForm] = useState({ name: "", code: "", type: "asset" as const, is_bank: true });
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 [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{ inserted: number; skipped: number } | null>(null);
@@ -208,16 +212,31 @@ export default function AccountingBankingPage() {
});
}, [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(() => {
if (!search.trim()) return register;
if (!search.trim()) return periodRegister;
const q = search.toLowerCase();
return register.filter(
return periodRegister.filter(
(r) =>
r.description?.toLowerCase().includes(q) ||
r.category?.toLowerCase().includes(q) ||
r.reference?.toLowerCase().includes(q)
);
}, [register, search]);
}, [periodRegister, search]);
const computedBalance = register.length > 0 ? register[register.length - 1].running : 0;
const activeAccount = (accounts as any[]).find((a) => a.id === activeAccountId);
@@ -731,12 +750,18 @@ export default function AccountingBankingPage() {
</Button>
</div>
</div>
<div className="flex flex-wrap items-end gap-4 mt-2">
<Input
placeholder="Search description, category, reference…"
value={search}
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>
<CardContent className="p-0">
{selected.size > 0 && (
@@ -797,6 +822,16 @@ export default function AccountingBankingPage() {
</TableRow>
</TableHeader>
<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) => (
<TableRow key={row.id} className={`hover:bg-muted/40 ${selected.has(row.id) ? "bg-primary/5" : ""}`}>
<TableCell className="px-2">
@@ -3,6 +3,7 @@ import { useMemo, useState } from "react";
import { accounting } from "@/lib/accountingClient";
import { useCompanyId } from "./lib/useCompanyId";
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
import { PeriodPicker, periodRange, type PeriodPreset } from "./components/PeriodPicker";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -40,6 +41,11 @@ export default function AccountingJournalEntriesPage() {
const [saving, setSaving] = useState(false);
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({
queryKey: ["journal-entries", 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 ?? [],
});
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(
() => (detailId ? (entries as any[]).find((e) => e.id === detailId) : null),
[entries, detailId]
@@ -199,6 +213,15 @@ export default function AccountingJournalEntriesPage() {
</Button>
</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>
<CardContent className="p-0">
<Table>
@@ -213,7 +236,7 @@ export default function AccountingJournalEntriesPage() {
</TableRow>
</TableHeader>
<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 credits = (e.journal_entry_lines ?? []).reduce((s: number, l: any) => s + Number(l.credit), 0);
return (
@@ -248,10 +271,10 @@ export default function AccountingJournalEntriesPage() {
</TableRow>
);
})}
{entries.length === 0 && (
{periodEntries.length === 0 && (
<TableRow>
<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>
</TableRow>
)}
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { ReportSheet } from "./ReportSheet";
import { PeriodPicker, periodRange, type PeriodPreset } from "./PeriodPicker";
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
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 }) {
const [from, setFrom] = useState(startOfYear());
const [to, setTo] = useState(today());
const [preset, setPreset] = useState<PeriodPreset>("month");
const [from, setFrom] = useState(() => periodRange("month").from);
const [to, setTo] = useState(() => periodRange("month").to);
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(initialAccountId ? [initialAccountId] : []);
// 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">
<Card>
<CardContent className="flex flex-wrap items-end gap-4 py-4">
<div>
<Label className="text-xs text-muted-foreground">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="w-40 mt-1" />
</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>
<PeriodPicker
preset={preset} from={from} to={to}
onChange={(n) => { setPreset(n.preset); setFrom(n.from); setTo(n.to); }}
/>
<div>
<Label className="text-xs text-muted-foreground">Accounts</Label>
<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 acmLogoFull from "@/assets/acm-logo-full.png";
/**
* Branded on-screen "sheet" for every accounting report, mirroring the printed
@@ -27,12 +26,7 @@ export function ReportSheet({
<Card>
<CardContent className="p-6">
<div className="relative mb-5">
<img
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"; }}
/>
{/* Logos intentionally omitted — financial reports are logo-free. */}
<div className="text-center">
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
{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 ML = 40;
if (opts.logo) {
try {
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 */ }
}
// Logos intentionally NOT drawn — financial reports are logo-free per user
// preference. The `logo` opt is kept for caller compatibility.
doc.setFont("helvetica", "bold"); doc.setFontSize(18); doc.setTextColor(20);
doc.text(opts.title, W / 2, 52, { align: "center" });
+11 -10
View File
@@ -31,8 +31,10 @@ export type RenderOpts = {
showZero: boolean;
/** Optional metadata lines for the header block (bold label + value). */
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;
/** Batch mode: skip the per-report footer (the batch stamps one global footer). */
skipFooter?: boolean;
};
type RGB = [number, number, number];
@@ -93,15 +95,9 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
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 = () => {
// 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.text(report.title, W / 2, 48, { align: "center" });
y = 82;
@@ -257,7 +253,7 @@ function buildReport(doc: jsPDF, report: StructuredReport, opts: RenderOpts): js
y += 28;
}
drawFooter();
if (!opts.skipFooter) drawFooter();
return doc;
}
@@ -268,6 +264,11 @@ export function renderReportPdf(report: StructuredReport, opts: RenderOpts): jsP
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
* (same look as the general Report Generator), followed by the report body.