10 Commits

Author SHA1 Message Date
soconnor b582b6c88e update pdf generation to flow better 2026-04-27 14:15:06 -04:00
soconnor 00e066ca4e fix: register frutiger-bold as pdf font 2026-04-27 13:33:40 -04:00
soconnor 4214a4b4de add invoice prefixes, currency passing to pdf gen 2026-04-10 01:28:14 -04:00
soconnor af392e1bc9 remove reordering controls, add auto sort 2026-04-09 23:27:45 -04:00
Claude 74f9696023 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
2026-04-05 03:21:08 +00:00
Claude 1f76cf38a7 Fix migrate: remove bogus tracking entries from broken baseline
The previous baseline blindly recorded all migrations as applied.
Now on startup the script validates every recorded migration against
the actual schema; any entry whose schema changes don't exist is
deleted so migrate() will re-run that migration.

This unblocks the existing deployment where 0001 was recorded as done
but beenvoice_client.currency was never actually added.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:11:43 +00:00
Claude e5242b37a4 Fix baseline: only mark migrations applied if schema changes already exist
Previously the baseline marked ALL migrations as done, causing 0001 to
be skipped even on databases that didn't have the currency column yet.

Now each migration is checked against a sentinel column/table before
being seeded into the tracking table. Migrations whose changes don't
exist yet are left out so migrate() runs them normally.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:08:34 +00:00
Claude 38206f34fe Handle baseline migration for databases previously set up with db:push
When switching from db:push to db:migrate on an existing database,
the migration table is empty so Drizzle tries to re-run all migrations,
failing with "relation already exists".

