"use client"; import { useMemo, useState } from "react"; import { api } from "~/trpc/react"; import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { StatusBadge } from "~/components/data/status-badge"; import { Button } from "~/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { Separator } from "~/components/ui/separator"; import { PageTabs, PageTabsContent, PageTabsList, PageTabsTrigger, } from "~/components/layout/page-tabs"; import { formatCurrency } from "~/lib/currency"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import type { StoredInvoiceStatus } from "~/types/invoice"; import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText, } from "lucide-react"; function toNumericChartValue(value: unknown) { const numericValue = typeof value === "number" ? value : Number(value ?? 0); return Number.isFinite(numericValue) ? numericValue : 0; } export default function ReportsPage() { const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery(); const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery(); const { data: stats } = api.dashboard.getStats.useQuery(); const isLoading = invoicesLoading || expensesLoading; const currentYear = new Date().getFullYear(); const [taxYear, setTaxYear] = useState(String(currentYear)); // Overview data (last 12 months) const overviewData = useMemo(() => { if (!invoices.length) return null; const now = new Date(); const monthMap: Record = {}; for (let i = 11; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; monthMap[key] = 0; } let totalRevenue = 0; let totalPending = 0; let totalHours = 0; for (const inv of invoices) { const status = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, ); if (status === "paid") { totalRevenue += inv.totalAmount; const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`; if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount; } else if (status === "sent" || status === "overdue") { totalPending += inv.totalAmount; } totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0); } const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({ month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit", }), revenue, })); const clientMap: Record = {}; for (const inv of invoices) { const status = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, ); if (status === "paid" && inv.client) { const id = inv.client.id; const entry = (clientMap[id] ??= { name: inv.client.name, revenue: 0, }); entry.revenue += inv.totalAmount; } } const topClients = Object.values(clientMap) .sort((a, b) => b.revenue - a.revenue) .slice(0, 6); const statusCount: Record = { draft: 0, sent: 0, paid: 0, overdue: 0, }; for (const inv of invoices) { const s = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, ); statusCount[s] = (statusCount[s] ?? 0) + 1; } return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount, }; }, [invoices]); // Tax summary for selected year const taxData = useMemo(() => { const year = parseInt(taxYear); const yearInvoices = invoices.filter((inv) => { const status = getEffectiveInvoiceStatus( inv.status as StoredInvoiceStatus, inv.dueDate, ); return ( status === "paid" && new Date(inv.issueDate).getFullYear() === year ); }); const yearExpenses = expenses.filter( (exp) => new Date(exp.date).getFullYear() === year, ); const getSubtotal = (inv: (typeof yearInvoices)[number]) => { const itemSubtotal = (inv.items ?? []).reduce( (s, item) => s + item.amount, 0, ); if (itemSubtotal > 0) return itemSubtotal; const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100; return taxMultiplier > 0 ? inv.totalAmount / taxMultiplier : inv.totalAmount; }; const grossIncome = yearInvoices.reduce( (s, inv) => s + getSubtotal(inv), 0, ); const taxCollected = yearInvoices.reduce( (s, inv) => s + (inv.totalAmount - getSubtotal(inv)), 0, ); const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0); const deductibleExpenses = yearExpenses .filter( (exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible, ) .reduce((s, exp) => s + exp.amount, 0); const netProfit = grossIncome - deductibleExpenses; const seTaxBase = Math.max(0, netProfit) * 0.9235; const selfEmploymentTax = seTaxBase * 0.153; const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2); const federalEstimate = taxableIncome * 0.22; const totalEstimated = selfEmploymentTax + federalEstimate; const quarters = [1, 2, 3, 4].map((q) => { const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2]; return { label: `Q${q}`, income: yearInvoices .filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())) .reduce((s, inv) => s + getSubtotal(inv), 0), expenses: yearExpenses .filter((exp) => qMonths.includes(new Date(exp.date).getMonth())) .reduce((s, exp) => s + exp.amount, 0), }; }); return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses, }; }, [invoices, expenses, taxYear]); const availableYears = useMemo(() => { const years = new Set([currentYear, currentYear - 1]); for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear()); for (const exp of expenses) years.add(new Date(exp.date).getFullYear()); return Array.from(years).sort((a, b) => b - a); }, [invoices, expenses, currentYear]); const avgInvoice = invoices.length > 0 ? (overviewData?.totalRevenue ?? 0) / (invoices.filter( (i) => getEffectiveInvoiceStatus( i.status as StoredInvoiceStatus, i.dueDate, ) === "paid", ).length || 1) : 0; function exportCSV() { const rows: string[] = [ `Tax Year ${taxYear} - Income & Expense Report`, `Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`, "", "INCOME (Paid Invoices)", "Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total", ...taxData.yearInvoices.map((inv) => { const subtotal = (inv.items ?? []).reduce( (s, item) => s + item.amount, 0, ); const fallbackSubtotal = inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100); const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal; const taxAmt = inv.totalAmount - invoiceSubtotal; return [ new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, invoiceSubtotal.toFixed(2), `${(inv.taxRate ?? 0).toFixed(1)}%`, taxAmt.toFixed(2), inv.totalAmount.toFixed(2), ].join(","); }), `,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`, "", "EXPENSES", "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", ...taxData.yearExpenses.map((exp) => [ new Date(exp.date).toLocaleDateString("en-US"), `"${exp.description}"`, `"${exp.category ?? ""}"`, exp.amount.toFixed(2), exp.currency, exp.billable ? "Yes" : "No", exp.reimbursable ? "Yes" : "No", (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No", ].join(","), ), `,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`, "", "TAX SUMMARY", `Gross Income,${taxData.grossIncome.toFixed(2)}`, `Tax Collected,${taxData.taxCollected.toFixed(2)}`, `Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`, `Net Profit,${taxData.netProfit.toFixed(2)}`, `Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`, `Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`, `Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`, ]; const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `tax-report-${taxYear}.csv`; a.click(); URL.revokeObjectURL(url); } if (isLoading) { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } return ( Overview Tax Summary {/* ── OVERVIEW TAB ── */}

