mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Accounting: prior-period reconcile items, void txns, unified report filters, accrual-only
1. Reconciliation now shows ALL outstanding (unreconciled) items on/before the statement date, including ones from prior periods — removed the prior-recon date floor. Finalized items still drop off (they carry a reconciliation_id). 2. Void transactions in Banking and Reconciliation. New accounting.transactions .voided flag (+ voided_at/by); voided rows stay visible (strikethrough + VOID badge) but are excluded from the running balance, register totals, cached account balance, and reconciliation. post_transaction_gl reverses the GL for gl_managed companies; un-void supported from Banking. 3. Unified report filters: the single Period bar on the Reports page now drives every report. General Ledger, Trial Balance, AR Aging (Property), Pre-Paid Homeowners, Cash Disbursement, and Reserve Fund no longer have their own date pickers — they consume the shared from/to (range) or to (as-of). 4. Accrual only: removed the cash-basis toggle from Trial Balance and General Ledger (the data was always accrual GL anyway; the cash label was misleading). All income/expense reports recognize on billed/issue date. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGr
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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, Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { money, fmtDate } from "./lib/format";
|
||||
import { PeriodPicker, periodRange, type PeriodPreset } from "./components/PeriodPicker";
|
||||
@@ -206,8 +206,11 @@ export default function AccountingBankingPage() {
|
||||
let bal = 0;
|
||||
return (txs as any[]).map((tx) => {
|
||||
const amt = Number(tx.amount ?? 0);
|
||||
if (tx.type === "credit") bal += amt;
|
||||
else bal -= amt;
|
||||
// Voided rows stay visible for audit but don't move the running balance.
|
||||
if (!tx.voided) {
|
||||
if (tx.type === "credit") bal += amt;
|
||||
else bal -= amt;
|
||||
}
|
||||
return { ...tx, running: bal };
|
||||
});
|
||||
}, [txs]);
|
||||
@@ -482,6 +485,21 @@ export default function AccountingBankingPage() {
|
||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||
};
|
||||
|
||||
const toggleVoidTx = async (tx: any) => {
|
||||
if (tx.reconciliation_id) return toast.error("Reconciled transactions can't be voided");
|
||||
const next = !tx.voided;
|
||||
if (next && !confirm("Void this transaction? It stays in the register for audit but is removed from the balance and can't be reconciled.")) return;
|
||||
const patch = next
|
||||
? { voided: true, voided_at: new Date().toISOString(), voided_by: user?.id ?? null }
|
||||
: { voided: false, voided_at: null, voided_by: null };
|
||||
const q = accounting.from("transactions").update(patch);
|
||||
const { error } = tx.transfer_id ? await q.eq("transfer_id", tx.transfer_id) : await q.eq("id", tx.id);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success(next ? "Transaction voided" : "Void removed");
|
||||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["accounts", cid] });
|
||||
};
|
||||
|
||||
const openEdit = (tx: any) => {
|
||||
if (tx.reconciliation_id) return toast.error("Reconciled transactions can't be edited");
|
||||
setEditId(tx.id);
|
||||
@@ -653,8 +671,8 @@ export default function AccountingBankingPage() {
|
||||
? "Edit transaction"
|
||||
: (txForm.type === "debit" ? "New payment" : "New transaction");
|
||||
|
||||
const totalDebits = filteredRegister.reduce((s, r) => s + (r.type === "debit" ? Number(r.amount) : 0), 0);
|
||||
const totalCredits = filteredRegister.reduce((s, r) => s + (r.type === "credit" ? Number(r.amount) : 0), 0);
|
||||
const totalDebits = filteredRegister.reduce((s, r) => s + (!r.voided && r.type === "debit" ? Number(r.amount) : 0), 0);
|
||||
const totalCredits = filteredRegister.reduce((s, r) => s + (!r.voided && r.type === "credit" ? Number(r.amount) : 0), 0);
|
||||
|
||||
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>;
|
||||
@@ -849,14 +867,17 @@ export default function AccountingBankingPage() {
|
||||
{row.reconciliation_id && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1 bg-emerald-50 text-emerald-700 border-emerald-200">R</Badge>
|
||||
)}
|
||||
{row.description}
|
||||
{row.voided && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-1 bg-red-50 text-red-700 border-red-200">VOID</Badge>
|
||||
)}
|
||||
<span className={row.voided ? "line-through text-muted-foreground" : ""}>{row.description}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{row.category ?? "—"}</TableCell>
|
||||
<TableCell className="text-right text-sm text-red-600">
|
||||
<TableCell className={`text-right text-sm text-red-600 ${row.voided ? "line-through opacity-60" : ""}`}>
|
||||
{row.type === "debit" ? money(row.amount, cur) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-emerald-600">
|
||||
<TableCell className={`text-right text-sm text-emerald-600 ${row.voided ? "line-through opacity-60" : ""}`}>
|
||||
{row.type === "credit" ? money(row.amount, cur) : ""}
|
||||
</TableCell>
|
||||
<TableCell className={`text-right text-sm font-medium ${row.running < 0 ? "text-destructive" : ""}`}>
|
||||
@@ -864,9 +885,17 @@ export default function AccountingBankingPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(row)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!row.voided && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(row)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{!row.reconciliation_id && (
|
||||
<Button size="icon" variant="ghost" className={`h-7 w-7 ${row.voided ? "text-emerald-600 hover:text-emerald-700" : "text-amber-600 hover:text-amber-700"}`}
|
||||
title={row.voided ? "Remove void" : "Void transaction"} onClick={() => toggleVoidTx(row)}>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => deleteTx(row)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown, Plus, Ban } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { money, fmtDate } from "./lib/format";
|
||||
@@ -52,6 +52,7 @@ type Tx = {
|
||||
type: "debit" | "credit";
|
||||
cleared: boolean;
|
||||
reconciliation_id: string | null;
|
||||
voided?: boolean;
|
||||
};
|
||||
|
||||
export default function AccountingReconcileDetailPage() {
|
||||
@@ -139,15 +140,18 @@ export default function AccountingReconcileDetailPage() {
|
||||
queryKey: ["recon-txs", accountId, active?.statement_end_date, priorReconDate],
|
||||
enabled: !!accountId && !!active,
|
||||
queryFn: async () => {
|
||||
let q = accounting
|
||||
// Show every item not yet tied to a completed reconciliation, on/before the
|
||||
// statement date — INCLUDING outstanding items from prior periods. Items
|
||||
// that were finalized get a reconciliation_id and drop off; uncleared ones
|
||||
// carry forward until they clear. Voided items are excluded entirely.
|
||||
const { data } = await accounting
|
||||
.from("transactions")
|
||||
.select("id,date,description,reference,amount,type,cleared,reconciliation_id")
|
||||
.select("id,date,description,reference,amount,type,cleared,reconciliation_id,voided")
|
||||
.eq("account_id", accountId)
|
||||
.is("reconciliation_id", null)
|
||||
.lte("date", active!.statement_end_date);
|
||||
// Only this period: skip anything already covered by the last reconciliation.
|
||||
if (priorReconDate) q = q.gt("date", priorReconDate);
|
||||
const { data } = await q.order("date");
|
||||
.eq("voided", false)
|
||||
.lte("date", active!.statement_end_date)
|
||||
.order("date");
|
||||
return (data ?? []) as Tx[];
|
||||
},
|
||||
});
|
||||
@@ -378,6 +382,19 @@ export default function AccountingReconcileDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const voidTx = async (t: Tx) => {
|
||||
if (t.reconciliation_id) return toast.error("Reconciled transactions can't be voided");
|
||||
if (!confirm(`Void this ${t.type === "credit" ? "deposit" : "withdrawal"} of ${money(t.amount, cur)}? It will be removed from the register and this reconciliation.`)) return;
|
||||
const { error } = await accounting.from("transactions")
|
||||
.update({ voided: true, voided_at: new Date().toISOString(), voided_by: user?.id ?? null })
|
||||
.eq("id", t.id);
|
||||
if (error) return toast.error(error.message);
|
||||
setChecked((prev) => { const n = new Set(prev); n.delete(t.id); return n; });
|
||||
toast.success("Transaction voided");
|
||||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions", cid] });
|
||||
};
|
||||
|
||||
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 (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||
@@ -454,6 +471,7 @@ export default function AccountingReconcileDetailPage() {
|
||||
<SortHead col="reference" label="Ref #" sort={sort} onSort={toggleSort} />
|
||||
<SortHead col="deposit" label="Deposit" sort={sort} onSort={toggleSort} align="right" />
|
||||
<SortHead col="withdrawal" label="Withdrawal" sort={sort} onSort={toggleSort} align="right" />
|
||||
<TableHead className="w-10 text-right">Void</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -485,11 +503,17 @@ export default function AccountingReconcileDetailPage() {
|
||||
<TableCell className="text-right text-red-700">
|
||||
{t.type === "debit" ? money(t.amount, cur) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
title="Void this transaction" onClick={() => voidTx(t)}>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
No unreconciled transactions in this period.
|
||||
</TableCell></TableRow>
|
||||
)}
|
||||
|
||||
@@ -657,22 +657,22 @@ export default function AccountingReportsPage({ association }: { association?: {
|
||||
<IncomeStatementReport companyId={cid} companyName={associationName ?? "Company"} from={from} to={to} currency={cur} logoUrl={logoUrl} />
|
||||
)}
|
||||
{active === "trial-balance" && (
|
||||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
|
||||
)}
|
||||
{active === "general-ledger" && (
|
||||
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} />
|
||||
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} from={from} to={to} />
|
||||
)}
|
||||
{active === "reserve-fund" && (
|
||||
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
|
||||
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} to={to} />
|
||||
)}
|
||||
{active === "ar-aging-property" && (
|
||||
<ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||
<ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
|
||||
)}
|
||||
{active === "prepaid-homeowners" && (
|
||||
<PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||
<PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
|
||||
)}
|
||||
{active === "cash-disbursement" && (
|
||||
<CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||
<CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} from={from} to={to} />
|
||||
)}
|
||||
{active === "reconciliation" && (
|
||||
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -56,8 +56,9 @@ const dash = (n: number) => (n ? money(n) : "-");
|
||||
* collection status, summary and distribution. Payments and credits apply to
|
||||
* charges oldest-first (FIFO), so only genuinely open charge amounts age.
|
||||
*/
|
||||
export function ARAgingPropertyReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
export function ARAgingPropertyReport({ companyId, companyName, logoUrl, to: propTo }: { companyId: string; companyName: string; logoUrl?: string | null; to?: string }) {
|
||||
const [asOf, setAsOf] = useState(() => propTo ?? new Date().toISOString().slice(0, 10));
|
||||
useEffect(() => { if (propTo) setAsOf(propTo); }, [propTo]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["ar-aging-property", companyId, asOf],
|
||||
@@ -305,7 +306,7 @@ export function ARAgingPropertyReport({ companyId, companyName, logoUrl }: { com
|
||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">As of</Label>
|
||||
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
|
||||
<div className="mt-1 text-sm font-medium">{asOfLabel}</div>
|
||||
</div>
|
||||
{report && report.rows.length > 0 && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -48,9 +48,10 @@ const acctLabel = (a: GLAccount | undefined) => (a ? `${a.code ? a.code + " - "
|
||||
* platform-managed and Buildium-imported companies. Platform entries are
|
||||
* enriched with check #, vendor and bill info from the banking register.
|
||||
*/
|
||||
export function CashDisbursementReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||
const [from, setFrom] = useState(monthStart());
|
||||
const [to, setTo] = useState(today());
|
||||
export function CashDisbursementReport({ companyId, companyName, logoUrl, from: propFrom, to: propTo }: { companyId: string; companyName: string; logoUrl?: string | null; from?: string; to?: string }) {
|
||||
const [from, setFrom] = useState(propFrom ?? monthStart());
|
||||
const [to, setTo] = useState(propTo ?? today());
|
||||
useEffect(() => { if (propFrom) setFrom(propFrom); if (propTo) setTo(propTo); }, [propFrom, propTo]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["cash-disbursement", companyId, from, to],
|
||||
@@ -249,12 +250,8 @@ export function CashDisbursementReport({ companyId, companyName, logoUrl }: { co
|
||||
<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 || from)} className="w-44 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => setTo(e.target.value || to)} className="w-44 mt-1" />
|
||||
<Label className="text-xs text-muted-foreground">Period</Label>
|
||||
<div className="mt-1 text-sm font-medium">{fmtDate(from)} – {fmtDate(to)}</div>
|
||||
</div>
|
||||
{hasData && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
|
||||
@@ -38,10 +38,11 @@ type Txn = {
|
||||
debit: number; credit: number; balance: number; abnormal?: boolean;
|
||||
};
|
||||
|
||||
export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAccountId }: { companyId: string; companyName: string; logoUrl?: string | null; initialAccountId?: string | null }) {
|
||||
export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAccountId, from: propFrom, to: propTo }: { companyId: string; companyName: string; logoUrl?: string | null; initialAccountId?: string | null; from?: string; to?: string }) {
|
||||
const [preset, setPreset] = useState<PeriodPreset>("month");
|
||||
const [from, setFrom] = useState(() => periodRange("month").from);
|
||||
const [to, setTo] = useState(() => periodRange("month").to);
|
||||
const [from, setFrom] = useState(() => propFrom ?? periodRange("month").from);
|
||||
const [to, setTo] = useState(() => propTo ?? periodRange("month").to);
|
||||
useEffect(() => { if (propFrom) setFrom(propFrom); if (propTo) setTo(propTo); }, [propFrom, propTo]);
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(initialAccountId ? [initialAccountId] : []);
|
||||
|
||||
// When opened via a report drill-down, focus the chosen account.
|
||||
@@ -287,10 +288,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">
|
||||
<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">Period</Label>
|
||||
<div className="mt-1 text-sm font-medium">{fmtDate(from)} – {fmtDate(to)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Accounts</Label>
|
||||
<Popover>
|
||||
@@ -323,11 +324,7 @@ export function GeneralLedgerReport({ companyId, companyName, logoUrl, initialAc
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Basis</Label>
|
||||
<div className="flex items-center gap-2 h-9 mt-1">
|
||||
<span className={cn("text-sm", basis === "accrual" && "font-semibold")}>Accrual</span>
|
||||
<Switch checked={basis === "cash"} onCheckedChange={(v) => setBasis(v ? "cash" : "accrual")} />
|
||||
<span className={cn("text-sm", basis === "cash" && "font-semibold")}>Cash</span>
|
||||
</div>
|
||||
<div className="h-9 mt-1 flex items-center text-sm font-medium">Accrual</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Opening Row</Label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -22,8 +22,9 @@ type Row = { account: string; property: string; ownerName: string; credit: numbe
|
||||
* Buildium-style "Pre Paid Homeowners": every unit whose owner ledger nets to
|
||||
* a credit (payments exceed charges) as of the chosen date, with the credit amount.
|
||||
*/
|
||||
export function PrepaidHomeownersReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
export function PrepaidHomeownersReport({ companyId, companyName, logoUrl, to: propTo }: { companyId: string; companyName: string; logoUrl?: string | null; to?: string }) {
|
||||
const [asOf, setAsOf] = useState(() => propTo ?? new Date().toISOString().slice(0, 10));
|
||||
useEffect(() => { if (propTo) setAsOf(propTo); }, [propTo]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["prepaid-homeowners", companyId, asOf],
|
||||
@@ -126,7 +127,7 @@ export function PrepaidHomeownersReport({ companyId, companyName, logoUrl }: { c
|
||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">As of</Label>
|
||||
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
|
||||
<div className="mt-1 text-sm font-medium">{asOfLabel}</div>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -61,13 +61,16 @@ export function ReserveFundReport({
|
||||
companyName,
|
||||
fiscalYearStart = "01-01",
|
||||
logoUrl,
|
||||
to: propTo,
|
||||
}: {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
fiscalYearStart?: string;
|
||||
logoUrl?: string | null;
|
||||
to?: string;
|
||||
}) {
|
||||
const [asOfMonth, setAsOfMonth] = useState(() => new Date().toISOString().slice(0, 7));
|
||||
const [asOfMonth, setAsOfMonth] = useState(() => (propTo ?? new Date().toISOString().slice(0, 10)).slice(0, 7));
|
||||
useEffect(() => { if (propTo) setAsOfMonth(propTo.slice(0, 7)); }, [propTo]);
|
||||
|
||||
const asOf = monthEnd(asOfMonth);
|
||||
const monthStart = monthStartOf(asOfMonth);
|
||||
@@ -270,7 +273,7 @@ export function ReserveFundReport({
|
||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">As of Month</Label>
|
||||
<Input type="month" value={asOfMonth} onChange={(e) => setAsOfMonth(e.target.value || asOfMonth)} className="w-44 mt-1" />
|
||||
<div className="mt-1 text-sm font-medium">{asOfLabel}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground pb-1">
|
||||
Fiscal year from {new Date(fyStart + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -52,8 +52,9 @@ function splitDebitCredit(a: Account): { debit: number; credit: number } {
|
||||
return bal >= 0 ? { debit: 0, credit: bal } : { debit: -bal, credit: 0 };
|
||||
}
|
||||
|
||||
export function TrialBalanceReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
export function TrialBalanceReport({ companyId, companyName, logoUrl, to: propTo }: { companyId: string; companyName: string; logoUrl?: string | null; to?: string }) {
|
||||
const [asOf, setAsOf] = useState(() => propTo ?? new Date().toISOString().slice(0, 10));
|
||||
useEffect(() => { if (propTo) setAsOf(propTo); }, [propTo]);
|
||||
const [basis, setBasis] = useState<"accrual" | "cash">("accrual");
|
||||
const [showZero, setShowZero] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<"all" | Account["type"]>("all");
|
||||
@@ -210,17 +211,11 @@ export function TrialBalanceReport({ companyId, companyName, logoUrl }: { compan
|
||||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">As of Date</Label>
|
||||
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value)} className="w-44 mt-1" />
|
||||
<div className="mt-1 text-sm font-medium">{fmtDate(asOf)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Basis</Label>
|
||||
<Select value={basis} onValueChange={(v) => setBasis(v as any)}>
|
||||
<SelectTrigger className="w-32 mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accrual">Accrual</SelectItem>
|
||||
<SelectItem value="cash">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1 text-sm font-medium">Accrual</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Account Type</Label>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
-- Void support for bank-register transactions. Voided rows stay for audit but
|
||||
-- are excluded from the cached account balance and, for gl_managed companies,
|
||||
-- reversed out of the GL (post_transaction_gl clears + posts nothing when voided).
|
||||
alter table accounting.transactions
|
||||
add column if not exists voided boolean not null default false,
|
||||
add column if not exists voided_at timestamptz,
|
||||
add column if not exists voided_by uuid;
|
||||
|
||||
-- post_transaction_gl: a voided txn clears its prior GL entry and posts nothing.
|
||||
create or replace function accounting.post_transaction_gl(_txn_id uuid)
|
||||
returns void language plpgsql security definer set search_path to 'public', 'accounting'
|
||||
as $function$
|
||||
declare t accounting.transactions%rowtype; _counter uuid; _je uuid; _amt numeric;
|
||||
begin
|
||||
select * into t from accounting.transactions where id=_txn_id;
|
||||
if not found then return; end if;
|
||||
perform accounting._gl_clear(t.company_id, 'acmacc_txn', t.id::text);
|
||||
if coalesce(t.voided, false) then return; end if;
|
||||
if not accounting.gl_managed(t.company_id) then return; end if;
|
||||
if t.transfer_id is not null or t.deposit_id is not null then return; end if;
|
||||
if t.account_id is null then return; end if;
|
||||
_amt := coalesce(t.amount,0);
|
||||
if _amt = 0 then return; end if;
|
||||
_counter := case
|
||||
when t.customer_id is not null then accounting.coa_ar(t.company_id)
|
||||
when t.coa_account_id is not null then t.coa_account_id
|
||||
when t.vendor_id is not null then accounting.coa_ap(t.company_id)
|
||||
else null end;
|
||||
if _counter is null then return; end if;
|
||||
insert into accounting.journal_entries (company_id, date, description, reference, external_source, external_id)
|
||||
values (t.company_id, t.date, coalesce(nullif(t.description,''), 'Bank transaction'), t.reference, 'acmacc_txn', t.id::text)
|
||||
returning id into _je;
|
||||
if t.type = 'credit' then
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values
|
||||
(_je, t.account_id, _amt, 0, t.description), (_je, _counter, 0, _amt, t.description);
|
||||
else
|
||||
insert into accounting.journal_entry_lines (journal_entry_id, account_id, debit, credit, description) values
|
||||
(_je, _counter, _amt, 0, t.description), (_je, t.account_id, 0, _amt, t.description);
|
||||
end if;
|
||||
end$function$;
|
||||
|
||||
-- sync_account_balance: exclude voided transactions from every cached-balance sum.
|
||||
-- (Full body re-created so each SUM filters COALESCE(voided,false)=false.)
|
||||
create or replace function accounting.sync_account_balance()
|
||||
returns trigger language plpgsql security definer set search_path to 'public'
|
||||
as $function$
|
||||
DECLARE v_computed numeric;
|
||||
BEGIN
|
||||
IF TG_OP IN ('INSERT','UPDATE') THEN
|
||||
IF NEW.account_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(CASE WHEN type='credit' THEN amount ELSE -amount END),0) INTO v_computed
|
||||
FROM accounting.transactions WHERE account_id=NEW.account_id AND COALESCE(voided,false)=false;
|
||||
UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=NEW.account_id;
|
||||
END IF;
|
||||
IF NEW.coa_account_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(CASE WHEN a.type IN ('expense','asset') THEN CASE WHEN t.type='debit' THEN t.amount ELSE -t.amount END
|
||||
ELSE CASE WHEN t.type='credit' THEN t.amount ELSE -t.amount END END),0) INTO v_computed
|
||||
FROM accounting.transactions t JOIN accounting.accounts a ON a.id=t.coa_account_id
|
||||
WHERE t.coa_account_id=NEW.coa_account_id AND COALESCE(t.voided,false)=false;
|
||||
UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=NEW.coa_account_id;
|
||||
END IF;
|
||||
END IF;
|
||||
IF TG_OP='UPDATE' THEN
|
||||
IF OLD.account_id IS NOT NULL AND OLD.account_id IS DISTINCT FROM NEW.account_id THEN
|
||||
SELECT COALESCE(SUM(CASE WHEN type='credit' THEN amount ELSE -amount END),0) INTO v_computed
|
||||
FROM accounting.transactions WHERE account_id=OLD.account_id AND COALESCE(voided,false)=false;
|
||||
UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.account_id;
|
||||
END IF;
|
||||
IF OLD.coa_account_id IS NOT NULL AND OLD.coa_account_id IS DISTINCT FROM NEW.coa_account_id THEN
|
||||
SELECT COALESCE(SUM(CASE WHEN a.type IN ('expense','asset') THEN CASE WHEN t.type='debit' THEN t.amount ELSE -t.amount END
|
||||
ELSE CASE WHEN t.type='credit' THEN t.amount ELSE -t.amount END END),0) INTO v_computed
|
||||
FROM accounting.transactions t JOIN accounting.accounts a ON a.id=t.coa_account_id
|
||||
WHERE t.coa_account_id=OLD.coa_account_id AND COALESCE(t.voided,false)=false;
|
||||
UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.coa_account_id;
|
||||
END IF;
|
||||
END IF;
|
||||
IF TG_OP='DELETE' THEN
|
||||
IF OLD.account_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(CASE WHEN type='credit' THEN amount ELSE -amount END),0) INTO v_computed
|
||||
FROM accounting.transactions WHERE account_id=OLD.account_id AND COALESCE(voided,false)=false;
|
||||
UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.account_id;
|
||||
END IF;
|
||||
IF OLD.coa_account_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(CASE WHEN a.type IN ('expense','asset') THEN CASE WHEN t.type='debit' THEN t.amount ELSE -t.amount END
|
||||
ELSE CASE WHEN t.type='credit' THEN t.amount ELSE -t.amount END END),0) INTO v_computed
|
||||
FROM accounting.transactions t JOIN accounting.accounts a ON a.id=t.coa_account_id
|
||||
WHERE t.coa_account_id=OLD.coa_account_id AND COALESCE(t.voided,false)=false;
|
||||
UPDATE accounting.accounts SET balance=v_computed, updated_at=now() WHERE id=OLD.coa_account_id;
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
Reference in New Issue
Block a user