Add tax features: summary report, deductible expenses, invoice tax fix, CSV export

- Add taxDeductible boolean to expenses schema + migration 0002
- Update expenses router, form, and list to support tax-deductible flag
- Fix invoice-view tax calculation (was hardcoded $0.00; now uses taxRate)
- New Tax Summary tab in Reports: year selector, income/deductions breakdown,
  SE tax + federal income estimates, quarterly bar chart
- CSV export for accountant with income + expense rows and tax summary

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
This commit is contained in:
Claude
2026-04-05 03:21:08 +00:00
parent 1f76cf38a7
commit 74f9696023
8 changed files with 409 additions and 175 deletions
+19 -4
View File
@@ -39,6 +39,7 @@ interface ExpenseFormData {
category: string;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean;
notes: string;
clientId: string;
}
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
category: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
clientId: "",
};
@@ -89,6 +91,7 @@ export default function ExpensesPage() {
category: expense.category ?? "",
billable: expense.billable,
reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "",
clientId: expense.clientId ?? "",
});
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; }
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined };
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload);
};
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
return (
<div className="page-enter space-y-6 pb-6">
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
</PageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
@@ -127,7 +131,13 @@ export default function ExpensesPage() {
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
</CardContent>
</Card>
<Card className="col-span-2 sm:col-span-1">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
@@ -159,6 +169,7 @@ export default function ExpensesPage() {
<p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
</SelectContent>
</Select>
</div>
<div className="flex gap-6">
<div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
<span className="text-sm">Billable</span>
@@ -238,6 +249,10 @@ export default function ExpensesPage() {
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
<span className="text-sm">Reimbursable</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
<span className="text-sm">Tax Deductible</span>
</label>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
+358 -165
View File
@@ -1,10 +1,14 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -19,18 +23,23 @@ import {
Tooltip,
ResponsiveContainer,
} from "recharts";
import { TrendingUp, DollarSign, Clock, Users } from "lucide-react";
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
export default function ReportsPage() {
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
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 now = new Date();
const isLoading = invoicesLoading || expensesLoading;
const reportData = useMemo(() => {
const now = new Date();
const currentYear = now.getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear));
// Overview data (last 12 months)
const overviewData = useMemo(() => {
if (!invoices.length) return null;
// Revenue by month (last 12 months)
const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
@@ -41,7 +50,6 @@ export default function ReportsPage() {
let totalRevenue = 0;
let totalPending = 0;
let totalHours = 0;
let overdueCount = 0;
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
@@ -52,7 +60,6 @@ export default function ReportsPage() {
} else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount;
}
if (status === "overdue") overdueCount++;
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
}
@@ -61,35 +68,122 @@ export default function ReportsPage() {
revenue,
}));
// Top clients by revenue (paid only)
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
if (status === "paid" && inv.client) {
const id = inv.client.id;
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 };
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 };
clientMap[id]!.revenue += inv.totalAmount;
clientMap[id]!.count += 1;
}
}
const topClients = Object.values(clientMap)
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
// Status breakdown
const statusCount: Record<string, number> = { 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, overdueCount, statusCount };
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 grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0);
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 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 + inv.totalAmount, 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<number>([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 taxAmt = inv.totalAmount * (inv.taxRate ?? 0);
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).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 (
<div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
</div>
@@ -97,165 +191,264 @@ export default function ReportsPage() {
);
}
const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0;
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
{/* KPI cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5">
<DollarSign className="text-primary h-4 w-4" />
</div>
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5">
<Clock className="h-4 w-4 text-yellow-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Pending</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5">
<TrendingUp className="h-4 w-4 text-blue-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-green-500/10 rounded p-1.5">
<Users className="h-4 w-4 text-green-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
</div>
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
</TabsList>
{/* Revenue trend chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.revenueByMonth ?? []}>
<defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
{/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
<p className="text-muted-foreground text-xs font-medium">Pending</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
</div>
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Top clients */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
</CardHeader>
<CardContent>
{!reportData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
) : (
<div className="h-48 md:h-56">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={reportData.topClients} layout="vertical">
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
<AreaChart data={overviewData?.revenueByMonth ?? []}>
<defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
</CardHeader>
<CardContent>
{!overviewData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
) : (
<div className="h-48 md:h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={overviewData.topClients} layout="vertical">
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
<CardContent className="space-y-3">
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
<div key={status} className="flex items-center justify-between">
<StatusBadge status={status as never} />
<div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
</div>
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
</div>
</div>
))}
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
</CardContent>
</Card>
</div>
{stats && (
<Card>
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
<CardContent>
<div className="divide-y">
{stats.recentInvoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between py-3">
<div>
<p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
</div>
<div className="flex items-center gap-3">
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span>
<Select value={taxYear} onValueChange={setTaxYear}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={exportCSV} className="gap-2">
<Download className="h-4 w-4" /> Export CSV
</Button>
</div>
{/* Income */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
</div>
{taxData.taxCollected > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax Collected from Clients</span>
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-medium">
<span>Total Invoiced (inc. tax)</span>
<span>{formatCurrency(taxData.totalInvoiced)}</span>
</div>
</CardContent>
</Card>
{/* Expenses */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Expenses</span>
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
</div>
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
)}
</CardContent>
</Card>
{/* Estimated tax */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Net Profit (income deductible expenses)</span>
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total Estimated Tax</span>
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
</div>
<p className="text-muted-foreground text-xs pt-1">
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
</p>
</CardContent>
</Card>
{/* Quarterly chart */}
<Card>
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
<CardContent>
<div className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={taxData.quarters}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]}
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
/>
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* Invoice status breakdown */}
<Card>
<CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => (
<div key={status} className="flex items-center justify-between">
<StatusBadge status={status as never} />
<div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
/>
</div>
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
</div>
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
</div>
))}
{invoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
)}
</CardContent>
</Card>
</div>
{/* Monthly stats table */}
{stats && (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y">
{stats.recentInvoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between py-3">
<div>
<p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
</div>
<div className="flex items-center gap-3">
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}