mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74f9696023 | |||
| 1f76cf38a7 | |||
| e5242b37a4 | |||
| 38206f34fe | |||
| e950abd805 | |||
| 4c0eae4b11 |
@@ -8,8 +8,6 @@ README.md
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
drizzle/*.sql
|
|
||||||
drizzle/*-journal
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
||||||
@@ -16,5 +16,12 @@
|
|||||||
"tag": "0001_supreme_the_enforcers",
|
"tag": "0001_supreme_the_enforcers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
,{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775400000000,
|
||||||
|
"tag": "0002_tax_deductible",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ import { NumberInput } from "~/components/ui/number-input";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses";
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
interface ExpenseFormData {
|
interface ExpenseFormData {
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -39,6 +39,7 @@ interface ExpenseFormData {
|
|||||||
category: string;
|
category: string;
|
||||||
billable: boolean;
|
billable: boolean;
|
||||||
reimbursable: boolean;
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
|
|||||||
category: "",
|
category: "",
|
||||||
billable: false,
|
billable: false,
|
||||||
reimbursable: false,
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
notes: "",
|
notes: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
};
|
};
|
||||||
@@ -89,6 +91,7 @@ export default function ExpensesPage() {
|
|||||||
category: expense.category ?? "",
|
category: expense.category ?? "",
|
||||||
billable: expense.billable,
|
billable: expense.billable,
|
||||||
reimbursable: expense.reimbursable,
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
notes: expense.notes ?? "",
|
notes: expense.notes ?? "",
|
||||||
clientId: expense.clientId ?? "",
|
clientId: expense.clientId ?? "",
|
||||||
});
|
});
|
||||||
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); 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 });
|
if (editId) update.mutate({ id: editId, ...payload });
|
||||||
else create.mutate(payload);
|
else create.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
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 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 (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<div className="page-enter space-y-6 pb-6">
|
||||||
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* 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>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
<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>
|
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
<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>
|
<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>
|
<p className="font-medium">{expense.description}</p>
|
||||||
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||||
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</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>}
|
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||||
<span className="text-sm">Billable</span>
|
<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 }))} />
|
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||||
<span className="text-sm">Reimbursable</span>
|
<span className="text-sm">Reimbursable</span>
|
||||||
</label>
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Notes (optional)</Label>
|
<Label>Notes (optional)</Label>
|
||||||
|
|||||||
+358
-165
@@ -1,10 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
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 { formatCurrency } from "~/lib/currency";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
@@ -19,18 +23,23 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} 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() {
|
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 { 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;
|
if (!invoices.length) return null;
|
||||||
|
|
||||||
// Revenue by month (last 12 months)
|
|
||||||
const monthMap: Record<string, number> = {};
|
const monthMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
@@ -41,7 +50,6 @@ export default function ReportsPage() {
|
|||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let totalPending = 0;
|
let totalPending = 0;
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
let overdueCount = 0;
|
|
||||||
|
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
@@ -52,7 +60,6 @@ export default function ReportsPage() {
|
|||||||
} else if (status === "sent" || status === "overdue") {
|
} else if (status === "sent" || status === "overdue") {
|
||||||
totalPending += inv.totalAmount;
|
totalPending += inv.totalAmount;
|
||||||
}
|
}
|
||||||
if (status === "overdue") overdueCount++;
|
|
||||||
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,35 +68,122 @@ export default function ReportsPage() {
|
|||||||
revenue,
|
revenue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Top clients by revenue (paid only)
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
|
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
if (status === "paid" && inv.client) {
|
if (status === "paid" && inv.client) {
|
||||||
const id = inv.client.id;
|
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]!.revenue += inv.totalAmount;
|
||||||
clientMap[id]!.count += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const topClients = Object.values(clientMap)
|
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
|
||||||
.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 };
|
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount };
|
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
|
||||||
}, [invoices]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<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">
|
<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" />)}
|
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<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 */}
|
<Tabs defaultValue="overview">
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<Card>
|
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
|
||||||
<CardContent className="p-4">
|
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
|
||||||
<div className="flex items-center gap-2">
|
</TabsList>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Revenue trend chart */}
|
{/* ── OVERVIEW TAB ── */}
|
||||||
<Card>
|
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||||
<CardHeader>
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Card>
|
||||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
<CardContent className="p-4">
|
||||||
</CardTitle>
|
<div className="flex items-center gap-2">
|
||||||
</CardHeader>
|
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
||||||
<CardContent>
|
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||||
<div className="h-48 w-full md:h-64">
|
</div>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
|
||||||
<AreaChart data={reportData?.revenueByMonth ?? []}>
|
</CardContent>
|
||||||
<defs>
|
</Card>
|
||||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
<Card>
|
||||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
<CardContent className="p-4">
|
||||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
<div className="flex items-center gap-2">
|
||||||
</linearGradient>
|
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
||||||
</defs>
|
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
</div>
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
|
||||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
</CardContent>
|
||||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
</Card>
|
||||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
<Card>
|
||||||
</AreaChart>
|
<CardContent className="p-4">
|
||||||
</ResponsiveContainer>
|
<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>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<Card>
|
||||||
{/* Top clients */}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
||||||
<CardHeader>
|
</CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardContent>
|
||||||
<Users className="h-5 w-5" /> Top Clients by Revenue
|
<div className="h-48 w-full md:h-64">
|
||||||
</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">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={reportData.topClients} layout="vertical">
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
<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}`} />
|
<defs>
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
<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 }} />
|
<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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
||||||
</CardContent>
|
<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>
|
||||||
</Card>
|
<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>
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
{invoices.length === 0 && (
|
</Card>
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
|
</TabsContent>
|
||||||
)}
|
</Tabs>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,18 +423,24 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Subtotal</span>
|
<span className="text-muted-foreground">Subtotal</span>
|
||||||
<span className="text-foreground font-medium">
|
<span className="text-foreground font-medium">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
{(invoice.taxRate ?? 0) > 0 && (
|
||||||
<span className="text-muted-foreground">Tax</span>
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-foreground font-medium">$0.00</span>
|
<span className="text-muted-foreground">
|
||||||
</div>
|
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between text-lg font-bold">
|
<div className="flex justify-between text-lg font-bold">
|
||||||
<span className="text-foreground">Total</span>
|
<span className="text-foreground">Total</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), invoice.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
@@ -3,18 +3,9 @@ import { eq, and, desc } from "drizzle-orm";
|
|||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
export const EXPENSE_CATEGORIES = [
|
export { EXPENSE_CATEGORIES };
|
||||||
"Travel",
|
|
||||||
"Meals & Entertainment",
|
|
||||||
"Software & Subscriptions",
|
|
||||||
"Hardware & Equipment",
|
|
||||||
"Office Supplies",
|
|
||||||
"Marketing",
|
|
||||||
"Professional Services",
|
|
||||||
"Utilities",
|
|
||||||
"Other",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
@@ -24,6 +15,7 @@ const createExpenseSchema = z.object({
|
|||||||
category: z.string().optional().or(z.literal("")),
|
category: z.string().optional().or(z.literal("")),
|
||||||
billable: z.boolean().default(false),
|
billable: z.boolean().default(false),
|
||||||
reimbursable: z.boolean().default(false),
|
reimbursable: z.boolean().default(false),
|
||||||
|
taxDeductible: z.boolean().default(false),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
clientId: z.string().optional().or(z.literal("")),
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
businessId: z.string().optional().or(z.literal("")),
|
businessId: z.string().optional().or(z.literal("")),
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
* This applies any pending migrations from the drizzle/ directory to the
|
* This applies any pending migrations from the drizzle/ directory to the
|
||||||
* database specified by DATABASE_URL. It is safe to run multiple times —
|
* database specified by DATABASE_URL. It is safe to run multiple times —
|
||||||
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
||||||
|
*
|
||||||
|
* If the database was previously set up via `db:push` (no migration history),
|
||||||
|
* this script will baseline it: seed the migration history without re-running
|
||||||
|
* the SQL, so only future migrations are applied.
|
||||||
*/
|
*/
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
@@ -17,6 +21,8 @@ import { Pool } from "pg";
|
|||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
@@ -36,9 +42,142 @@ const pool = new Pool({
|
|||||||
|
|
||||||
const db = drizzle(pool);
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and repair the migration tracking table:
|
||||||
|
* 1. If no tracking table exists and DB has tables → baseline from db:push
|
||||||
|
* 2. If tracking table exists → scan for any entries that are recorded as
|
||||||
|
* applied but whose schema changes don't actually exist, and remove them
|
||||||
|
* so migrate() will re-run those migrations.
|
||||||
|
*/
|
||||||
|
async function baselineIfNeeded(client: Pool) {
|
||||||
|
const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations");
|
||||||
|
|
||||||
|
// Always ensure the drizzle schema + table exist
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { rows: entryRows } = await client.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`
|
||||||
|
);
|
||||||
|
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
|
||||||
|
|
||||||
|
if (!hasMigrationsTable || !hasEntries) {
|
||||||
|
// No history at all — check if DB was previously set up via db:push
|
||||||
|
const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account");
|
||||||
|
if (!dbAlreadyExists) {
|
||||||
|
return; // Fresh DB — let migrate() run everything normally
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[migrate] Existing database detected without migration history — baselining...");
|
||||||
|
await seedMigrationHistory(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration history exists — validate that each recorded migration is
|
||||||
|
// actually reflected in the schema. Remove any bogus entries.
|
||||||
|
await removeBogusEntries(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMigrationHistory(client: Pool) {
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
|
||||||
|
);
|
||||||
|
const hash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
|
||||||
|
[hash, entry.when]
|
||||||
|
);
|
||||||
|
console.log(`[migrate] Baselined: ${entry.tag}`);
|
||||||
|
}
|
||||||
|
console.log("[migrate] Baseline complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBogusEntries(client: Pool) {
|
||||||
|
// Get all recorded hashes
|
||||||
|
const { rows } = await client.query<{ id: number; hash: string }>(
|
||||||
|
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`
|
||||||
|
);
|
||||||
|
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
|
||||||
|
);
|
||||||
|
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
const recorded = rows.find((r) => r.hash === expectedHash);
|
||||||
|
if (!recorded) continue; // Not recorded yet — migrate() will run it
|
||||||
|
|
||||||
|
// It's recorded — verify it's actually applied in the schema
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`);
|
||||||
|
await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(client: Pool, schema: string, table: string): Promise<boolean> {
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_name = $2
|
||||||
|
`, [schema, table]);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a specific migration's schema changes already exist in the DB.
|
||||||
|
*/
|
||||||
|
async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
|
||||||
|
if (tag === "0000_glossy_magneto") {
|
||||||
|
return tableExists(client, "public", "beenvoice_account");
|
||||||
|
}
|
||||||
|
if (tag === "0001_supreme_the_enforcers") {
|
||||||
|
// 0001 adds currency to beenvoice_client
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_client'
|
||||||
|
AND column_name = 'currency'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0002_tax_deductible") {
|
||||||
|
// 0002 adds taxDeductible to beenvoice_expense
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_expense'
|
||||||
|
AND column_name = 'taxDeductible'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
// Unknown migration — assume not applied so it runs
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[migrate] Running migrations from", migrationsFolder);
|
console.log("[migrate] Running migrations from", migrationsFolder);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await baselineIfNeeded(pool);
|
||||||
await migrate(db, { migrationsFolder });
|
await migrate(db, { migrationsFolder });
|
||||||
console.log("[migrate] All migrations applied successfully");
|
console.log("[migrate] All migrations applied successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export const expenses = createTable(
|
|||||||
category: d.varchar({ length: 100 }),
|
category: d.varchar({ length: 100 }),
|
||||||
billable: d.boolean().default(false).notNull(),
|
billable: d.boolean().default(false).notNull(),
|
||||||
reimbursable: d.boolean().default(false).notNull(),
|
reimbursable: d.boolean().default(false).notNull(),
|
||||||
|
taxDeductible: d.boolean().default(false).notNull(),
|
||||||
notes: d.varchar({ length: 500 }),
|
notes: d.varchar({ length: 500 }),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
|
|||||||
@@ -84,9 +84,9 @@
|
|||||||
--color-input: hsl(var(--input));
|
--color-input: hsl(var(--input));
|
||||||
--color-ring: hsl(var(--ring));
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
--font-sans: var(--font-sans), sans-serif;
|
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-heading: var(--font-heading), serif;
|
--font-heading: var(--font-heading), ui-serif, Georgia, serif;
|
||||||
--font-mono: var(--font-geist-mono), monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,6 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import { type AppRouter } from "~/server/api/root";
|
|
||||||
import { createQueryClient } from "./query-client";
|
import { createQueryClient } from "./query-client";
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||||
@@ -22,21 +21,22 @@ const getQueryClient = () => {
|
|||||||
return clientQueryClientSingleton;
|
return clientQueryClientSingleton;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
// Use inline import() type to avoid pulling server modules into the client bundle
|
||||||
|
export const api = createTRPCReact<import("~/server/api/root").AppRouter>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for inputs.
|
* Inference helper for inputs.
|
||||||
*
|
*
|
||||||
* @example type HelloInput = RouterInputs['example']['hello']
|
* @example type HelloInput = RouterInputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
export type RouterInputs = inferRouterInputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for outputs.
|
* Inference helper for outputs.
|
||||||
*
|
*
|
||||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
export type RouterOutputs = inferRouterOutputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user