mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b582b6c88e | |||
| 00e066ca4e | |||
| 4214a4b4de | |||
| af392e1bc9 | |||
| 74f9696023 | |||
| 1f76cf38a7 | |||
| e5242b37a4 | |||
| 38206f34fe | |||
| e950abd805 | |||
| 4c0eae4b11 |
@@ -8,8 +8,6 @@ README.md
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
drizzle/*.sql
|
|
||||||
drizzle/*-journal
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
coverage
|
coverage
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_expense" ADD COLUMN "taxDeductible" boolean DEFAULT false NOT NULL;
|
||||||
@@ -16,5 +16,12 @@
|
|||||||
"tag": "0001_supreme_the_enforcers",
|
"tag": "0001_supreme_the_enforcers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
,{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775400000000,
|
||||||
|
"tag": "0002_tax_deductible",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ import { NumberInput } from "~/components/ui/number-input";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses";
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
interface ExpenseFormData {
|
interface ExpenseFormData {
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -39,6 +39,7 @@ interface ExpenseFormData {
|
|||||||
category: string;
|
category: string;
|
||||||
billable: boolean;
|
billable: boolean;
|
||||||
reimbursable: boolean;
|
reimbursable: boolean;
|
||||||
|
taxDeductible: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ const defaultForm: ExpenseFormData = {
|
|||||||
category: "",
|
category: "",
|
||||||
billable: false,
|
billable: false,
|
||||||
reimbursable: false,
|
reimbursable: false,
|
||||||
|
taxDeductible: false,
|
||||||
notes: "",
|
notes: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
};
|
};
|
||||||
@@ -89,6 +91,7 @@ export default function ExpensesPage() {
|
|||||||
category: expense.category ?? "",
|
category: expense.category ?? "",
|
||||||
billable: expense.billable,
|
billable: expense.billable,
|
||||||
reimbursable: expense.reimbursable,
|
reimbursable: expense.reimbursable,
|
||||||
|
taxDeductible: expense.taxDeductible ?? false,
|
||||||
notes: expense.notes ?? "",
|
notes: expense.notes ?? "",
|
||||||
clientId: expense.clientId ?? "",
|
clientId: expense.clientId ?? "",
|
||||||
});
|
});
|
||||||
@@ -97,13 +100,14 @@ export default function ExpensesPage() {
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
||||||
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined };
|
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible };
|
||||||
if (editId) update.mutate({ id: editId, ...payload });
|
if (editId) update.mutate({ id: editId, ...payload });
|
||||||
else create.mutate(payload);
|
else create.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||||
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
||||||
|
const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<div className="page-enter space-y-6 pb-6">
|
||||||
@@ -114,7 +118,7 @@ export default function ExpensesPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
||||||
@@ -127,7 +131,13 @@ export default function ExpensesPage() {
|
|||||||
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="col-span-2 sm:col-span-1">
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Deductible</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-green-600">{formatCurrency(deductibleTotal)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||||
@@ -159,6 +169,7 @@ export default function ExpensesPage() {
|
|||||||
<p className="font-medium">{expense.description}</p>
|
<p className="font-medium">{expense.description}</p>
|
||||||
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
|
||||||
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
|
||||||
|
{expense.taxDeductible && <Badge variant="outline" className="text-xs text-green-600 border-green-300">Tax Deductible</Badge>}
|
||||||
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
@@ -229,7 +240,7 @@ export default function ExpensesPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<label className="flex cursor-pointer items-center gap-2">
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||||
<span className="text-sm">Billable</span>
|
<span className="text-sm">Billable</span>
|
||||||
@@ -238,6 +249,10 @@ export default function ExpensesPage() {
|
|||||||
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||||
<span className="text-sm">Reimbursable</span>
|
<span className="text-sm">Reimbursable</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<Checkbox checked={form.taxDeductible} onCheckedChange={(v) => setForm((p) => ({ ...p, taxDeductible: !!v }))} />
|
||||||
|
<span className="text-sm">Tax Deductible</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Notes (optional)</Label>
|
<Label>Notes (optional)</Label>
|
||||||
|
|||||||
@@ -39,7 +39,23 @@ export function PDFDownloadButton({
|
|||||||
throw new Error("Invoice not found");
|
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");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
console.error("PDF generation error:", error);
|
||||||
|
|||||||
+358
-165
@@ -1,10 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { formatCurrency } from "~/lib/currency";
|
import { formatCurrency } from "~/lib/currency";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
@@ -19,18 +23,23 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { TrendingUp, DollarSign, Clock, Users } from "lucide-react";
|
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
|
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
|
||||||
|
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
|
||||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
const now = new Date();
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
|
|
||||||
const reportData = useMemo(() => {
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
|
// Overview data (last 12 months)
|
||||||
|
const overviewData = useMemo(() => {
|
||||||
if (!invoices.length) return null;
|
if (!invoices.length) return null;
|
||||||
|
|
||||||
// Revenue by month (last 12 months)
|
|
||||||
const monthMap: Record<string, number> = {};
|
const monthMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
@@ -41,7 +50,6 @@ export default function ReportsPage() {
|
|||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let totalPending = 0;
|
let totalPending = 0;
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
let overdueCount = 0;
|
|
||||||
|
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
@@ -52,7 +60,6 @@ export default function ReportsPage() {
|
|||||||
} else if (status === "sent" || status === "overdue") {
|
} else if (status === "sent" || status === "overdue") {
|
||||||
totalPending += inv.totalAmount;
|
totalPending += inv.totalAmount;
|
||||||
}
|
}
|
||||||
if (status === "overdue") overdueCount++;
|
|
||||||
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,35 +68,122 @@ export default function ReportsPage() {
|
|||||||
revenue,
|
revenue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Top clients by revenue (paid only)
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
|
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
if (status === "paid" && inv.client) {
|
if (status === "paid" && inv.client) {
|
||||||
const id = inv.client.id;
|
const id = inv.client.id;
|
||||||
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 };
|
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 };
|
||||||
clientMap[id]!.revenue += inv.totalAmount;
|
clientMap[id]!.revenue += inv.totalAmount;
|
||||||
clientMap[id]!.count += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const topClients = Object.values(clientMap)
|
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
|
||||||
.sort((a, b) => b.revenue - a.revenue)
|
|
||||||
.slice(0, 6);
|
|
||||||
|
|
||||||
// Status breakdown
|
|
||||||
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount };
|
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
|
||||||
}, [invoices]);
|
}, [invoices]);
|
||||||
|
|
||||||
|
// Tax summary for selected year
|
||||||
|
const taxData = useMemo(() => {
|
||||||
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
|
const yearInvoices = invoices.filter((inv) => {
|
||||||
|
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||||
|
return status === "paid" && new Date(inv.issueDate).getFullYear() === year;
|
||||||
|
});
|
||||||
|
const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year);
|
||||||
|
|
||||||
|
const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0);
|
||||||
|
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0);
|
||||||
|
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
const deductibleExpenses = yearExpenses
|
||||||
|
.filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible)
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
|
||||||
|
const netProfit = grossIncome - deductibleExpenses;
|
||||||
|
const seTaxBase = Math.max(0, netProfit) * 0.9235;
|
||||||
|
const selfEmploymentTax = seTaxBase * 0.153;
|
||||||
|
const taxableIncome = Math.max(0, netProfit - selfEmploymentTax / 2);
|
||||||
|
const federalEstimate = taxableIncome * 0.22;
|
||||||
|
const totalEstimated = selfEmploymentTax + federalEstimate;
|
||||||
|
|
||||||
|
const quarters = [1, 2, 3, 4].map((q) => {
|
||||||
|
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
||||||
|
return {
|
||||||
|
label: `Q${q}`,
|
||||||
|
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0),
|
||||||
|
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses };
|
||||||
|
}, [invoices, expenses, taxYear]);
|
||||||
|
|
||||||
|
const availableYears = useMemo(() => {
|
||||||
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
|
for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear());
|
||||||
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
|
}, [invoices, expenses, currentYear]);
|
||||||
|
|
||||||
|
const avgInvoice = invoices.length > 0
|
||||||
|
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const rows: string[] = [
|
||||||
|
`Tax Year ${taxYear} - Income & Expense Report`,
|
||||||
|
`Generated: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||||
|
"",
|
||||||
|
"INCOME (Paid Invoices)",
|
||||||
|
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
||||||
|
...taxData.yearInvoices.map((inv) => {
|
||||||
|
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0);
|
||||||
|
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(",");
|
||||||
|
}),
|
||||||
|
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
||||||
|
"",
|
||||||
|
"EXPENSES",
|
||||||
|
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||||
|
...taxData.yearExpenses.map((exp) => [
|
||||||
|
new Date(exp.date).toLocaleDateString("en-US"),
|
||||||
|
`"${exp.description}"`,
|
||||||
|
`"${exp.category ?? ""}"`,
|
||||||
|
exp.amount.toFixed(2),
|
||||||
|
exp.currency,
|
||||||
|
exp.billable ? "Yes" : "No",
|
||||||
|
exp.reimbursable ? "Yes" : "No",
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No",
|
||||||
|
].join(",")),
|
||||||
|
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
||||||
|
"",
|
||||||
|
"TAX SUMMARY",
|
||||||
|
`Gross Income,${taxData.grossIncome.toFixed(2)}`,
|
||||||
|
`Tax Collected,${taxData.taxCollected.toFixed(2)}`,
|
||||||
|
`Deductible Expenses,${taxData.deductibleExpenses.toFixed(2)}`,
|
||||||
|
`Net Profit,${taxData.netProfit.toFixed(2)}`,
|
||||||
|
`Est. Self-Employment Tax (15.3%),${taxData.selfEmploymentTax.toFixed(2)}`,
|
||||||
|
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
||||||
|
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
||||||
|
];
|
||||||
|
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tax-report-${taxYear}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<div className="page-enter space-y-6">
|
||||||
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
|
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,165 +191,264 @@ export default function ReportsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<div className="page-enter space-y-6 pb-6">
|
||||||
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
|
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
||||||
|
|
||||||
{/* KPI cards */}
|
<Tabs defaultValue="overview">
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<Card>
|
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
|
||||||
<CardContent className="p-4">
|
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
|
||||||
<div className="flex items-center gap-2">
|
</TabsList>
|
||||||
<div className="bg-primary/10 rounded p-1.5">
|
|
||||||
<DollarSign className="text-primary h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-yellow-500/10 rounded p-1.5">
|
|
||||||
<Clock className="h-4 w-4 text-yellow-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-blue-500/10 rounded p-1.5">
|
|
||||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-green-500/10 rounded p-1.5">
|
|
||||||
<Users className="h-4 w-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Revenue trend chart */}
|
{/* ── OVERVIEW TAB ── */}
|
||||||
<Card>
|
<TabsContent value="overview" className="mt-4 space-y-6">
|
||||||
<CardHeader>
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Card>
|
||||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
<CardContent className="p-4">
|
||||||
</CardTitle>
|
<div className="flex items-center gap-2">
|
||||||
</CardHeader>
|
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
||||||
<CardContent>
|
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||||
<div className="h-48 w-full md:h-64">
|
</div>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
|
||||||
<AreaChart data={reportData?.revenueByMonth ?? []}>
|
</CardContent>
|
||||||
<defs>
|
</Card>
|
||||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
<Card>
|
||||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
<CardContent className="p-4">
|
||||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
<div className="flex items-center gap-2">
|
||||||
</linearGradient>
|
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
||||||
</defs>
|
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
</div>
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
|
||||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
</CardContent>
|
||||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
</Card>
|
||||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
<Card>
|
||||||
</AreaChart>
|
<CardContent className="p-4">
|
||||||
</ResponsiveContainer>
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<Card>
|
||||||
{/* Top clients */}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
||||||
<CardHeader>
|
</CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardContent>
|
||||||
<Users className="h-5 w-5" /> Top Clients by Revenue
|
<div className="h-48 w-full md:h-64">
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!reportData?.topClients.length ? (
|
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="h-48 md:h-56">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={reportData.topClients} layout="vertical">
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
<defs>
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||||
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!overviewData?.topClients.length ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 md:h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={overviewData.topClients} layout="vertical">
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
||||||
|
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||||
|
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
|
||||||
|
<div key={status} className="flex items-center justify-between">
|
||||||
|
<StatusBadge status={status as never} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||||
|
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="divide-y">
|
||||||
|
{stats.recentInvoices.map((inv) => (
|
||||||
|
<div key={inv.id} className="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
||||||
|
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── TAX SUMMARY TAB ── */}
|
||||||
|
<TabsContent value="tax" className="mt-4 space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
|
<Select value={taxYear} onValueChange={setTaxYear}>
|
||||||
|
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={exportCSV} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" /> Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Income */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
|
||||||
|
</div>
|
||||||
|
{taxData.taxCollected > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tax Collected from Clients</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between font-medium">
|
||||||
|
<span>Total Invoiced (inc. tax)</span>
|
||||||
|
<span>{formatCurrency(taxData.totalInvoiced)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Total Expenses</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
|
||||||
|
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
|
||||||
|
</div>
|
||||||
|
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Estimated tax */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Net Profit (income − deductible expenses)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total Estimated Tax</span>
|
||||||
|
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs pt-1">
|
||||||
|
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quarterly chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 md:h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={taxData.quarters}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]}
|
||||||
|
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
||||||
</CardContent>
|
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
|
||||||
</Card>
|
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
|
||||||
|
|
||||||
{/* Invoice status breakdown */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Invoice Status Breakdown</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => (
|
|
||||||
<div key={status} className="flex items-center justify-between">
|
|
||||||
<StatusBadge status={status as never} />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
|
||||||
<div
|
|
||||||
className="bg-primary h-full rounded-full"
|
|
||||||
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
{invoices.length === 0 && (
|
</Card>
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
|
</TabsContent>
|
||||||
)}
|
</Tabs>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Monthly stats table */}
|
|
||||||
{stats && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Activity</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="divide-y">
|
|
||||||
{stats.recentInvoices.map((inv) => (
|
|
||||||
<div key={inv.id} className="flex items-center justify-between py-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
|
||||||
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
|
||||||
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
onClick={handlePDFExport}
|
onClick={handlePDFExport}
|
||||||
disabled={isExportingPDF}
|
disabled={isExportingPDF}
|
||||||
variant="default"
|
variant="default"
|
||||||
className="transform-none flex-shrink-0"
|
className="flex-shrink-0 transform-none"
|
||||||
>
|
>
|
||||||
{isExportingPDF ? (
|
{isExportingPDF ? (
|
||||||
<>
|
<>
|
||||||
@@ -423,18 +423,30 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Subtotal</span>
|
<span className="text-muted-foreground">Subtotal</span>
|
||||||
<span className="text-foreground font-medium">
|
<span className="text-foreground font-medium">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
{(invoice.taxRate ?? 0) > 0 && (
|
||||||
<span className="text-muted-foreground">Tax</span>
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-foreground font-medium">$0.00</span>
|
<span className="text-muted-foreground">
|
||||||
</div>
|
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{formatCurrency(
|
||||||
|
invoice.totalAmount * (invoice.taxRate ?? 0),
|
||||||
|
invoice.currency,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between text-lg font-bold">
|
<div className="flex justify-between text-lg font-bold">
|
||||||
<span className="text-foreground">Total</span>
|
<span className="text-foreground">Total</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(
|
||||||
|
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
|
||||||
|
invoice.currency,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,13 +15,22 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { InvoiceLineItems } from "./invoice-line-items";
|
import { InvoiceLineItems } from "./invoice-line-items";
|
||||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
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 { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +81,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
|
invoicePrefix: "#",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
issueDate: new Date(),
|
issueDate: new Date(),
|
||||||
@@ -101,7 +111,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } =
|
const { data: clients, isLoading: loadingClients } =
|
||||||
api.clients.getAll.useQuery();
|
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 } =
|
const { data: businesses, isLoading: loadingBusinesses } =
|
||||||
api.businesses.getAll.useQuery();
|
api.businesses.getAll.useQuery();
|
||||||
const { data: existingInvoice, isLoading: loadingInvoice } =
|
const { data: existingInvoice, isLoading: loadingInvoice } =
|
||||||
@@ -140,6 +152,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
) || [];
|
) || [];
|
||||||
setFormData({
|
setFormData({
|
||||||
invoiceNumber: existingInvoice.invoiceNumber,
|
invoiceNumber: existingInvoice.invoiceNumber,
|
||||||
|
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||||
businessId: existingInvoice.businessId ?? "",
|
businessId: existingInvoice.businessId ?? "",
|
||||||
clientId: existingInvoice.clientId,
|
clientId: existingInvoice.clientId,
|
||||||
issueDate: new Date(existingInvoice.issueDate),
|
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({
|
const createInvoice = api.invoices.create.useMutation({
|
||||||
onSuccess: (inv) => {
|
onSuccess: (inv) => {
|
||||||
@@ -333,6 +320,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
invoiceNumber: formData.invoiceNumber,
|
invoiceNumber: formData.invoiceNumber,
|
||||||
|
invoicePrefix: formData.invoicePrefix,
|
||||||
businessId: formData.businessId || "",
|
businessId: formData.businessId || "",
|
||||||
clientId: formData.clientId,
|
clientId: formData.clientId,
|
||||||
issueDate: formData.issueDate,
|
issueDate: formData.issueDate,
|
||||||
@@ -453,13 +441,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
? selectedClient.defaultHourlyRate
|
? selectedClient.defaultHourlyRate
|
||||||
: null;
|
: null;
|
||||||
const businessRate =
|
const businessRate =
|
||||||
currentBusiness && "defaultHourlyRate" in currentBusiness
|
currentBusiness &&
|
||||||
|
"defaultHourlyRate" in currentBusiness
|
||||||
? currentBusiness.defaultHourlyRate
|
? currentBusiness.defaultHourlyRate
|
||||||
: null;
|
: null;
|
||||||
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number);
|
updateField(
|
||||||
|
"defaultHourlyRate",
|
||||||
|
(clientRate ?? businessRate ?? 0) as number,
|
||||||
|
);
|
||||||
// Auto-fill currency from client
|
// Auto-fill currency from client
|
||||||
if (selectedClient && "currency" in selectedClient && selectedClient.currency) {
|
if (
|
||||||
updateField("currency", selectedClient.currency as string);
|
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>
|
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tax Rate</Label>
|
<Label>Tax Rate</Label>
|
||||||
@@ -599,7 +611,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
{noteTemplates && noteTemplates.length > 0 && (
|
{noteTemplates && noteTemplates.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
Use template <ChevronDown className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -674,9 +690,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onAddItem={addItem}
|
onAddItem={addItem}
|
||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
onMoveUp={moveItemUp}
|
|
||||||
onMoveDown={moveItemDown}
|
|
||||||
onReorderItems={reorderItems}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
|
|||||||
field: string,
|
field: string,
|
||||||
value: string | number | Date,
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onMoveUp: (index: number) => void;
|
|
||||||
onMoveDown: (index: number) => void;
|
|
||||||
onReorderItems: (items: InvoiceItem[]) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,61 +41,18 @@ interface LineItemRowProps {
|
|||||||
field: string,
|
field: string,
|
||||||
value: string | number | Date,
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onMoveUp: (index: number) => void;
|
|
||||||
onMoveDown: (index: number) => void;
|
|
||||||
isFirst: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
||||||
(
|
({ item, index, canRemove, onRemove, onUpdate }, ref) => {
|
||||||
{
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
canRemove,
|
|
||||||
onRemove,
|
|
||||||
onUpdate,
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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">
|
<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 */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -136,7 +85,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.25}
|
step={0.25}
|
||||||
width="auto"
|
width="auto"
|
||||||
className="h-9 flex-1 min-w-[100px] font-mono"
|
className="h-9 min-w-[100px] flex-1 font-mono"
|
||||||
suffix="h"
|
suffix="h"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -148,7 +97,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
|
|||||||
step={1}
|
step={1}
|
||||||
prefix="$"
|
prefix="$"
|
||||||
width="auto"
|
width="auto"
|
||||||
className="h-9 flex-1 min-w-[100px] font-mono"
|
className="h-9 min-w-[100px] flex-1 font-mono"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
@@ -185,10 +134,6 @@ function MobileLineItem({
|
|||||||
canRemove,
|
canRemove,
|
||||||
onRemove,
|
onRemove,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
}: LineItemRowProps) {
|
}: LineItemRowProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -253,28 +198,6 @@ function MobileLineItem({
|
|||||||
{/* Bottom section with controls, item name, and total */}
|
{/* 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="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
|
||||||
<div className="flex items-center gap-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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -310,8 +233,6 @@ export function InvoiceLineItems({
|
|||||||
onAddItem,
|
onAddItem,
|
||||||
onRemoveItem,
|
onRemoveItem,
|
||||||
onUpdateItem,
|
onUpdateItem,
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
className,
|
className,
|
||||||
}: InvoiceLineItemsProps) {
|
}: InvoiceLineItemsProps) {
|
||||||
const canRemoveItems = items.length > 1;
|
const canRemoveItems = items.length > 1;
|
||||||
@@ -337,10 +258,6 @@ export function InvoiceLineItems({
|
|||||||
canRemove={canRemoveItems}
|
canRemove={canRemoveItems}
|
||||||
onRemove={onRemoveItem}
|
onRemove={onRemoveItem}
|
||||||
onUpdate={onUpdateItem}
|
onUpdate={onUpdateItem}
|
||||||
onMoveUp={onMoveUp}
|
|
||||||
onMoveDown={onMoveDown}
|
|
||||||
isFirst={index === 0}
|
|
||||||
isLast={index === items.length - 1}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -351,10 +268,6 @@ export function InvoiceLineItems({
|
|||||||
canRemove={canRemoveItems}
|
canRemove={canRemoveItems}
|
||||||
onRemove={onRemoveItem}
|
onRemove={onRemoveItem}
|
||||||
onUpdate={onUpdateItem}
|
onUpdate={onUpdateItem}
|
||||||
onMoveUp={onMoveUp}
|
|
||||||
onMoveDown={onMoveDown}
|
|
||||||
isFirst={index === 0}
|
|
||||||
isLast={index === items.length - 1}
|
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -368,7 +281,7 @@ export function InvoiceLineItems({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onAddItem}
|
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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Line Item
|
Add Line Item
|
||||||
|
|||||||
@@ -9,98 +9,93 @@ import { InvoiceCalendarView } from "../invoice-calendar-view";
|
|||||||
import type { InvoiceFormData } from "./types";
|
import type { InvoiceFormData } from "./types";
|
||||||
|
|
||||||
interface InvoiceWorkspaceProps {
|
interface InvoiceWorkspaceProps {
|
||||||
formData: InvoiceFormData;
|
formData: InvoiceFormData;
|
||||||
viewMode: "list" | "calendar";
|
viewMode: "list" | "calendar";
|
||||||
setViewMode: (mode: "list" | "calendar") => void;
|
setViewMode: (mode: "list" | "calendar") => void;
|
||||||
addItem: (date?: Date) => void;
|
addItem: (date?: Date) => void;
|
||||||
removeItem: (index: number) => void;
|
removeItem: (index: number) => void;
|
||||||
updateItem: (index: number, field: string, value: string | number | Date) => void;
|
updateItem: (
|
||||||
moveItemUp: (index: number) => void;
|
index: number,
|
||||||
moveItemDown: (index: number) => void;
|
field: string,
|
||||||
reorderItems: (items: InvoiceFormData['items']) => void;
|
value: string | number | Date,
|
||||||
className?: string;
|
) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceWorkspace({
|
export function InvoiceWorkspace({
|
||||||
formData,
|
formData,
|
||||||
viewMode,
|
viewMode,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
moveItemUp,
|
className,
|
||||||
moveItemDown,
|
|
||||||
reorderItems,
|
|
||||||
className,
|
|
||||||
}: InvoiceWorkspaceProps) {
|
}: InvoiceWorkspaceProps) {
|
||||||
|
return (
|
||||||
return (
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
{/* Workspace Header / View Toggle */}
|
||||||
{/* Workspace Header / View Toggle */}
|
<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 justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<h2 className="text-lg font-semibold tracking-tight">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">
|
{viewMode === "list" ? "Line Items" : "Timesheet"}
|
||||||
{viewMode === 'list' ? 'Line Items' : 'Timesheet'}
|
</h2>
|
||||||
</h2>
|
<div className="text-muted-foreground ml-2 text-sm">
|
||||||
<div className="text-sm text-muted-foreground ml-2">
|
{formData.items.length}{" "}
|
||||||
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'}
|
{formData.items.length === 1 ? "entry" : "entries"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className="h-8 gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<List className="w-3.5 h-3.5" />
|
|
||||||
List
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode('calendar')}
|
|
||||||
className="h-8 gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<CalendarIcon className="w-3.5 h-3.5" />
|
|
||||||
Calendar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Workspace Content */}
|
|
||||||
<div className="flex-1 overflow-hidden relative">
|
|
||||||
<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">
|
|
||||||
<InvoiceLineItems
|
|
||||||
items={formData.items}
|
|
||||||
onAddItem={() => addItem()}
|
|
||||||
onRemoveItem={removeItem}
|
|
||||||
onUpdateItem={updateItem}
|
|
||||||
onMoveUp={moveItemUp}
|
|
||||||
onMoveDown={moveItemDown}
|
|
||||||
onReorderItems={reorderItems}
|
|
||||||
className="p-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-full">
|
|
||||||
<InvoiceCalendarView
|
|
||||||
items={formData.items}
|
|
||||||
onAddItem={addItem}
|
|
||||||
onRemoveItem={removeItem}
|
|
||||||
onUpdateItem={updateItem}
|
|
||||||
defaultHourlyRate={formData.defaultHourlyRate}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
List
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "calendar" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("calendar")}
|
||||||
|
className="h-8 gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="h-3.5 w-3.5" />
|
||||||
|
Calendar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace Content */}
|
||||||
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
|
||||||
|
{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}
|
||||||
|
className="p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full">
|
||||||
|
<InvoiceCalendarView
|
||||||
|
items={formData.items}
|
||||||
|
onAddItem={addItem}
|
||||||
|
onRemoveItem={removeItem}
|
||||||
|
onUpdateItem={updateItem}
|
||||||
|
defaultHourlyRate={formData.defaultHourlyRate}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,31 @@ export type ClientType = RouterOutputs["clients"]["getAll"][number];
|
|||||||
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
|
||||||
|
|
||||||
export interface InvoiceItem {
|
export interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
description: string;
|
description: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceFormData {
|
export interface InvoiceFormData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
businessId: string;
|
invoicePrefix: string;
|
||||||
clientId: string;
|
businessId: string;
|
||||||
issueDate: Date;
|
clientId: string;
|
||||||
dueDate: Date;
|
issueDate: Date;
|
||||||
status: "draft" | "sent" | "paid";
|
dueDate: Date;
|
||||||
notes: string;
|
status: "draft" | "sent" | "paid";
|
||||||
taxRate: number;
|
notes: string;
|
||||||
currency: string;
|
taxRate: number;
|
||||||
defaultHourlyRate: number | null;
|
currency: string;
|
||||||
items: InvoiceItem[];
|
defaultHourlyRate: number | null;
|
||||||
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_OPTIONS = [
|
export const STATUS_OPTIONS = [
|
||||||
{ value: "draft", label: "Draft" },
|
{ value: "draft", label: "Draft" },
|
||||||
{ value: "sent", label: "Sent" },
|
{ value: "sent", label: "Sent" },
|
||||||
{ value: "paid", label: "Paid" },
|
{ value: "paid", label: "Paid" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const EXPENSE_CATEGORIES = [
|
||||||
|
"Travel",
|
||||||
|
"Meals & Entertainment",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
"Hardware & Equipment",
|
||||||
|
"Office Supplies",
|
||||||
|
"Marketing",
|
||||||
|
"Professional Services",
|
||||||
|
"Utilities",
|
||||||
|
"Other",
|
||||||
|
] as const;
|
||||||
+162
-237
@@ -5,11 +5,31 @@ import {
|
|||||||
View,
|
View,
|
||||||
Image,
|
Image,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Font,
|
||||||
pdf,
|
pdf,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import React from "react";
|
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
|
// Fallback download function for better browser compatibility
|
||||||
function downloadBlob(blob: Blob, filename: string): void {
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -56,11 +76,13 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
|
|
||||||
interface InvoiceData {
|
interface InvoiceData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
invoicePrefix?: string | null;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
business?: {
|
business?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -100,7 +122,7 @@ const styles = StyleSheet.create({
|
|||||||
page: {
|
page: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
paddingTop: 40,
|
paddingTop: 40,
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
@@ -127,7 +149,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
businessName: {
|
businessName: {
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
@@ -135,7 +157,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
businessInfo: {
|
businessInfo: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
@@ -143,7 +165,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
businessAddress: {
|
businessAddress: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
@@ -156,14 +178,14 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
invoiceTitle: {
|
invoiceTitle: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
|
|
||||||
invoiceNumber: {
|
invoiceNumber: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
@@ -172,7 +194,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -200,13 +222,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
clientName: {
|
clientName: {
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
@@ -214,7 +236,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
@@ -222,7 +244,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
clientAddress: {
|
clientAddress: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
@@ -236,14 +258,14 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
detailLabel: {
|
detailLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
detailValue: {
|
detailValue: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
@@ -259,21 +281,21 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
notesTitle: {
|
notesTitle: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
|
|
||||||
notesContent: {
|
notesContent: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
|
|
||||||
businessContact: {
|
businessContact: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
@@ -297,7 +319,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
abridgedBusinessName: {
|
abridgedBusinessName: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -309,13 +331,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
abridgedInvoiceTitle: {
|
abridgedInvoiceTitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
abridgedInvoiceNumber: {
|
abridgedInvoiceNumber: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -335,7 +357,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
tableHeaderCell: {
|
tableHeaderCell: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
@@ -380,7 +402,7 @@ const styles = StyleSheet.create({
|
|||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
},
|
},
|
||||||
|
|
||||||
tableCellDate: {
|
tableCellDate: {
|
||||||
@@ -396,7 +418,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 2,
|
paddingHorizontal: 2,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
},
|
},
|
||||||
|
|
||||||
tableCellHours: {
|
tableCellHours: {
|
||||||
@@ -454,7 +476,7 @@ const styles = StyleSheet.create({
|
|||||||
totalLabel: {
|
totalLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
},
|
},
|
||||||
|
|
||||||
totalAmount: {
|
totalAmount: {
|
||||||
@@ -472,7 +494,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
finalTotalLabel: {
|
finalTotalLabel: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -484,7 +506,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
itemCount: {
|
itemCount: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#9ca3af",
|
color: "#9ca3af",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@@ -511,16 +533,16 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
pageNumber: {
|
pageNumber: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number, currency = "USD") => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -565,206 +587,86 @@ const getStatusStyle = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to estimate text height based on content and width
|
function pageContentBudget(isFirstPage: boolean, hasNotes: boolean): number {
|
||||||
function estimateTextHeight(
|
// 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
|
||||||
text: string,
|
let h = 672;
|
||||||
maxWidth: number,
|
h -= isFirstPage ? 285 : 50; // dense vs abridged header
|
||||||
fontSize = 10,
|
h -= hasNotes ? 185 : 130; // totals box (+ notes section if present)
|
||||||
lineHeight = 1.3,
|
h -= 28; // table header row
|
||||||
): number {
|
return h;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate estimated height for a table row based on actual content
|
function estimateRowHeight(
|
||||||
function calculateRowHeight(
|
item: NonNullable<NonNullable<InvoiceData["items"]>[0]>,
|
||||||
item: NonNullable<InvoiceData["items"]>[0],
|
showRate: boolean,
|
||||||
): number {
|
): number {
|
||||||
if (!item) return 18; // fallback
|
// 532pt usable width (612 - 80pt horizontal padding); description takes 40% or 48%
|
||||||
|
const descColWidth = 532 * (showRate ? 0.4 : 0.48);
|
||||||
const basePadding = 8; // Row padding
|
// Frutiger at 10pt: 0.45em gives ~47 chars/line, matching real wrap behaviour
|
||||||
const fontSize = 10;
|
const charsPerLine = Math.max(1, Math.floor(descColWidth / (10 * 0.45)));
|
||||||
const lineHeight = 1.3;
|
const lines = Math.ceil((item.description.length || 1) / charsPerLine);
|
||||||
|
// row paddingVertical:6 (×2=12) + cell paddingVertical:4 (×2=8) = 20pt overhead,
|
||||||
// Description column is 40% of table width
|
// but react-pdf measures the line box at slightly under full lineHeight, so 16pt in practice
|
||||||
// Table width is roughly 512 points (letter width - margins)
|
return lines * 10 * 1.4 + 16;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
function paginateItems(
|
||||||
items: NonNullable<InvoiceData["items"]>,
|
items: NonNullable<InvoiceData["items"]>,
|
||||||
hasNotes = false,
|
hasNotes = false,
|
||||||
|
showRate = true,
|
||||||
) {
|
) {
|
||||||
const validItems = items.filter(Boolean);
|
const validItems = items.filter(Boolean) as NonNullable<typeof items[0]>[];
|
||||||
const pages: Array<typeof validItems> = [];
|
if (validItems.length === 0) return [[]];
|
||||||
|
|
||||||
if (validItems.length === 0) {
|
const rowHeights = validItems.map((item) => estimateRowHeight(item, showRate));
|
||||||
return [[]];
|
|
||||||
|
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;
|
const pages: (typeof validItems)[] = [];
|
||||||
let pageIndex = 0;
|
let idx = 0;
|
||||||
|
|
||||||
while (currentIndex < validItems.length) {
|
while (idx < validItems.length) {
|
||||||
const isFirstPage = pageIndex === 0;
|
const isFirst = pages.length === 0;
|
||||||
const remainingItems = validItems.length - currentIndex;
|
const countFull = pack(idx, pageContentBudget(isFirst, false));
|
||||||
|
|
||||||
// Determine if this could be the last page with simple calculation
|
if (idx + countFull >= validItems.length) {
|
||||||
const maxPossibleItems = calculateItemsPerPage(isFirstPage, false);
|
// All remaining items fit — if there are notes, verify they also fit with the notes reservation
|
||||||
const wouldBeLastPage =
|
if (hasNotes) {
|
||||||
currentIndex + maxPossibleItems >= validItems.length;
|
const countWithNotes = pack(idx, pageContentBudget(isFirst, true));
|
||||||
|
if (idx + countWithNotes >= validItems.length) {
|
||||||
// Calculate items per page, accounting for notes space if this is likely the last page
|
pages.push(validItems.slice(idx));
|
||||||
let itemsPerPage = calculateItemsForPage(
|
break;
|
||||||
validItems,
|
}
|
||||||
currentIndex,
|
// Notes don't fit alongside all items — push what fits, notes go on next page
|
||||||
isFirstPage,
|
pages.push(validItems.slice(idx, idx + countWithNotes));
|
||||||
wouldBeLastPage && hasNotes,
|
idx += countWithNotes;
|
||||||
);
|
} else {
|
||||||
|
pages.push(validItems.slice(idx));
|
||||||
// Fallback to conservative calculation if dynamic fails
|
break;
|
||||||
if (itemsPerPage === 0) {
|
}
|
||||||
itemsPerPage = calculateItemsPerPage(
|
} else {
|
||||||
isFirstPage,
|
pages.push(validItems.slice(idx, idx + countFull));
|
||||||
wouldBeLastPage && hasNotes,
|
idx += countFull;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we don't have tiny orphan pages
|
|
||||||
if (remainingItems > itemsPerPage && remainingItems - itemsPerPage < 2) {
|
|
||||||
itemsPerPage = Math.max(1, itemsPerPage - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
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)
|
// Dense header component (first page)
|
||||||
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
||||||
<View style={styles.denseHeader}>
|
<View style={styles.denseHeader}>
|
||||||
@@ -807,7 +709,10 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|||||||
|
|
||||||
<View style={styles.invoiceSection}>
|
<View style={styles.invoiceSection}>
|
||||||
<Text style={styles.invoiceTitle}>INVOICE</Text>
|
<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)}>
|
<View style={getStatusStyle(invoice.status)}>
|
||||||
<Text>{getStatusLabel(invoice.status)}</Text>
|
<Text>{getStatusLabel(invoice.status)}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -873,25 +778,33 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|||||||
</Text>
|
</Text>
|
||||||
<View style={styles.abridgedInvoiceInfo}>
|
<View style={styles.abridgedInvoiceInfo}>
|
||||||
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
|
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
|
||||||
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
|
<Text style={styles.abridgedInvoiceNumber}>
|
||||||
|
{invoice.invoicePrefix ?? "#"}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Table header component
|
// Table header component
|
||||||
const TableHeader: React.FC = () => (
|
const TableHeader: React.FC<{ showRate: boolean }> = ({ showRate }) => {
|
||||||
<View style={styles.tableHeader}>
|
const cols = getColumnWidths(showRate);
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderDate]}>Date</Text>
|
return (
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderDescription]}>
|
<View style={styles.tableHeader}>
|
||||||
Description
|
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
|
||||||
</Text>
|
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours]}>Hours</Text>
|
Description
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
|
</Text>
|
||||||
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount]}>
|
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours, { width: cols.hours }]}>Hours</Text>
|
||||||
Amount
|
{showRate && (
|
||||||
</Text>
|
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
|
||||||
</View>
|
)}
|
||||||
);
|
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount, { width: cols.amount }]}>
|
||||||
|
Amount
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Footer component
|
// Footer component
|
||||||
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||||
@@ -922,7 +835,7 @@ const Footer: React.FC = () => (
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -944,8 +857,10 @@ const TotalsSection: React.FC<{
|
|||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||||
}> = ({ invoice, items }) => {
|
}> = ({ invoice, items }) => {
|
||||||
|
const currency = invoice.currency ?? "USD";
|
||||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.totalsContainer}>
|
<View style={styles.totalsContainer}>
|
||||||
@@ -953,7 +868,7 @@ const TotalsSection: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -965,20 +880,24 @@ const TotalsSection: React.FC<{
|
|||||||
|
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Subtotal:</Text>
|
<Text style={styles.totalLabel}>Subtotal:</Text>
|
||||||
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text>
|
<Text style={styles.totalAmount}>
|
||||||
|
{formatCurrency(subtotal, currency)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.finalTotalRow}>
|
<View style={styles.finalTotalRow}>
|
||||||
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
||||||
<Text style={styles.finalTotalAmount}>
|
<Text style={styles.finalTotalAmount}>
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(total, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -993,7 +912,10 @@ const TotalsSection: React.FC<{
|
|||||||
// Main PDF component
|
// Main PDF component
|
||||||
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||||
const items = invoice.items?.filter(Boolean) ?? [];
|
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 (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
@@ -1014,7 +936,7 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
{hasItems && (
|
{hasItems && (
|
||||||
<View style={styles.tableContainer}>
|
<View style={styles.tableContainer}>
|
||||||
<TableHeader />
|
<TableHeader showRate={showRate} />
|
||||||
{pageItems.map(
|
{pageItems.map(
|
||||||
(item, index) =>
|
(item, index) =>
|
||||||
item && (
|
item && (
|
||||||
@@ -1025,27 +947,30 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
index % 2 === 0 ? styles.tableRowAlt : {},
|
index % 2 === 0 ? styles.tableRowAlt : {},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tableCell, styles.tableCellDate]}>
|
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
|
||||||
{formatDate(item.date)}
|
{formatDate(item.date)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tableCell,
|
styles.tableCell,
|
||||||
styles.tableCellDescription,
|
styles.tableCellDescription,
|
||||||
|
{ width: cols.description },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.tableCell, styles.tableCellHours]}>
|
<Text style={[styles.tableCell, styles.tableCellHours, { width: cols.hours }]}>
|
||||||
{item.hours}
|
{item.hours}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
{showRate && (
|
||||||
{formatCurrency(item.rate)}
|
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
||||||
</Text>
|
{formatCurrency(item.rate, currency)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text
|
<Text
|
||||||
style={[styles.tableCell, styles.tableCellAmount]}
|
style={[styles.tableCell, styles.tableCellAmount, { width: cols.amount }]}
|
||||||
>
|
>
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.amount, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,18 +3,9 @@ import { eq, and, desc } from "drizzle-orm";
|
|||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||||
|
|
||||||
export const EXPENSE_CATEGORIES = [
|
export { EXPENSE_CATEGORIES };
|
||||||
"Travel",
|
|
||||||
"Meals & Entertainment",
|
|
||||||
"Software & Subscriptions",
|
|
||||||
"Hardware & Equipment",
|
|
||||||
"Office Supplies",
|
|
||||||
"Marketing",
|
|
||||||
"Professional Services",
|
|
||||||
"Utilities",
|
|
||||||
"Other",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
@@ -24,6 +15,7 @@ const createExpenseSchema = z.object({
|
|||||||
category: z.string().optional().or(z.literal("")),
|
category: z.string().optional().or(z.literal("")),
|
||||||
billable: z.boolean().default(false),
|
billable: z.boolean().default(false),
|
||||||
reimbursable: z.boolean().default(false),
|
reimbursable: z.boolean().default(false),
|
||||||
|
taxDeductible: z.boolean().default(false),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
clientId: z.string().optional().or(z.literal("")),
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
businessId: z.string().optional().or(z.literal("")),
|
businessId: z.string().optional().or(z.literal("")),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const invoiceItemSchema = z.object({
|
|||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||||
|
invoicePrefix: z.string().optional().default("#"),
|
||||||
businessId: z
|
businessId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Business is required")
|
.min(1, "Business is required")
|
||||||
@@ -416,11 +417,17 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) {
|
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) {
|
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
|
await ctx.db
|
||||||
@@ -428,18 +435,27 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.set({ status: input.status, updatedAt: new Date() })
|
.set({ status: input.status, updatedAt: new Date() })
|
||||||
.where(eq(invoices.id, input.id));
|
.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) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) throw 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
|
bulkUpdateStatus: protectedProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
ids: z.array(z.string()).min(1),
|
z.object({
|
||||||
status: z.enum(["draft", "sent", "paid"]),
|
ids: z.array(z.string()).min(1),
|
||||||
}))
|
status: z.enum(["draft", "sent", "paid"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Only update invoices owned by this user
|
// Only update invoices owned by this user
|
||||||
const owned = await ctx.db.query.invoices.findMany({
|
const owned = await ctx.db.query.invoices.findMany({
|
||||||
@@ -452,7 +468,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.map((inv) => inv.id);
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
if (ownedIds.length === 0) {
|
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
|
await ctx.db
|
||||||
@@ -476,7 +495,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.map((inv) => inv.id);
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
if (ownedIds.length === 0) {
|
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));
|
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
* This applies any pending migrations from the drizzle/ directory to the
|
* This applies any pending migrations from the drizzle/ directory to the
|
||||||
* database specified by DATABASE_URL. It is safe to run multiple times —
|
* database specified by DATABASE_URL. It is safe to run multiple times —
|
||||||
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
* Drizzle tracks applied migrations in the __drizzle_migrations table.
|
||||||
|
*
|
||||||
|
* If the database was previously set up via `db:push` (no migration history),
|
||||||
|
* this script will baseline it: seed the migration history without re-running
|
||||||
|
* the SQL, so only future migrations are applied.
|
||||||
*/
|
*/
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
@@ -17,6 +21,8 @@ import { Pool } from "pg";
|
|||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
@@ -36,9 +42,142 @@ const pool = new Pool({
|
|||||||
|
|
||||||
const db = drizzle(pool);
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify and repair the migration tracking table:
|
||||||
|
* 1. If no tracking table exists and DB has tables → baseline from db:push
|
||||||
|
* 2. If tracking table exists → scan for any entries that are recorded as
|
||||||
|
* applied but whose schema changes don't actually exist, and remove them
|
||||||
|
* so migrate() will re-run those migrations.
|
||||||
|
*/
|
||||||
|
async function baselineIfNeeded(client: Pool) {
|
||||||
|
const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations");
|
||||||
|
|
||||||
|
// Always ensure the drizzle schema + table exist
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { rows: entryRows } = await client.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`
|
||||||
|
);
|
||||||
|
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
|
||||||
|
|
||||||
|
if (!hasMigrationsTable || !hasEntries) {
|
||||||
|
// No history at all — check if DB was previously set up via db:push
|
||||||
|
const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account");
|
||||||
|
if (!dbAlreadyExists) {
|
||||||
|
return; // Fresh DB — let migrate() run everything normally
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[migrate] Existing database detected without migration history — baselining...");
|
||||||
|
await seedMigrationHistory(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration history exists — validate that each recorded migration is
|
||||||
|
// actually reflected in the schema. Remove any bogus entries.
|
||||||
|
await removeBogusEntries(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMigrationHistory(client: Pool) {
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
|
||||||
|
);
|
||||||
|
const hash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
|
||||||
|
[hash, entry.when]
|
||||||
|
);
|
||||||
|
console.log(`[migrate] Baselined: ${entry.tag}`);
|
||||||
|
}
|
||||||
|
console.log("[migrate] Baseline complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBogusEntries(client: Pool) {
|
||||||
|
// Get all recorded hashes
|
||||||
|
const { rows } = await client.query<{ id: number; hash: string }>(
|
||||||
|
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`
|
||||||
|
);
|
||||||
|
|
||||||
|
const journal = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
|
||||||
|
) as { entries: { idx: number; tag: string; when: number }[] };
|
||||||
|
|
||||||
|
for (const entry of journal.entries) {
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
|
||||||
|
);
|
||||||
|
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
|
||||||
|
const recorded = rows.find((r) => r.hash === expectedHash);
|
||||||
|
if (!recorded) continue; // Not recorded yet — migrate() will run it
|
||||||
|
|
||||||
|
// It's recorded — verify it's actually applied in the schema
|
||||||
|
const applied = await isMigrationApplied(client, entry.tag);
|
||||||
|
if (!applied) {
|
||||||
|
console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`);
|
||||||
|
await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(client: Pool, schema: string, table: string): Promise<boolean> {
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1 AND table_name = $2
|
||||||
|
`, [schema, table]);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a specific migration's schema changes already exist in the DB.
|
||||||
|
*/
|
||||||
|
async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
|
||||||
|
if (tag === "0000_glossy_magneto") {
|
||||||
|
return tableExists(client, "public", "beenvoice_account");
|
||||||
|
}
|
||||||
|
if (tag === "0001_supreme_the_enforcers") {
|
||||||
|
// 0001 adds currency to beenvoice_client
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_client'
|
||||||
|
AND column_name = 'currency'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
if (tag === "0002_tax_deductible") {
|
||||||
|
// 0002 adds taxDeductible to beenvoice_expense
|
||||||
|
const { rows } = await client.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text AS count FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'beenvoice_expense'
|
||||||
|
AND column_name = 'taxDeductible'
|
||||||
|
`);
|
||||||
|
return parseInt(rows[0]?.count ?? "0") > 0;
|
||||||
|
}
|
||||||
|
// Unknown migration — assume not applied so it runs
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[migrate] Running migrations from", migrationsFolder);
|
console.log("[migrate] Running migrations from", migrationsFolder);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await baselineIfNeeded(pool);
|
||||||
await migrate(db, { migrationsFolder });
|
await migrate(db, { migrationsFolder });
|
||||||
console.log("[migrate] All migrations applied successfully");
|
console.log("[migrate] All migrations applied successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+52
-15
@@ -1,7 +1,6 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { index, pgTableCreator } from "drizzle-orm/pg-core";
|
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
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({
|
|||||||
emailVerified: d.boolean().default(false).notNull(),
|
emailVerified: d.boolean().default(false).notNull(),
|
||||||
image: d.varchar({ length: 255 }),
|
image: d.varchar({ length: 255 }),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
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)
|
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetTokenExpiry: d.timestamp(),
|
resetTokenExpiry: d.timestamp(),
|
||||||
@@ -47,7 +50,11 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
"account",
|
"account",
|
||||||
(d) => ({
|
(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
|
userId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -62,11 +69,13 @@ export const accounts = createTable(
|
|||||||
idToken: d.text(),
|
idToken: d.text(),
|
||||||
password: d.text(), // Matched DB: text
|
password: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [index("account_userId_idx").on(t.userId)],
|
||||||
index("account_userId_idx").on(t.userId),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||||
@@ -76,7 +85,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
|
|||||||
export const sessions = createTable(
|
export const sessions = createTable(
|
||||||
"session",
|
"session",
|
||||||
(d) => ({
|
(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
|
userId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -86,7 +99,11 @@ export const sessions = createTable(
|
|||||||
ipAddress: d.text(), // Matched DB: text
|
ipAddress: d.text(), // Matched DB: text
|
||||||
userAgent: d.text(), // Matched DB: text
|
userAgent: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
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)],
|
(t) => [index("session_userId_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -98,12 +115,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
|
|||||||
export const verificationTokens = createTable(
|
export const verificationTokens = createTable(
|
||||||
"verification_token",
|
"verification_token",
|
||||||
(d) => ({
|
(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(),
|
identifier: d.varchar({ length: 255 }).notNull(),
|
||||||
value: d.varchar({ length: 255 }).notNull(),
|
value: d.varchar({ length: 255 }).notNull(),
|
||||||
expiresAt: d.timestamp().notNull(),
|
expiresAt: d.timestamp().notNull(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
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)],
|
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
|
||||||
);
|
);
|
||||||
@@ -111,14 +136,25 @@ export const verificationTokens = createTable(
|
|||||||
export const ssoProviders = createTable(
|
export const ssoProviders = createTable(
|
||||||
"sso_provider",
|
"sso_provider",
|
||||||
(d) => ({
|
(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(),
|
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
|
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
||||||
oidcConfig: d.text(),
|
oidcConfig: d.text(),
|
||||||
samlConfig: d.text(),
|
samlConfig: d.text(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
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)],
|
(t) => [index("sso_provider_user_id_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -230,6 +266,7 @@ export const invoices = createTable(
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
||||||
|
invoicePrefix: d.varchar({ length: 20 }).default("#"),
|
||||||
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||||
clientId: d
|
clientId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -334,6 +371,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 })
|
||||||
@@ -410,4 +448,3 @@ export const invoiceTemplatesRelations = relations(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,9 @@
|
|||||||
--color-input: hsl(var(--input));
|
--color-input: hsl(var(--input));
|
||||||
--color-ring: hsl(var(--ring));
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
--font-sans: var(--font-sans), sans-serif;
|
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-heading: var(--font-heading), serif;
|
--font-heading: var(--font-heading), ui-serif, Georgia, serif;
|
||||||
--font-mono: var(--font-geist-mono), monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,6 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
import { type AppRouter } from "~/server/api/root";
|
|
||||||
import { createQueryClient } from "./query-client";
|
import { createQueryClient } from "./query-client";
|
||||||
|
|
||||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||||
@@ -22,21 +21,22 @@ const getQueryClient = () => {
|
|||||||
return clientQueryClientSingleton;
|
return clientQueryClientSingleton;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>();
|
// Use inline import() type to avoid pulling server modules into the client bundle
|
||||||
|
export const api = createTRPCReact<import("~/server/api/root").AppRouter>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for inputs.
|
* Inference helper for inputs.
|
||||||
*
|
*
|
||||||
* @example type HelloInput = RouterInputs['example']['hello']
|
* @example type HelloInput = RouterInputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
export type RouterInputs = inferRouterInputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inference helper for outputs.
|
* Inference helper for outputs.
|
||||||
*
|
*
|
||||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||||
*/
|
*/
|
||||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
export type RouterOutputs = inferRouterOutputs<import("~/server/api/root").AppRouter>;
|
||||||
|
|
||||||
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user