Total Revenue

{formatCurrency(overviewData?.totalRevenue ?? 0)}

Pending

{formatCurrency(overviewData?.totalPending ?? 0)}

Avg Invoice

{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}

Total Hours

{(overviewData?.totalHours ?? 0).toFixed(1)}h

Revenue (Last 12 Months)
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}` } /> [ formatCurrency(toNumericChartValue(value)), "Revenue", ]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12, }} />
Top Clients by Revenue {!overviewData?.topClients.length ? (

No paid invoices yet.

) : (
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}` } /> [ formatCurrency(toNumericChartValue(value)), "Revenue", ]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12, }} />
)}
Invoice Status Breakdown {Object.entries(overviewData?.statusCount ?? {}).map( ([status, count]) => (
{count}
), )} {invoices.length === 0 && (

No invoices yet.

)}
{stats && ( Recent Activity
{stats.recentInvoices.map((inv) => (

{inv.client?.name ?? "—"}

{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", })}

{formatCurrency(inv.totalAmount)}

))}
)} {/* ── TAX SUMMARY TAB ── */}
Tax Year
{/* Income */} Income
Gross Income (paid invoices) {formatCurrency(taxData.grossIncome)}
{taxData.taxCollected > 0 && (
Tax Collected from Clients {formatCurrency(taxData.taxCollected)}
)}
Total Invoiced (inc. tax) {formatCurrency(taxData.totalInvoiced)}
{/* Expenses */} Expenses & Deductions
Total Expenses {formatCurrency(taxData.totalExpenses)}
Tax-Deductible Expenses {formatCurrency(taxData.deductibleExpenses)}
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (

Mark expenses as "Tax Deductible" in the Expenses page to include them here.

)}
{/* Estimated tax */} Estimated Tax Liability
Net Profit (income − deductible expenses) {formatCurrency(taxData.netProfit)}
Self-Employment Tax (15.3% on 92.35% of net) {formatCurrency(taxData.selfEmploymentTax)}
Federal Income Tax (est. 22% bracket) {formatCurrency(taxData.federalEstimate)}
Total Estimated Tax {formatCurrency(taxData.totalEstimated)}

Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.

{/* Quarterly chart */} Quarterly Breakdown
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}` } /> [ formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses", ]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12, }} />
{" "} Income {" "} Expenses
); }