mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { PieChart, Download, Printer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
type ReportType = "trial_balance" | "income_statement" | "balance_sheet" | "aging" | "delinquency";
|
||||
|
||||
export default function AccountingReportsPage() {
|
||||
const { toast } = useToast();
|
||||
const [associations, setAssociations] = useState<any[]>([]);
|
||||
const [selectedAssocId, setSelectedAssocId] = useState("");
|
||||
const [startDate, setStartDate] = useState(new Date(new Date().getFullYear(), 0, 1).toISOString().split("T")[0]);
|
||||
const [endDate, setEndDate] = useState(new Date().toISOString().split("T")[0]);
|
||||
const [reportType, setReportType] = useState<ReportType>("trial_balance");
|
||||
|
||||
// Data
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [journalEntries, setJournalEntries] = useState<any[]>([]);
|
||||
const [ownerLedger, setOwnerLedger] = useState<any[]>([]);
|
||||
const [owners, setOwners] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
|
||||
setAssociations(data || []);
|
||||
if (data?.length) setSelectedAssocId(data[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generateReport = async () => {
|
||||
if (!selectedAssocId) return;
|
||||
setLoading(true);
|
||||
const [acctRes, jeRes, olRes, oRes] = await Promise.all([
|
||||
supabase.from("chart_of_accounts").select("*").eq("association_id", selectedAssocId).order("account_number"),
|
||||
supabase.from("journal_entries").select("*, chart_of_accounts(account_number, account_name, account_type)").eq("association_id", selectedAssocId).gte("date", startDate).lte("date", endDate),
|
||||
supabase.from("owner_ledger_entries").select("*, owners(first_name, last_name), units(unit_number)").eq("association_id", selectedAssocId),
|
||||
supabase.from("owners").select("id, first_name, last_name, balance, unit_id, units(unit_number)").eq("status", "active").eq("association_id", selectedAssocId).order("last_name"),
|
||||
]);
|
||||
setAccounts(acctRes.data || []);
|
||||
setJournalEntries(jeRes.data || []);
|
||||
setOwnerLedger(olRes.data || []);
|
||||
setOwners(oRes.data || []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Trial Balance
|
||||
const trialBalance = useMemo(() => {
|
||||
const map = new Map<string, { account: any; debit: number; credit: number }>();
|
||||
journalEntries.forEach(je => {
|
||||
const key = je.chart_of_account_id || "uncategorized";
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
account: je.chart_of_accounts || { account_number: "N/A", account_name: "Uncategorized", account_type: "expense" },
|
||||
debit: 0, credit: 0
|
||||
});
|
||||
}
|
||||
const entry = map.get(key)!;
|
||||
if (je.type === "debit") entry.debit += Number(je.amount);
|
||||
else entry.credit += Number(je.amount);
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) => (a.account.account_number || "").localeCompare(b.account.account_number || ""));
|
||||
}, [journalEntries]);
|
||||
|
||||
// Income Statement
|
||||
const incomeStatement = useMemo(() => {
|
||||
const income: typeof trialBalance = [];
|
||||
const expenses: typeof trialBalance = [];
|
||||
trialBalance.forEach(tb => {
|
||||
if (tb.account.account_type === "income") income.push(tb);
|
||||
else if (tb.account.account_type === "expense") expenses.push(tb);
|
||||
});
|
||||
const totalIncome = income.reduce((s, i) => s + i.credit - i.debit, 0);
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.debit - e.credit, 0);
|
||||
return { income, expenses, totalIncome, totalExpenses, netIncome: totalIncome - totalExpenses };
|
||||
}, [trialBalance]);
|
||||
|
||||
// Balance Sheet
|
||||
const balanceSheet = useMemo(() => {
|
||||
const assets: typeof trialBalance = [];
|
||||
const liabilities: typeof trialBalance = [];
|
||||
const equity: typeof trialBalance = [];
|
||||
trialBalance.forEach(tb => {
|
||||
if (tb.account.account_type === "asset") assets.push(tb);
|
||||
else if (tb.account.account_type === "liability") liabilities.push(tb);
|
||||
else if (tb.account.account_type === "equity") equity.push(tb);
|
||||
});
|
||||
return {
|
||||
assets, liabilities, equity,
|
||||
totalAssets: assets.reduce((s, a) => s + a.debit - a.credit, 0),
|
||||
totalLiabilities: liabilities.reduce((s, l) => s + l.credit - l.debit, 0),
|
||||
totalEquity: equity.reduce((s, e) => s + e.credit - e.debit, 0),
|
||||
};
|
||||
}, [trialBalance]);
|
||||
|
||||
// Aging Report
|
||||
const agingReport = useMemo(() => {
|
||||
const now = new Date();
|
||||
return owners.filter(o => Number(o.balance) > 0).map(o => {
|
||||
const entries = ownerLedger.filter(e => e.owner_id === o.id && Number(e.debit) > 0);
|
||||
let current = 0, over30 = 0, over60 = 0, over90 = 0;
|
||||
entries.forEach(e => {
|
||||
const days = Math.floor((now.getTime() - new Date(e.date).getTime()) / 86400000);
|
||||
const amt = Number(e.debit) - Number(e.credit);
|
||||
if (amt <= 0) return;
|
||||
if (days <= 30) current += amt;
|
||||
else if (days <= 60) over30 += amt;
|
||||
else if (days <= 90) over60 += amt;
|
||||
else over90 += amt;
|
||||
});
|
||||
return { owner: o, current, over30, over60, over90, total: Number(o.balance) };
|
||||
}).sort((a, b) => b.total - a.total);
|
||||
}, [owners, ownerLedger]);
|
||||
|
||||
const fmtMoney = (v: number) => `$${v.toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><PieChart className="h-6 w-6 text-primary" /> Financial Reports</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Generate accounting and owner reports.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => window.print()} className="gap-2"><Printer className="h-4 w-4" /> Print</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase">Association</Label>
|
||||
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label className="text-xs font-semibold text-muted-foreground uppercase">Start Date</Label><Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} /></div>
|
||||
<div><Label className="text-xs font-semibold text-muted-foreground uppercase">End Date</Label><Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} /></div>
|
||||
<div className="flex items-end"><Button onClick={generateReport} className="w-full" disabled={loading}>{loading ? "Generating..." : "Generate Report"}</Button></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs value={reportType} onValueChange={(v) => setReportType(v as ReportType)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="trial_balance">Trial Balance</TabsTrigger>
|
||||
<TabsTrigger value="income_statement">Income Statement</TabsTrigger>
|
||||
<TabsTrigger value="balance_sheet">Balance Sheet</TabsTrigger>
|
||||
<TabsTrigger value="aging">Aging Report</TabsTrigger>
|
||||
<TabsTrigger value="delinquency">Delinquency</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="trial_balance">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Trial Balance</CardTitle></CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Account #</TableHead><TableHead>Account Name</TableHead><TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Debit</TableHead><TableHead className="text-right">Credit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{trialBalance.map((tb, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono">{tb.account.account_number}</TableCell>
|
||||
<TableCell className="font-medium">{tb.account.account_name}</TableCell>
|
||||
<TableCell className="capitalize">{tb.account.account_type}</TableCell>
|
||||
<TableCell className="text-right font-mono">{tb.debit > 0 ? fmtMoney(tb.debit) : "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono">{tb.credit > 0 ? fmtMoney(tb.credit) : "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{trialBalance.length > 0 && (
|
||||
<TableRow className="font-bold border-t-2">
|
||||
<TableCell colSpan={3}>Totals</TableCell>
|
||||
<TableCell className="text-right font-mono">{fmtMoney(trialBalance.reduce((s, t) => s + t.debit, 0))}</TableCell>
|
||||
<TableCell className="text-right font-mono">{fmtMoney(trialBalance.reduce((s, t) => s + t.credit, 0))}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{trialBalance.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">Generate a report to see data.</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="income_statement">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Income Statement (P&L)</CardTitle></CardHeader>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Account</TableHead><TableHead className="text-right">Amount</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="bg-muted/50"><TableCell colSpan={2} className="font-bold">Revenue</TableCell></TableRow>
|
||||
{incomeStatement.income.map((i, idx) => (
|
||||
<TableRow key={idx}><TableCell className="pl-8">{i.account.account_name}</TableCell><TableCell className="text-right font-mono">{fmtMoney(i.credit - i.debit)}</TableCell></TableRow>
|
||||
))}
|
||||
<TableRow className="font-bold border-t"><TableCell>Total Revenue</TableCell><TableCell className="text-right font-mono text-emerald-600">{fmtMoney(incomeStatement.totalIncome)}</TableCell></TableRow>
|
||||
<TableRow className="bg-muted/50"><TableCell colSpan={2} className="font-bold">Expenses</TableCell></TableRow>
|
||||
{incomeStatement.expenses.map((e, idx) => (
|
||||
<TableRow key={idx}><TableCell className="pl-8">{e.account.account_name}</TableCell><TableCell className="text-right font-mono">{fmtMoney(e.debit - e.credit)}</TableCell></TableRow>
|
||||
))}
|
||||
<TableRow className="font-bold border-t"><TableCell>Total Expenses</TableCell><TableCell className="text-right font-mono text-destructive">{fmtMoney(incomeStatement.totalExpenses)}</TableCell></TableRow>
|
||||
<TableRow className="font-bold border-t-2 text-lg">
|
||||
<TableCell>Net Income</TableCell>
|
||||
<TableCell className={`text-right font-mono ${incomeStatement.netIncome >= 0 ? "text-emerald-600" : "text-destructive"}`}>{fmtMoney(incomeStatement.netIncome)}</TableCell>
|
||||
</TableRow>
|
||||
{journalEntries.length === 0 && <TableRow><TableCell colSpan={2} className="text-center py-8 text-muted-foreground">Generate a report to see data.</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="balance_sheet">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Balance Sheet</CardTitle></CardHeader>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Account</TableHead><TableHead className="text-right">Amount</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="bg-muted/50"><TableCell colSpan={2} className="font-bold">Assets</TableCell></TableRow>
|
||||
{balanceSheet.assets.map((a, i) => (
|
||||
<TableRow key={i}><TableCell className="pl-8">{a.account.account_name}</TableCell><TableCell className="text-right font-mono">{fmtMoney(a.debit - a.credit)}</TableCell></TableRow>
|
||||
))}
|
||||
<TableRow className="font-bold border-t"><TableCell>Total Assets</TableCell><TableCell className="text-right font-mono">{fmtMoney(balanceSheet.totalAssets)}</TableCell></TableRow>
|
||||
<TableRow className="bg-muted/50"><TableCell colSpan={2} className="font-bold">Liabilities</TableCell></TableRow>
|
||||
{balanceSheet.liabilities.map((l, i) => (
|
||||
<TableRow key={i}><TableCell className="pl-8">{l.account.account_name}</TableCell><TableCell className="text-right font-mono">{fmtMoney(l.credit - l.debit)}</TableCell></TableRow>
|
||||
))}
|
||||
<TableRow className="font-bold border-t"><TableCell>Total Liabilities</TableCell><TableCell className="text-right font-mono">{fmtMoney(balanceSheet.totalLiabilities)}</TableCell></TableRow>
|
||||
<TableRow className="bg-muted/50"><TableCell colSpan={2} className="font-bold">Equity</TableCell></TableRow>
|
||||
{balanceSheet.equity.map((e, i) => (
|
||||
<TableRow key={i}><TableCell className="pl-8">{e.account.account_name}</TableCell><TableCell className="text-right font-mono">{fmtMoney(e.credit - e.debit)}</TableCell></TableRow>
|
||||
))}
|
||||
<TableRow className="font-bold border-t"><TableCell>Total Equity</TableCell><TableCell className="text-right font-mono">{fmtMoney(balanceSheet.totalEquity)}</TableCell></TableRow>
|
||||
{journalEntries.length === 0 && <TableRow><TableCell colSpan={2} className="text-center py-8 text-muted-foreground">Generate a report to see data.</TableCell></TableRow>}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="aging">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Aging Report</CardTitle></CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Owner</TableHead><TableHead>Unit</TableHead>
|
||||
<TableHead className="text-right">Current</TableHead><TableHead className="text-right">31-60</TableHead>
|
||||
<TableHead className="text-right">61-90</TableHead><TableHead className="text-right">90+</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agingReport.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No delinquent accounts.</TableCell></TableRow>
|
||||
) : agingReport.map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-medium">{r.owner.last_name}, {r.owner.first_name}</TableCell>
|
||||
<TableCell>{r.owner.units?.unit_number || "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono">{r.current > 0 ? fmtMoney(r.current) : "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono">{r.over30 > 0 ? fmtMoney(r.over30) : "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono">{r.over60 > 0 ? fmtMoney(r.over60) : "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-destructive">{r.over90 > 0 ? fmtMoney(r.over90) : "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono font-bold">{fmtMoney(r.total)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="delinquency">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Delinquency Report</CardTitle></CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Owner</TableHead><TableHead>Unit</TableHead>
|
||||
<TableHead className="text-right">Balance Due</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{owners.filter(o => Number(o.balance) > 0).length === 0 ? (
|
||||
<TableRow><TableCell colSpan={3} className="text-center py-8 text-muted-foreground">No delinquent accounts.</TableCell></TableRow>
|
||||
) : owners.filter(o => Number(o.balance) > 0).sort((a, b) => Number(b.balance) - Number(a.balance)).map((o, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-medium">{o.last_name}, {o.first_name}</TableCell>
|
||||
<TableCell>{o.units?.unit_number || "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono font-bold text-destructive">{fmtMoney(Number(o.balance))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user