Detect this case (tables exist but no migration history) and seed the
__drizzle_migrations tracking table with all current migrations so
Drizzle treats them as already applied. Future migrations run normally.

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:06:08 +00:00
Claude e950abd805 Fix migration files excluded from Docker build and restore fonts
- Remove drizzle/*.sql and drizzle/*-journal from .dockerignore so
  migration files are included in the Docker build context
- Restore next/font/google imports (removed prematurely due to local
  IP being 403'd by Google Fonts; production builds should work fine)
- Update CSS font fallbacks to use proper system font stacks

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:03:45 +00:00
Claude 4c0eae4b11 Fix build: resolve Turbopack client bundle and font issues
- Move EXPENSE_CATEGORIES to ~/lib/expense-categories.ts to break
  server router import chain from client component
- Use inline import() types in trpc/react.tsx to prevent Turbopack
  from including server modules (pg, db) in the client bundle
- Replace next/font/google with system font stacks to fix build
  failures in environments without Google Fonts access

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
2026-04-05 03:00:25 +00:00
19 changed files with 990 additions and 700 deletions
-2
View File
@@ -8,8 +8,6 @@ README.md
*.log
.env*
!.env.example
drizzle/*.sql
drizzle/*-journal
.vscode
.idea
coverage
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
+7
View File
@@ -16,5 +16,12 @@
"tag": "0001_supreme_the_enforcers",
"breakpoints": true
}
,{
"idx": 2,
"version": "7",
"when": 1775400000000,
"tag": "0002_tax_deductible",
"breakpoints": true
}
]
}
+20 -5
View File
@@ -29,7 +29,7 @@ import { NumberInput } from "~/components/ui/number-input";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
interface ExpenseFormData {
date: Date;
@@ -39,6 +39,7 @@ interface ExpenseFormData {
category: string;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean;
notes: string;
clientId: string;
}
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
category: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
clientId: "",
};
@@ -89,6 +91,7 @@ export default function ExpensesPage() {
category: expense.category ?? "",
billable: expense.billable,
reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "",
clientId: expense.clientId ?? "",
});
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; }
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined };
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload);
};
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
return (
<div className="page-enter space-y-6 pb-6">
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
</PageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
@@ -127,7 +131,13 @@ export default function ExpensesPage() {
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
</CardContent>
</Card>
<Card className="col-span-2 sm:col-span-1">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
@@ -159,6 +169,7 @@ export default function ExpensesPage() {
<p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
</SelectContent>
</Select>
</div>
<div className="flex gap-6">
<div className="flex flex-wrap gap-6">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
<span className="text-sm">Billable</span>
@@ -238,6 +249,10 @@ export default function ExpensesPage() {
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
<span className="text-sm">Reimbursable</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
<span className="text-sm">Tax Deductible</span>
</label>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
@@ -39,7 +39,23 @@ export function PDFDownloadButton({
throw new Error("Invoice not found");
}
await generateInvoicePDF(invoiceData);
// Map invoice to PDF format with currency support
const pdfData = {
invoiceNumber: invoiceData.invoiceNumber,
invoicePrefix: invoiceData.invoicePrefix,
issueDate: new Date(invoiceData.issueDate),
dueDate: new Date(invoiceData.dueDate),
status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
currency: invoiceData.currency ?? "USD",
notes: invoiceData.notes,
business: invoiceData.business,
client: invoiceData.client,
items: invoiceData.items,
};
await generateInvoicePDF(pdfData);
toast.success("PDF downloaded successfully");
} catch (error) {
console.error("PDF generation error:", error);
+257 -64
View File
@@ -1,10 +1,14 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -19,18 +23,23 @@ import {
Tooltip,
ResponsiveContainer,
} from "recharts";
import { TrendingUp, DollarSign, Clock, Users } from "lucide-react";
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
export default function ReportsPage() {
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
const { data: stats } = api.dashboard.getStats.useQuery();
const now = new Date();
const isLoading = invoicesLoading || expensesLoading;
const reportData = useMemo(() => {
const now = new Date();
const currentYear = now.getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear));
// Overview data (last 12 months)
const overviewData = useMemo(() => {
if (!invoices.length) return null;
// Revenue by month (last 12 months)
const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
@@ -41,7 +50,6 @@ export default function ReportsPage() {
let totalRevenue = 0;
let totalPending = 0;
let totalHours = 0;
let overdueCount = 0;
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
@@ -52,7 +60,6 @@ export default function ReportsPage() {
} else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount;
}
if (status === "overdue") overdueCount++;
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
}
@@ -61,35 +68,122 @@ export default function ReportsPage() {
revenue,
}));
// Top clients by revenue (paid only)
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
if (status === "paid" && inv.client) {
const id = inv.client.id;
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 };
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 };
clientMap[id]!.revenue += inv.totalAmount;
clientMap[id]!.count += 1;
}
}
const topClients = Object.values(clientMap)
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
// Status breakdown
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
for (const inv of invoices) {
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
statusCount[s] = (statusCount[s] ?? 0) + 1;
}
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount };
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
}, [invoices]);
// Tax summary for selected year
const taxData = useMemo(() => {
const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
return status === "paid" && new Date(inv.issueDate).getFullYear() === year;
});
const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year);
const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0);
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0);
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
const deductibleExpenses = yearExpenses
.filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible)
.reduce((s, exp) => s + exp.amount, 0);
const netProfit = grossIncome - deductibleExpenses;
const seTaxBase = Math.max(0, netProfit) * 0.9235;
const selfEmploymentTax = seTaxBase * 0.153;
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
const federalEstimate = taxableIncome * 0.22;
const totalEstimated = selfEmploymentTax + federalEstimate;
const quarters = [1, 2, 3, 4].map((q) => {
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
return {
label: `Q${q}`,
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0),
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0),
};
});
return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses };
}, [invoices, expenses, taxYear]);
const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]);
for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear());
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
return Array.from(years).sort((a, b) => b - a);
}, [invoices, expenses, currentYear]);
const avgInvoice = invoices.length > 0
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1)
: 0;
function exportCSV() {
const rows: string[] = [
`Tax Year ${taxYear} - Income & Expense Report`,
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
"",
"INCOME (Paid Invoices)",
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
...taxData.yearInvoices.map((inv) => {
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0);
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(",");
}),
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
"",
"EXPENSES",
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) => [
new Date(exp.date).toLocaleDateString("en-US"),
`"${exp.description}"`,
`"${exp.category ?? ""}"`,
exp.amount.toFixed(2),
exp.currency,
exp.billable ? "Yes" : "No",
exp.reimbursable ? "Yes" : "No",
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No",
].join(",")),
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
"",
"TAX SUMMARY",
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
`Net Profit,${taxData.netProfit.toFixed(2)}`,
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
];
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tax-report-${taxYear}.csv`;
a.click();
URL.revokeObjectURL(url);
}
if (isLoading) {
return (
<div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
</div>
@@ -97,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 (
<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">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5">
<DollarSign className="text-primary h-4 w-4" />
</div>
<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>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5">
<Clock className="h-4 w-4 text-yellow-500" />
</div>
<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>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5">
<TrendingUp className="h-4 w-4 text-blue-500" />
</div>
<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>
@@ -141,27 +234,22 @@ export default function ReportsPage() {
<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>
<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>
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
</CardContent>
</Card>
</div>
{/* Revenue trend chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
</CardTitle>
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.revenueByMonth ?? []}>
<AreaChart data={overviewData?.revenueByMonth ?? []}>
<defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
@@ -180,20 +268,17 @@ export default function ReportsPage() {
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Top clients */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
</CardHeader>
<CardContent>
{!reportData?.topClients.length ? (
{!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={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}`} />
<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 }} />
@@ -205,39 +290,28 @@ export default function ReportsPage() {
</CardContent>
</Card>
{/* Invoice status breakdown */}
<Card>
<CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
<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">
<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 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>
)}
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
</CardContent>
</Card>
</div>
{/* Monthly stats table */}
{stats && (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
<CardContent>
<div className="divide-y">
{stats.recentInvoices.map((inv) => (
@@ -256,6 +330,125 @@ export default function ReportsPage() {
</CardContent>
</Card>
)}
</TabsContent>
{/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span>
<Select value={taxYear} onValueChange={setTaxYear}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={exportCSV} className="gap-2">
<Download className="h-4 w-4" /> Export CSV
</Button>
</div>
{/* Income */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
</div>
{taxData.taxCollected > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax Collected from Clients</span>
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-medium">
<span>Total Invoiced (inc. tax)</span>
<span>{formatCurrency(taxData.totalInvoiced)}</span>
</div>
</CardContent>
</Card>
{/* Expenses */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Expenses</span>
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
</div>
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
)}
</CardContent>
</Card>
{/* Estimated tax */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Net Profit (income deductible expenses)</span>
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total Estimated Tax</span>
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
</div>
<p className="text-muted-foreground text-xs pt-1">
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
</p>
</CardContent>
</Card>
{/* Quarterly chart */}
<Card>
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
<CardContent>
<div className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={taxData.quarters}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]}
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
/>
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
</BarChart>
</ResponsiveContainer>
</div>
<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>
);
}
+19 -7
View File
@@ -108,10 +108,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
}
};
const formatCurrency = (amount: number) => {
const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency,
}).format(amount);
};
@@ -233,7 +233,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
onClick={handlePDFExport}
disabled={isExportingPDF}
variant="default"
className="transform-none flex-shrink-0"
className="flex-shrink-0 transform-none"
>
{isExportingPDF ? (
<>
@@ -423,18 +423,30 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount)}
{formatCurrency(invoice.totalAmount, invoice.currency)}
</span>
</div>
{(invoice.taxRate ?? 0) > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax</span>
<span className="text-foreground font-medium">$0.00</span>
<span className="text-muted-foreground">
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 />
<div className="flex justify-between text-lg font-bold">
<span className="text-foreground">Total</span>
<span className="text-primary">
{formatCurrency(invoice.totalAmount)}
{formatCurrency(
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
invoice.currency,
)}
</span>
</div>
</div>
+49 -36
View File
@@ -15,13 +15,22 @@ import {
SelectValue,
} from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown } from "lucide-react";
import {
Save,
Calendar as CalendarIcon,
Tag,
User,
List,
FileText,
ChevronDown,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea";
import {
@@ -72,6 +81,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// State
const [formData, setFormData] = useState<InvoiceFormData>({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
businessId: "",
clientId: "",
issueDate: new Date(),
@@ -101,7 +111,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" });
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
type: "notes",
});
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
const { data: existingInvoice, isLoading: loadingInvoice } =
@@ -140,6 +152,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
) || [];
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
@@ -231,32 +244,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}),
}));
};
const moveItemUp = (idx: number) => {
if (idx === 0) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx - 1]) {
const temp = newItems[idx - 1]!;
newItems[idx - 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const moveItemDown = (idx: number) => {
if (idx === formData.items.length - 1) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx + 1]) {
const temp = newItems[idx + 1]!;
newItems[idx + 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const reorderItems = (newItems: InvoiceItem[]) =>
setFormData((prev) => ({ ...prev, items: newItems }));
const createInvoice = api.invoices.create.useMutation({
onSuccess: (inv) => {
@@ -333,6 +320,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
try {
const payload = {
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "",
clientId: formData.clientId,
issueDate: formData.issueDate,
@@ -453,13 +441,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
? selectedClient.defaultHourlyRate
: null;
const businessRate =
currentBusiness && "defaultHourlyRate" in currentBusiness
currentBusiness &&
"defaultHourlyRate" in currentBusiness
? currentBusiness.defaultHourlyRate
: null;
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number);
updateField(
"defaultHourlyRate",
(clientRate ?? businessRate ?? 0) as number,
);
// Auto-fill currency from client
if (selectedClient && "currency" in selectedClient && selectedClient.currency) {
updateField("currency", selectedClient.currency as string);
if (
selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField(
"currency",
selectedClient.currency as string,
);
}
}}
>
@@ -525,6 +524,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
<div className="space-y-2">
<Label>Prefix</Label>
<Input
value={formData.invoicePrefix}
onChange={(e) =>
updateField("invoicePrefix", e.target.value)
}
placeholder="#"
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tax Rate</Label>
@@ -599,7 +611,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
Use template <ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
@@ -674,9 +690,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
/>
</CardContent>
</Card>
+6 -93
View File
@@ -1,11 +1,6 @@
"use client";
import {
ChevronDown,
ChevronUp,
Plus,
Trash2,
} from "lucide-react";
import { Plus, Trash2 } from "lucide-react";
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "~/components/ui/button";
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
field: string,
value: string | number | Date,
) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onReorderItems: (items: InvoiceItem[]) => void;
className?: string;
}
@@ -49,61 +41,18 @@ interface LineItemRowProps {
field: string,
value: string | number | Date,
) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
isFirst: boolean;
isLast: boolean;
}
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
(
{
item,
index,
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
},
ref,
) => {
({ item, index, canRemove, onRemove, onUpdate }, ref) => {
return (
<div
ref={ref}
className={cn(
"bg-card border hidden rounded-xl p-4 md:block transition-all shadow-sm group hover:border-primary/20",
"bg-card group hover:border-primary/20 hidden rounded-xl border p-4 shadow-sm transition-all md:block",
)}
>
<div className="flex items-center gap-3">
{/* Arrow Controls */}
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-6 w-6 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-6 w-6 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Main Content */}
<div className="flex-1 space-y-3">
{/* Description */}
@@ -136,7 +85,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
min={0}
step={0.25}
width="auto"
className="h-9 flex-1 min-w-[100px] font-mono"
className="h-9 min-w-[100px] flex-1 font-mono"
suffix="h"
/>
@@ -148,7 +97,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
step={1}
prefix="$"
width="auto"
className="h-9 flex-1 min-w-[100px] font-mono"
className="h-9 min-w-[100px] flex-1 font-mono"
/>
{/* Amount */}
@@ -185,10 +134,6 @@ function MobileLineItem({
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: LineItemRowProps) {
return (
<motion.div
@@ -253,28 +198,6 @@ function MobileLineItem({
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-8 w-8 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-8 w-8 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
@@ -310,8 +233,6 @@ export function InvoiceLineItems({
onAddItem,
onRemoveItem,
onUpdateItem,
onMoveUp,
onMoveDown,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
@@ -337,10 +258,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
</motion.div>
@@ -351,10 +268,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
</React.Fragment>
))}
@@ -368,7 +281,7 @@ export function InvoiceLineItems({
type="button"
variant="outline"
onClick={onAddItem}
className="w-full border-dashed border-border py-8 text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 transition-all"
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 w-full border-dashed py-8 transition-all"
>
<Plus className="mr-2 h-4 w-4" />
Add Line Item
@@ -14,10 +14,11 @@ interface InvoiceWorkspaceProps {
setViewMode: (mode: "list" | "calendar") => void;
addItem: (date?: Date) => void;
removeItem: (index: number) => void;
updateItem: (index: number, field: string, value: string | number | Date) => void;
moveItemUp: (index: number) => void;
moveItemDown: (index: number) => void;
reorderItems: (items: InvoiceFormData['items']) => void;
updateItem: (
index: number,
field: string,
value: string | number | Date,
) => void;
className?: string;
}
@@ -28,61 +29,55 @@ export function InvoiceWorkspace({
addItem,
removeItem,
updateItem,
moveItemUp,
moveItemDown,
reorderItems,
className,
}: InvoiceWorkspaceProps) {
return (
<div className={cn("flex flex-col h-full", className)}>
<div className={cn("flex h-full flex-col", className)}>
{/* Workspace Header / View Toggle */}
<div className="flex items-center justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10">
<div className="bg-background/50 sticky top-0 z-10 flex items-center justify-between border-b p-4 backdrop-blur-sm">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">
{viewMode === 'list' ? 'Line Items' : 'Timesheet'}
{viewMode === "list" ? "Line Items" : "Timesheet"}
</h2>
<div className="text-sm text-muted-foreground ml-2">
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'}
<div className="text-muted-foreground ml-2 text-sm">
{formData.items.length}{" "}
{formData.items.length === 1 ? "entry" : "entries"}
</div>
</div>
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
variant={viewMode === "list" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode('list')}
onClick={() => setViewMode("list")}
className="h-8 gap-2 text-xs"
>
<List className="w-3.5 h-3.5" />
<List className="h-3.5 w-3.5" />
List
</Button>
<Button
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
variant={viewMode === "calendar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode('calendar')}
onClick={() => setViewMode("calendar")}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="w-3.5 h-3.5" />
<CalendarIcon className="h-3.5 w-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="flex-1 overflow-hidden relative">
<div className="relative flex-1 overflow-hidden">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === 'list' ? (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-background/40 backdrop-blur-md rounded-xl border border-white/10 p-1">
{viewMode === "list" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
className="p-4"
/>
</div>
+1
View File
@@ -14,6 +14,7 @@ export interface InvoiceItem {
export interface InvoiceFormData {
invoiceNumber: string;
invoicePrefix: string;
businessId: string;
clientId: string;
issueDate: Date;
+11
View File
@@ -0,0 +1,11 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+152 -227
View File
@@ -5,11 +5,31 @@ import {
View,
Image,
StyleSheet,
Font,
pdf,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
import React from "react";
Font.register({
family: "Frutiger",
fonts: [
{
src: "/fonts/frutiger/Frutiger.ttf",
fontWeight: "normal",
},
{
src: "/fonts/frutiger/Frutiger_bold.ttf",
fontWeight: "bold",
},
],
});
Font.register({
family: "Frutiger-Bold",
src: "/fonts/frutiger/Frutiger_bold.ttf",
});
// Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void {
try {
@@ -56,11 +76,13 @@ function downloadBlob(blob: Blob, filename: string): void {
interface InvoiceData {
invoiceNumber: string;
invoicePrefix?: string | null;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
currency?: string | null;
notes?: string | null;
business?: {
name: string;
@@ -100,7 +122,7 @@ const styles = StyleSheet.create({
page: {
flexDirection: "column",
backgroundColor: "#ffffff",
fontFamily: "Helvetica",
fontFamily: "Frutiger",
fontSize: 10,
paddingTop: 40,
paddingBottom: 80,
@@ -127,7 +149,7 @@ const styles = StyleSheet.create({
},
businessName: {
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
fontSize: 18,
color: "#0f0f0f",
marginBottom: 4,
@@ -135,7 +157,7 @@ const styles = StyleSheet.create({
businessInfo: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginBottom: 3,
@@ -143,7 +165,7 @@ const styles = StyleSheet.create({
businessAddress: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginTop: 4,
@@ -156,14 +178,14 @@ const styles = StyleSheet.create({
invoiceTitle: {
fontSize: 28,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
marginBottom: 8,
},
invoiceNumber: {
fontSize: 14,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#374151",
marginBottom: 4,
},
@@ -172,7 +194,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 11,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
textAlign: "center",
},
@@ -200,13 +222,13 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
marginBottom: 12,
},
clientName: {
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
fontSize: 12,
color: "#0f0f0f",
marginBottom: 2,
@@ -214,7 +236,7 @@ const styles = StyleSheet.create({
clientInfo: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginBottom: 2,
@@ -222,7 +244,7 @@ const styles = StyleSheet.create({
clientAddress: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginTop: 4,
@@ -236,14 +258,14 @@ const styles = StyleSheet.create({
detailLabel: {
fontSize: 11,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
flex: 1,
},
detailValue: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
flex: 1,
textAlign: "right",
@@ -259,21 +281,21 @@ const styles = StyleSheet.create({
notesTitle: {
fontSize: 11,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
marginBottom: 6,
},
notesContent: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#374151",
lineHeight: 1.4,
},
businessContact: {
fontSize: 9,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
},
@@ -297,7 +319,7 @@ const styles = StyleSheet.create({
abridgedBusinessName: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
},
@@ -309,13 +331,13 @@ const styles = StyleSheet.create({
abridgedInvoiceTitle: {
fontSize: 14,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
},
abridgedInvoiceNumber: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#374151",
},
@@ -335,7 +357,7 @@ const styles = StyleSheet.create({
tableHeaderCell: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#374151",
paddingHorizontal: 4,
},
@@ -380,7 +402,7 @@ const styles = StyleSheet.create({
color: "#0f0f0f",
paddingHorizontal: 4,
paddingVertical: 2,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
},
tableCellDate: {
@@ -396,7 +418,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 2,
textAlign: "left",
flexWrap: "wrap",
fontFamily: "Helvetica",
fontFamily: "Frutiger",
},
tableCellHours: {
@@ -454,7 +476,7 @@ const styles = StyleSheet.create({
totalLabel: {
fontSize: 11,
color: "#6b7280",
fontFamily: "Helvetica",
fontFamily: "Frutiger",
},
totalAmount: {
@@ -472,7 +494,7 @@ const styles = StyleSheet.create({
finalTotalLabel: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
},
@@ -484,7 +506,7 @@ const styles = StyleSheet.create({
itemCount: {
fontSize: 9,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#9ca3af",
textAlign: "center",
marginTop: 6,
@@ -511,16 +533,16 @@ const styles = StyleSheet.create({
pageNumber: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
},
});
// Helper functions
const formatCurrency = (amount: number) => {
const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency,
}).format(amount);
};
@@ -565,206 +587,86 @@ const getStatusStyle = (status: string) => {
}
};
// Helper function to estimate text height based on content and width
function estimateTextHeight(
text: string,
maxWidth: number,
fontSize = 10,
lineHeight = 1.3,
): number {
if (!text) return fontSize * lineHeight;
// Rough character width estimation for Helvetica at given font size
const avgCharWidth = fontSize * 0.6;
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
if (maxCharsPerLine <= 0) return fontSize * lineHeight;
const lines = Math.ceil(text.length / maxCharsPerLine);
return lines * fontSize * lineHeight;
function pageContentBudget(isFirstPage: boolean, hasNotes: boolean): number {
// 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
let h = 672;
h -= isFirstPage ? 285 : 50; // dense vs abridged header
h -= hasNotes ? 185 : 130; // totals box (+ notes section if present)
h -= 28; // table header row
return h;
}
// Calculate estimated height for a table row based on actual content
function calculateRowHeight(
item: NonNullable<InvoiceData["items"]>[0],
function estimateRowHeight(
item: NonNullable<NonNullable<InvoiceData["items"]>[0]>,
showRate: boolean,
): number {
if (!item) return 18; // fallback
const basePadding = 8; // Row padding
const fontSize = 10;
const lineHeight = 1.3;
// Description column is 40% of table width
// Table width is roughly 512 points (letter width - margins)
const descriptionWidth = 512 * 0.4;
const descriptionHeight = estimateTextHeight(
item.description,
descriptionWidth,
fontSize,
lineHeight,
);
// Minimum row height for other columns
const minRowHeight = fontSize * lineHeight;
// Row height is the maximum of description height and minimum height, plus padding
// Ensure minimum row height of 24 points for readability
return Math.max(descriptionHeight, minRowHeight, 24) + basePadding;
// 532pt usable width (612 - 80pt horizontal padding); description takes 40% or 48%
const descColWidth = 532 * (showRate ? 0.4 : 0.48);
// Frutiger at 10pt: 0.45em gives ~47 chars/line, matching real wrap behaviour
const charsPerLine = Math.max(1, Math.floor(descColWidth / (10 * 0.45)));
const lines = Math.ceil((item.description.length || 1) / charsPerLine);
// row paddingVertical:6 (×2=12) + cell paddingVertical:4 (×2=8) = 20pt overhead,
// but react-pdf measures the line box at slightly under full lineHeight, so 16pt in practice
return lines * 10 * 1.4 + 16;
}
// Dynamic pagination calculation based on actual content
function calculateItemsForPage(
items: NonNullable<InvoiceData["items"]>,
startIndex: number,
isFirstPage: boolean,
hasNotes: boolean,
): number {
// Estimate available space in points (1 point = 1/72 inch)
const pageHeight = 792; // Letter size height in points
const margins = 80; // Top + bottom margins
const footerSpace = 60; // Footer space
let availableHeight = pageHeight - margins - footerSpace;
if (isFirstPage) {
// Dense header takes significant space
availableHeight -= 300; // Dense header space
} else {
// Abridged header is smaller
availableHeight -= 60; // Abridged header space
}
if (hasNotes) {
// Last page needs space for totals and notes
availableHeight -= 200; // Totals + notes space (much more conservative)
} else {
// Regular page just needs totals space
availableHeight -= 150; // Totals space only (much more conservative)
}
// Table header takes space
availableHeight -= 30; // Table header
// Calculate how many items can fit based on actual row heights
let usedHeight = 0;
let itemCount = 0;
for (let i = startIndex; i < items.length; i++) {
const item = items[i];
if (!item) continue;
const rowHeight = calculateRowHeight(item);
if (usedHeight + rowHeight > availableHeight) {
break; // This item won't fit
}
usedHeight += rowHeight;
itemCount++;
}
return Math.max(1, itemCount); // Always return at least 1 item
}
// Fallback function for backward compatibility
function calculateItemsPerPage(
isFirstPage: boolean,
hasNotes: boolean,
): number {
// Estimate available space in points (1 point = 1/72 inch)
const pageHeight = 792; // Letter size height in points
const margins = 80; // Top + bottom margins
const footerSpace = 60; // Footer space
let availableHeight = pageHeight - margins - footerSpace;
if (isFirstPage) {
// Dense header takes significant space
availableHeight -= 300; // Dense header space
} else {
// Abridged header is smaller
availableHeight -= 60; // Abridged header space
}
if (hasNotes) {
// Last page needs space for totals and notes
availableHeight -= 200; // Totals + notes space (much more conservative)
} else {
// Regular page just needs totals space
availableHeight -= 150; // Totals space only (much more conservative)
}
// Table header takes space
availableHeight -= 30; // Table header
// Conservative estimate using average row height
const avgRowHeight = 24; // Increased from 18 to account for potential wrapping
return Math.max(1, Math.floor(availableHeight / avgRowHeight));
}
// Dynamic pagination function
function paginateItems(
items: NonNullable<InvoiceData["items"]>,
hasNotes = false,
showRate = true,
) {
const validItems = items.filter(Boolean);
const pages: Array<typeof validItems> = [];
const validItems = items.filter(Boolean) as NonNullable<typeof items[0]>[];
if (validItems.length === 0) return [[]];
if (validItems.length === 0) {
return [[]];
const rowHeights = validItems.map((item) => estimateRowHeight(item, showRate));
function pack(startIdx: number, budget: number): number {
let used = 0, count = 0;
for (let i = startIdx; i < validItems.length; i++) {
if (used + rowHeights[i]! > budget) break;
used += rowHeights[i]!;
count++;
}
return Math.max(1, count);
}
let currentIndex = 0;
let pageIndex = 0;
const pages: (typeof validItems)[] = [];
let idx = 0;
while (currentIndex < validItems.length) {
const isFirstPage = pageIndex === 0;
const remainingItems = validItems.length - currentIndex;
while (idx < validItems.length) {
const isFirst = pages.length === 0;
const countFull = pack(idx, pageContentBudget(isFirst, false));
// Determine if this could be the last page with simple calculation
const maxPossibleItems = calculateItemsPerPage(isFirstPage, false);
const wouldBeLastPage =
currentIndex + maxPossibleItems >= validItems.length;
// Calculate items per page, accounting for notes space if this is likely the last page
let itemsPerPage = calculateItemsForPage(
validItems,
currentIndex,
isFirstPage,
wouldBeLastPage && hasNotes,
);
// Fallback to conservative calculation if dynamic fails
if (itemsPerPage === 0) {
itemsPerPage = calculateItemsPerPage(
isFirstPage,
wouldBeLastPage && hasNotes,
);
if (idx + countFull >= validItems.length) {
// All remaining items fit — if there are notes, verify they also fit with the notes reservation
if (hasNotes) {
const countWithNotes = pack(idx, pageContentBudget(isFirst, true));
if (idx + countWithNotes >= validItems.length) {
pages.push(validItems.slice(idx));
break;
}
// Ensure we don't have tiny orphan pages
if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 2) {
itemsPerPage = Math.max(1, itemsPerPage - 1);
// Notes don't fit alongside all items — push what fits, notes go on next page
pages.push(validItems.slice(idx, idx + countWithNotes));
idx += countWithNotes;
} else {
pages.push(validItems.slice(idx));
break;
}
} else {
pages.push(validItems.slice(idx, idx + countFull));
idx += countFull;
}
// Never take more items than we have
itemsPerPage = Math.min(itemsPerPage, remainingItems);
const pageItems = validItems.slice(
currentIndex,
currentIndex + itemsPerPage,
);
pages.push(pageItems);
currentIndex += itemsPerPage;
pageIndex++;
}
return pages;
}
function getColumnWidths(showRate: boolean) {
return showRate
? { date: "15%", description: "40%", hours: "12%", rate: "15%", amount: "18%" }
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
}
// Dense header component (first page)
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
<View style={styles.denseHeader}>
@@ -807,7 +709,10 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
<View style={styles.invoiceSection}>
<Text style={styles.invoiceTitle}>INVOICE</Text>
<Text style={styles.invoiceNumber}>#{invoice.invoiceNumber}</Text>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
<View style={getStatusStyle(invoice.status)}>
<Text>{getStatusLabel(invoice.status)}</Text>
</View>
@@ -873,25 +778,33 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
</Text>
<View style={styles.abridgedInvoiceInfo}>
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
<Text style={styles.abridgedInvoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
</View>
</View>
);
// Table header component
const TableHeader: React.FC = () => (
const TableHeader: React.FC<{ showRate: boolean }> = ({ showRate }) => {
const cols = getColumnWidths(showRate);
return (
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderCell, styles.tableHeaderDate]}>Date</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderDescription]}>
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
Description
</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours]}>Hours</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours, { width: cols.hours }]}>Hours</Text>
{showRate && (
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount]}>
)}
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount, { width: cols.amount }]}>
Amount
</Text>
</View>
);
);
};
// Footer component
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
@@ -922,7 +835,7 @@ const Footer: React.FC = () => (
<Text
style={{
fontSize: 9,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
marginLeft: 8,
}}
@@ -944,8 +857,10 @@ const TotalsSection: React.FC<{
invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>;
}> = ({ invoice, items }) => {
const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
return (
<View style={styles.totalsContainer}>
@@ -953,7 +868,7 @@ const TotalsSection: React.FC<{
<Text
style={{
fontSize: 11,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
textAlign: "center",
marginBottom: 8,
@@ -965,20 +880,24 @@ const TotalsSection: React.FC<{
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text>
<Text style={styles.totalAmount}>
{formatCurrency(subtotal, currency)}
</Text>
</View>
{invoice.taxRate > 0 && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
<Text style={styles.totalAmount}>{formatCurrency(taxAmount)}</Text>
<Text style={styles.totalAmount}>
{formatCurrency(taxAmount, currency)}
</Text>
</View>
)}
<View style={styles.finalTotalRow}>
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
<Text style={styles.finalTotalAmount}>
{formatCurrency(invoice.totalAmount)}
{formatCurrency(total, currency)}
</Text>
</View>
@@ -993,7 +912,10 @@ const TotalsSection: React.FC<{
// Main PDF component
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const items = invoice.items?.filter(Boolean) ?? [];
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate);
const paginatedItems = paginateItems(items, Boolean(invoice.notes), showRate);
return (
<Document>
@@ -1014,7 +936,7 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{/* Table */}
{hasItems && (
<View style={styles.tableContainer}>
<TableHeader />
<TableHeader showRate={showRate} />
{pageItems.map(
(item, index) =>
item && (
@@ -1025,27 +947,30 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
index % 2 === 0 ? styles.tableRowAlt : {},
]}
>
<Text style={[styles.tableCell, styles.tableCellDate]}>
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
{formatDate(item.date)}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellDescription,
{ width: cols.description },
]}
>
{item.description}
</Text>
<Text style={[styles.tableCell, styles.tableCellHours]}>
<Text style={[styles.tableCell, styles.tableCellHours, { width: cols.hours }]}>
{item.hours}
</Text>
{showRate && (
<Text style={[styles.tableCell, styles.tableCellRate]}>
{formatCurrency(item.rate)}
{formatCurrency(item.rate, currency)}
</Text>
)}
<Text
style={[styles.tableCell, styles.tableCellAmount]}
style={[styles.tableCell, styles.tableCellAmount, { width: cols.amount }]}
>
{formatCurrency(item.amount)}
{formatCurrency(item.amount, currency)}
</Text>
</View>
),
+3 -11
View File
@@ -3,18 +3,9 @@ import { eq, and, desc } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
export { EXPENSE_CATEGORIES };
const createExpenseSchema = z.object({
date: z.date(),
@@ -24,6 +15,7 @@ const createExpenseSchema = z.object({
category: z.string().optional().or(z.literal("")),
billable: z.boolean().default(false),
reimbursable: z.boolean().default(false),
taxDeductible: z.boolean().default(false),
notes: z.string().optional().or(z.literal("")),
clientId: z.string().optional().or(z.literal("")),
businessId: z.string().optional().or(z.literal("")),
+30 -8
View File
@@ -18,6 +18,7 @@ const invoiceItemSchema = z.object({
const createInvoiceSchema = z.object({
invoiceNumber: z.string().min(1, "Invoice number is required"),
invoicePrefix: z.string().optional().default("#"),
businessId: z
.string()
.min(1, "Business is required")
@@ -416,11 +417,17 @@ export const invoicesRouter = createTRPCRouter({
});
if (!invoice) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
throw new TRPCError({
code: "NOT_FOUND",
message: "Invoice not found",
});
}
if (invoice.createdById !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" });
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to update this invoice",
});
}
await ctx.db
@@ -428,18 +435,27 @@ export const invoicesRouter = createTRPCRouter({
.set({ status: input.status, updatedAt: new Date() })
.where(eq(invoices.id, input.id));
return { success: true, message: `Invoice status updated to ${input.status}` };
return {
success: true,
message: `Invoice status updated to ${input.status}`,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update invoice status",
cause: error,
});
}
}),
bulkUpdateStatus: protectedProcedure
.input(z.object({
.input(
z.object({
ids: z.array(z.string()).min(1),
status: z.enum(["draft", "sent", "paid"]),
}))
}),
)
.mutation(async ({ ctx, input }) => {
// Only update invoices owned by this user
const owned = await ctx.db.query.invoices.findMany({
@@ -452,7 +468,10 @@ export const invoicesRouter = createTRPCRouter({
.map((inv) => inv.id);
if (ownedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
throw new TRPCError({
code: "NOT_FOUND",
message: "No matching invoices found",
});
}
await ctx.db
@@ -476,7 +495,10 @@ export const invoicesRouter = createTRPCRouter({
.map((inv) => inv.id);
if (ownedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
throw new TRPCError({
code: "NOT_FOUND",
message: "No matching invoices found",
});
}
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
+139
View File
@@ -6,6 +6,10 @@
* This applies any pending migrations from the drizzle/ directory to the
* database specified by DATABASE_URL. It is safe to run multiple times —
* 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";
@@ -17,6 +21,8 @@ import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { fileURLToPath } from "url";
const databaseUrl = process.env.DATABASE_URL;
@@ -36,9 +42,142 @@ const pool = new 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);
try {
await baselineIfNeeded(pool);
await migrate(db, { migrationsFolder });
console.log("[migrate] All migrations applied successfully");
} catch (err) {
+52 -15
View File
@@ -1,7 +1,6 @@
import { relations, sql } from "drizzle-orm";
import { index, pgTableCreator } from "drizzle-orm/pg-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({
emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetTokenExpiry: d.timestamp(),
@@ -47,7 +50,11 @@ export const usersRelations = relations(users, ({ many }) => ({
export const accounts = createTable(
"account",
(d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
id: d
.text()
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
userId: d
.varchar({ length: 255 })
.notNull()
@@ -62,11 +69,13 @@ export const accounts = createTable(
idToken: d.text(),
password: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}),
(t) => [
index("account_userId_idx").on(t.userId),
],
(t) => [index("account_userId_idx").on(t.userId)],
);
export const accountsRelations = relations(accounts, ({ one }) => ({
@@ -76,7 +85,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
export const sessions = createTable(
"session",
(d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
id: d
.text()
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
userId: d
.varchar({ length: 255 })
.notNull()
@@ -86,7 +99,11 @@ export const sessions = createTable(
ipAddress: d.text(), // Matched DB: text
userAgent: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}),
(t) => [index("session_userId_idx").on(t.userId)],
);
@@ -98,12 +115,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export const verificationTokens = createTable(
"verification_token",
(d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
id: d
.text()
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
identifier: d.varchar({ length: 255 }).notNull(),
value: d.varchar({ length: 255 }).notNull(),
expiresAt: d.timestamp().notNull(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}),
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
);
@@ -111,14 +136,25 @@ export const verificationTokens = createTable(
export const ssoProviders = createTable(
"sso_provider",
(d) => ({
id: d.varchar({ length: 255 }).notNull().primaryKey().$defaultFn(() => crypto.randomUUID()),
id: d
.varchar({ length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
providerId: d.varchar({ length: 255 }).notNull().unique(),
userId: d.varchar({ length: 255 }).notNull().references(() => users.id),
userId: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
oidcConfig: d.text(),
samlConfig: d.text(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}),
(t) => [index("sso_provider_user_id_idx").on(t.userId)],
);
@@ -230,6 +266,7 @@ export const invoices = createTable(
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceNumber: d.varchar({ length: 100 }).notNull(),
invoicePrefix: d.varchar({ length: 20 }).default("#"),
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
clientId: d
.varchar({ length: 255 })
@@ -334,6 +371,7 @@ export const expenses = createTable(
category: d.varchar({ length: 100 }),
billable: d.boolean().default(false).notNull(),
reimbursable: d.boolean().default(false).notNull(),
taxDeductible: d.boolean().default(false).notNull(),
notes: d.varchar({ length: 500 }),
createdById: d
.varchar({ length: 255 })
@@ -410,4 +448,3 @@ export const invoiceTemplatesRelations = relations(
}),
}),
);
+3 -3
View File
@@ -84,9 +84,9 @@
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--font-sans: var(--font-sans), sans-serif;
--font-heading: var(--font-heading), serif;
--font-mono: var(--font-geist-mono), monospace;
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-heading), ui-serif, Georgia, serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
+4 -4
View File
@@ -7,7 +7,6 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
@@ -22,21 +21,22 @@ const getQueryClient = () => {
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.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterInputs = inferRouterInputs<import("~/server/api/root").AppRouter>;
/**
* Inference helper for outputs.
*
* @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 }) {
const queryClient = getQueryClient();