Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
This commit is contained in:
@@ -0,0 +1,877 @@
|
||||
"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 [businessFilter, setBusinessFilter] = useState("all");
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||
api.invoices.getAll.useQuery();
|
||||
const { data: expenses = [], isLoading: expensesLoading } =
|
||||
api.expenses.getAll.useQuery(
|
||||
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||
);
|
||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const isLoading = invoicesLoading || expensesLoading;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||
|
||||
const filteredInvoices = useMemo(() => {
|
||||
if (businessFilter === "all") return invoices;
|
||||
return invoices.filter((inv) => inv.businessId === businessFilter);
|
||||
}, [invoices, businessFilter]);
|
||||
|
||||
// Overview data (last 12 months)
|
||||
const overviewData = useMemo(() => {
|
||||
if (!filteredInvoices.length) return null;
|
||||
|
||||
const now = new Date();
|
||||
const monthMap: Record<string, number> = {};
|
||||
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 filteredInvoices) {
|
||||
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<string, { name: string; revenue: number }> = {};
|
||||
for (const inv of filteredInvoices) {
|
||||
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<string, number> = {
|
||||
draft: 0,
|
||||
sent: 0,
|
||||
paid: 0,
|
||||
overdue: 0,
|
||||
};
|
||||
for (const inv of filteredInvoices) {
|
||||
const s = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
revenueByMonth,
|
||||
topClients,
|
||||
totalRevenue,
|
||||
totalPending,
|
||||
totalHours,
|
||||
statusCount,
|
||||
};
|
||||
}, [filteredInvoices]);
|
||||
|
||||
// Tax summary for selected year
|
||||
const taxData = useMemo(() => {
|
||||
const year = parseInt(taxYear);
|
||||
|
||||
const yearInvoices = filteredInvoices.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,
|
||||
};
|
||||
}, [filteredInvoices, expenses, taxYear]);
|
||||
|
||||
const availableYears = useMemo(() => {
|
||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||
for (const inv of filteredInvoices)
|
||||
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);
|
||||
}, [filteredInvoices, expenses, currentYear]);
|
||||
|
||||
const avgInvoice =
|
||||
filteredInvoices.length > 0
|
||||
? (overviewData?.totalRevenue ?? 0) /
|
||||
(filteredInvoices.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 (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
/>
|
||||
<div className={dashboardStatGridClass}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Business</span>
|
||||
<Select value={businessFilter} onValueChange={setBusinessFilter}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="All businesses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All businesses</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<PageTabs defaultValue="overview">
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="overview" className="gap-1.5">
|
||||
<TrendingUp className="h-4 w-4" /> Overview
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="tax" className="gap-1.5">
|
||||
<FileText className="h-4 w-4" /> Tax Summary
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
{/* ── OVERVIEW TAB ── */}
|
||||
<PageTabsContent value="overview">
|
||||
<div className={dashboardStatGridClass}>
|
||||
<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="rounded bg-yellow-500/10 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="rounded bg-blue-500/10 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="rounded bg-green-500/10 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>
|
||||
|
||||
<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={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={(value) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
"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>
|
||||
</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={(value) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
"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: `${filteredInvoices.length ? (count / filteredInvoices.length) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-sm">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{filteredInvoices.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>
|
||||
)}
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ── TAX SUMMARY TAB ── */}
|
||||
<PageTabsContent value="tax">
|
||||
<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 pt-1 text-xs">
|
||||
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={(value, name) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
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>
|
||||
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user