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 { 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>
|
||||
<Input
|
||||
placeholder="Search description, category, reference…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm mt-2"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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>}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user