mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
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:
@@ -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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,42 +191,41 @@ 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* ── OVERVIEW TAB ── */}
|
||||||
|
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 rounded p-1.5">
|
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
||||||
<DollarSign className="text-primary h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p>
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-yellow-500/10 rounded p-1.5">
|
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
||||||
<Clock className="h-4 w-4 text-yellow-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p>
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-blue-500/10 rounded p-1.5">
|
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
|
||||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
||||||
@@ -141,27 +234,22 @@ export default function ReportsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-green-500/10 rounded p-1.5">
|
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
|
||||||
<Users className="h-4 w-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
|
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue trend chart */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
||||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-48 w-full md:h-64">
|
<div className="h-48 w-full md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={reportData?.revenueByMonth ?? []}>
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
||||||
@@ -180,20 +268,17 @@ export default function ReportsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
{/* Top clients */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
|
||||||
<Users className="h-5 w-5" /> Top Clients by Revenue
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!reportData?.topClients.length ? (
|
{!overviewData?.topClients.length ? (
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-48 md:h-56">
|
<div className="h-48 md:h-56">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={reportData.topClients} layout="vertical">
|
<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}`} />
|
<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} />
|
<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 }} />
|
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||||
@@ -205,39 +290,28 @@ export default function ReportsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Invoice status breakdown */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
|
||||||
<CardTitle>Invoice Status Breakdown</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => (
|
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
|
||||||
<div key={status} className="flex items-center justify-between">
|
<div key={status} className="flex items-center justify-between">
|
||||||
<StatusBadge status={status as never} />
|
<StatusBadge status={status as never} />
|
||||||
<div className="flex items-center gap-3">
|
<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-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||||
<div
|
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
|
||||||
className="bg-primary h-full rounded-full"
|
|
||||||
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{invoices.length === 0 && (
|
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monthly stats table */}
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
|
||||||
<CardTitle>Recent Activity</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{stats.recentInvoices.map((inv) => (
|
{stats.recentInvoices.map((inv) => (
|
||||||
@@ -256,6 +330,125 @@ export default function ReportsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</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>
|
||||||
|
{(invoice.taxRate ?? 0) > 0 && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Tax</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="text-foreground font-medium">$0.00</span>
|
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)}
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -15,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("")),
|
||||||
|
|||||||
@@ -160,6 +160,16 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
|
|||||||
`);
|
`);
|
||||||
return parseInt(rows[0]?.count ?? "0") > 0;
|
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
|
// Unknown migration — assume not applied so it runs
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user