From 84a5d997b478188c18069afaf49bc81814178fe6 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 28 Apr 2026 00:34:56 -0400 Subject: [PATCH] refactor: remove InvoiceView component and update related email and invoice handling - Deleted the InvoiceView component to streamline the codebase. - Updated EmailPreview and SendEmailDialog components to include currency and notes fields. - Enhanced invoice-form to handle default hourly rates and improved item mapping. - Refactored email template generation to include notes and currency formatting. - Adjusted API routers for invoices to calculate totals and handle notes and currency correctly. --- .../[id]/_components/send-invoice-button.tsx | 127 ---- .../[id]/_components/unified-invoice-page.tsx | 26 - src/app/dashboard/invoices/[id]/page.tsx | 4 +- src/app/dashboard/invoices/[id]/send/page.tsx | 62 +- src/app/dashboard/reports/page.tsx | 640 ++++++++++++++---- src/components/data/invoice-view.tsx | 516 -------------- src/components/forms/email-preview.tsx | 18 +- src/components/forms/invoice-form.tsx | 67 +- src/components/forms/send-email-dialog.tsx | 5 + src/lib/email-templates/invoice-email.ts | 36 +- src/server/api/routers/email.ts | 1 + src/server/api/routers/invoices.ts | 206 +++--- 12 files changed, 739 insertions(+), 969 deletions(-) delete mode 100644 src/app/dashboard/invoices/[id]/_components/send-invoice-button.tsx delete mode 100644 src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx delete mode 100644 src/components/data/invoice-view.tsx diff --git a/src/app/dashboard/invoices/[id]/_components/send-invoice-button.tsx b/src/app/dashboard/invoices/[id]/_components/send-invoice-button.tsx deleted file mode 100644 index bc9f59a..0000000 --- a/src/app/dashboard/invoices/[id]/_components/send-invoice-button.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "~/components/ui/button"; -import { toast } from "sonner"; -import { api } from "~/trpc/react"; -import { Send, Loader2 } from "lucide-react"; - -interface SendInvoiceButtonProps { - invoiceId: string; - variant?: "default" | "outline" | "ghost" | "icon"; - className?: string; - showResend?: boolean; -} - -export function SendInvoiceButton({ - invoiceId, - variant = "outline", - className, - showResend = false, -}: SendInvoiceButtonProps) { - const [isSending, setIsSending] = useState(false); - - // Get utils for cache invalidation - const utils = api.useUtils(); - - // Use the new email API mutation - const sendInvoiceMutation = api.email.sendInvoice.useMutation({ - onSuccess: (data) => { - // Show detailed success message with delivery info - toast.success(data.message, { - description: `Email ID: ${data.emailId}`, - duration: 5000, - }); - - // Refresh invoice data to show updated status - void utils.invoices.getById.invalidate({ id: invoiceId }); - }, - onError: (error) => { - // Enhanced error handling with specific error types - console.error("Email send error:", error); - - let errorMessage = "Failed to send invoice email"; - let errorDescription = ""; - - if (error.message.includes("Invalid recipient")) { - errorMessage = "Invalid Email Address"; - errorDescription = - "Please check the client's email address and try again."; - } else if (error.message.includes("domain not verified")) { - errorMessage = "Email Configuration Issue"; - errorDescription = "Please contact support to configure email sending."; - } else if (error.message.includes("rate limit")) { - errorMessage = "Too Many Emails"; - errorDescription = "Please wait a moment before sending another email."; - } else if (error.message.includes("no email address")) { - errorMessage = "No Email Address"; - errorDescription = "This client doesn't have an email address on file."; - } else { - errorDescription = error.message; - } - - toast.error(errorMessage, { - description: errorDescription, - duration: 6000, - }); - }, - }); - - const handleSendInvoice = async () => { - if (isSending) return; - - setIsSending(true); - - try { - await sendInvoiceMutation.mutateAsync({ - invoiceId, - }); - } catch (error) { - // Error is already handled by the mutation's onError - console.error("Send invoice error:", error); - } finally { - setIsSending(false); - } - }; - - if (variant === "icon") { - return ( - - ); - } - - return ( - - ); -} diff --git a/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx b/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx deleted file mode 100644 index 7af3302..0000000 --- a/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { InvoiceView } from "~/components/data/invoice-view"; -import InvoiceForm from "~/components/forms/invoice-form"; - -interface UnifiedInvoicePageProps { - invoiceId: string; - mode: string; -} - -export function UnifiedInvoicePage({ - invoiceId, - mode, -}: UnifiedInvoicePageProps) { - return ( -
- {/* Always render InvoiceForm to preserve state, but hide when in view mode */} -
- -
- - {/* Show InvoiceView only when in view mode */} - {mode === "view" && } -
- ); -} diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index 2ba99b9..4dbb736 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -99,10 +99,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { }).format(new Date(date)); }; - const formatCurrency = (amount: number) => { + const formatCurrency = (amount: number, currency = invoice.currency) => { return new Intl.NumberFormat("en-US", { style: "currency", - currency: "USD", + currency, }).format(amount); }; diff --git a/src/app/dashboard/invoices/[id]/send/page.tsx b/src/app/dashboard/invoices/[id]/send/page.tsx index ef23470..a961040 100644 --- a/src/app/dashboard/invoices/[id]/send/page.tsx +++ b/src/app/dashboard/invoices/[id]/send/page.tsx @@ -136,9 +136,9 @@ export default function SendEmailPage() { action: canRetry && retryCount < 2 ? { - label: "Retry", - onClick: () => handleRetry(), - } + label: "Retry", + onClick: () => handleRetry(), + } : undefined, }); @@ -150,31 +150,37 @@ export default function SendEmailPage() { const invoice = useMemo(() => { return invoiceData ? { - id: invoiceData.id, - invoiceNumber: invoiceData.invoiceNumber, - issueDate: invoiceData.issueDate, - dueDate: invoiceData.dueDate, - status: invoiceData.status, - taxRate: invoiceData.taxRate, - client: invoiceData.client - ? { - name: invoiceData.client.name, - email: invoiceData.client.email, - } - : undefined, - business: invoiceData.business - ? { - name: invoiceData.business.name, - nickname: invoiceData.business.nickname, - email: invoiceData.business.email, - } - : undefined, - items: invoiceData.items?.map((item) => ({ - id: item.id, - hours: item.hours, - rate: item.rate, - })), - } + id: invoiceData.id, + invoiceNumber: invoiceData.invoiceNumber, + issueDate: invoiceData.issueDate, + dueDate: invoiceData.dueDate, + status: invoiceData.status, + totalAmount: invoiceData.totalAmount, + taxRate: invoiceData.taxRate, + currency: invoiceData.currency, + notes: invoiceData.notes, + client: invoiceData.client + ? { + name: invoiceData.client.name, + email: invoiceData.client.email, + } + : undefined, + business: invoiceData.business + ? { + name: invoiceData.business.name, + nickname: invoiceData.business.nickname, + email: invoiceData.business.email, + } + : undefined, + items: invoiceData.items?.map((item) => ({ + id: item.id, + date: item.date, + description: item.description, + hours: item.hours, + rate: item.rate, + amount: item.amount, + })), + } : undefined; }, [invoiceData]); diff --git a/src/app/dashboard/reports/page.tsx b/src/app/dashboard/reports/page.tsx index 74a9e5a..b4ff07b 100644 --- a/src/app/dashboard/reports/page.tsx +++ b/src/app/dashboard/reports/page.tsx @@ -6,7 +6,13 @@ import { PageHeader } from "~/components/layout/page-header"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { StatusBadge } from "~/components/data/status-badge"; import { Button } from "~/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; +import { + 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"; @@ -23,7 +29,15 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; -import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react"; +import { + TrendingUp, + DollarSign, + Clock, + Users, + Download, + Receipt, + FileText, +} from "lucide-react"; function toNumericChartValue(value: unknown) { const numericValue = typeof value === "number" ? value : Number(value ?? 0); @@ -31,20 +45,22 @@ function toNumericChartValue(value: unknown) { } export default function ReportsPage() { - const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery(); - const { data: expenses = [], isLoading: expensesLoading } = api.expenses.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 isLoading = invoicesLoading || expensesLoading; - const now = new Date(); - const currentYear = now.getFullYear(); + const currentYear = new Date().getFullYear(); const [taxYear, setTaxYear] = useState(String(currentYear)); // Overview data (last 12 months) const overviewData = useMemo(() => { if (!invoices.length) return null; + const now = new Date(); const monthMap: Record = {}; for (let i = 11; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); @@ -57,7 +73,10 @@ export default function ReportsPage() { let totalHours = 0; 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") { totalRevenue += inv.totalAmount; const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`; @@ -69,28 +88,54 @@ export default function ReportsPage() { } const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({ - month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }), + month: new Date(month + "-01").toLocaleDateString("en-US", { + month: "short", + year: "2-digit", + }), revenue, })); const clientMap: Record = {}; 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) { const id = inv.client.id; - if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 }; - clientMap[id]!.revenue += inv.totalAmount; + const entry = (clientMap[id] ??= { + name: inv.client.name, + revenue: 0, + }); + entry.revenue += inv.totalAmount; } } - const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6); + const topClients = Object.values(clientMap) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 6); - const statusCount: Record = { draft: 0, sent: 0, paid: 0, overdue: 0 }; + const statusCount: Record = { + draft: 0, + sent: 0, + paid: 0, + overdue: 0, + }; 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; } - return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount }; + return { + revenueByMonth, + topClients, + totalRevenue, + totalPending, + totalHours, + statusCount, + }; }, [invoices]); // Tax summary for selected year @@ -98,16 +143,45 @@ export default function ReportsPage() { 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 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 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 getSubtotal = (inv: (typeof yearInvoices)[number]) => { + const itemSubtotal = (inv.items ?? []).reduce( + (s, item) => s + item.amount, + 0, + ); + if (itemSubtotal > 0) return itemSubtotal; + + const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100; + return taxMultiplier > 0 + ? inv.totalAmount / taxMultiplier + : inv.totalAmount; + }; + + const grossIncome = yearInvoices.reduce( + (s, inv) => s + getSubtotal(inv), + 0, + ); + const taxCollected = yearInvoices.reduce( + (s, inv) => s + (inv.totalAmount - getSubtotal(inv)), + 0, + ); const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0); const deductibleExpenses = yearExpenses - .filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible) + .filter( + (exp) => + (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible, + ) .reduce((s, exp) => s + exp.amount, 0); const netProfit = grossIncome - deductibleExpenses; @@ -121,24 +195,50 @@ export default function ReportsPage() { 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), + income: yearInvoices + .filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())) + .reduce((s, inv) => s + getSubtotal(inv), 0), + expenses: yearExpenses + .filter((exp) => qMonths.includes(new Date(exp.date).getMonth())) + .reduce((s, exp) => s + exp.amount, 0), }; }); - return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses }; + 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([currentYear, currentYear - 1]); - for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear()); + 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; + 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[] = [ @@ -148,23 +248,42 @@ export default function ReportsPage() { "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(","); + const subtotal = (inv.items ?? []).reduce( + (s, item) => s + item.amount, + 0, + ); + const fallbackSubtotal = + inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100); + const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal; + const taxAmt = inv.totalAmount - invoiceSubtotal; + return [ + new Date(inv.issueDate).toLocaleDateString("en-US"), + inv.invoiceNumber, + `"${inv.client?.name ?? ""}"`, + invoiceSubtotal.toFixed(2), + `${(inv.taxRate ?? 0).toFixed(1)}%`, + taxAmt.toFixed(2), + inv.totalAmount.toFixed(2), + ].join(","); }), `,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`, "", "EXPENSES", "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", - ...taxData.yearExpenses.map((exp) => [ - new Date(exp.date).toLocaleDateString("en-US"), - `"${exp.description}"`, - `"${exp.category ?? ""}"`, - exp.amount.toFixed(2), - exp.currency, - exp.billable ? "Yes" : "No", - exp.reimbursable ? "Yes" : "No", - (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No", - ].join(",")), + ...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", @@ -176,7 +295,9 @@ export default function ReportsPage() { `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 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; @@ -188,9 +309,15 @@ export default function ReportsPage() { if (isLoading) { return (
- +
- {[...Array(4)].map((_, i) =>
)} + {Array.from({ length: 4 }).map((_, i) => ( +
+ ))}
); @@ -198,12 +325,20 @@ export default function ReportsPage() { return (
- + - Overview - Tax Summary + + Overview + + + Tax Summary + {/* ── OVERVIEW TAB ── */} @@ -212,60 +347,139 @@ export default function ReportsPage() {
-
-

Total Revenue

+
+ +
+

+ Total Revenue +

-

{formatCurrency(overviewData?.totalRevenue ?? 0)}

+

+ {formatCurrency(overviewData?.totalRevenue ?? 0)} +

-
-

Pending

+
+ +
+

+ Pending +

-

{formatCurrency(overviewData?.totalPending ?? 0)}

+

+ {formatCurrency(overviewData?.totalPending ?? 0)} +

-
-

Avg Invoice

+
+ +
+

+ Avg Invoice +

-

{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}

+

+ {formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)} +

-
-

Total Hours

+
+ +
+

+ Total Hours +

-

{(overviewData?.totalHours ?? 0).toFixed(1)}h

+

+ {(overviewData?.totalHours ?? 0).toFixed(1)}h +

- Revenue (Last 12 Months) + + Revenue (Last 12 Months) +
- - - + + + - - - `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> - [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> - + + + + `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}` + } + /> + [ + formatCurrency(toNumericChartValue(value)), + "Revenue", + ]} + contentStyle={{ + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: 12, + }} + /> +
@@ -275,19 +489,62 @@ export default function ReportsPage() {
- Top Clients by Revenue + + Top Clients by Revenue + {!overviewData?.topClients.length ? ( -

No paid invoices yet.

+

+ No paid invoices yet. +

) : (
- - `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> - - [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} /> - + + + `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}` + } + /> + + [ + formatCurrency(toNumericChartValue(value)), + "Revenue", + ]} + contentStyle={{ + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: 12, + }} + /> +
@@ -296,38 +553,76 @@ export default function ReportsPage() {
- Invoice Status Breakdown + + Invoice Status Breakdown + - {Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => ( -
- -
-
-
+ {Object.entries(overviewData?.statusCount ?? {}).map( + ([status, count]) => ( +
+ +
+
+
+
+ + {count} +
- {count}
-
- ))} - {invoices.length === 0 &&

No invoices yet.

} + ), + )} + {invoices.length === 0 && ( +

+ No invoices yet. +

+ )}
{stats && ( - Recent Activity + + Recent Activity +
{stats.recentInvoices.map((inv) => ( -
+

{inv.client?.name ?? "—"}

-

{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}

+

+ {new Date(inv.issueDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

- -

{formatCurrency(inv.totalAmount)}

+ +

+ {formatCurrency(inv.totalAmount)} +

))} @@ -343,9 +638,15 @@ export default function ReportsPage() {
Tax Year
@@ -357,17 +658,27 @@ export default function ReportsPage() { {/* Income */} - Income + + Income +
- Gross Income (paid invoices) - {formatCurrency(taxData.grossIncome)} + + Gross Income (paid invoices) + + + {formatCurrency(taxData.grossIncome)} +
{taxData.taxCollected > 0 && (
- Tax Collected from Clients - {formatCurrency(taxData.taxCollected)} + + Tax Collected from Clients + + + {formatCurrency(taxData.taxCollected)} +
)} @@ -381,74 +692,151 @@ export default function ReportsPage() { {/* Expenses */} - Expenses & Deductions + + Expenses & Deductions +
Total Expenses - {formatCurrency(taxData.totalExpenses)} + + {formatCurrency(taxData.totalExpenses)} +
- Tax-Deductible Expenses - {formatCurrency(taxData.deductibleExpenses)} + + Tax-Deductible Expenses + + + {formatCurrency(taxData.deductibleExpenses)} +
- {taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && ( -

Mark expenses as "Tax Deductible" in the Expenses page to include them here.

- )} + {taxData.totalExpenses > 0 && + taxData.deductibleExpenses === 0 && ( +

+ Mark expenses as "Tax Deductible" in the Expenses + page to include them here. +

+ )}
{/* Estimated tax */} - Estimated Tax Liability + + Estimated Tax Liability +
- Net Profit (income − deductible expenses) - {formatCurrency(taxData.netProfit)} + + Net Profit (income − deductible expenses) + + + {formatCurrency(taxData.netProfit)} +
- Self-Employment Tax (15.3% on 92.35% of net) - {formatCurrency(taxData.selfEmploymentTax)} + + Self-Employment Tax (15.3% on 92.35% of net) + + + {formatCurrency(taxData.selfEmploymentTax)} +
- Federal Income Tax (est. 22% bracket) - {formatCurrency(taxData.federalEstimate)} + + Federal Income Tax (est. 22% bracket) + + + {formatCurrency(taxData.federalEstimate)} +
Total Estimated Tax - {formatCurrency(taxData.totalEstimated)} + + {formatCurrency(taxData.totalEstimated)} +
-

- Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing. +

+ Assumes US self-employment tax rules and the 22% federal + bracket. Consult a tax professional for accurate filing.

{/* Quarterly chart */} - Quarterly Breakdown + + Quarterly Breakdown +
- - - `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> - [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]} - contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} + + + + `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}` + } + /> + [ + formatCurrency(toNumericChartValue(value)), + name === "income" ? "Income" : "Expenses", + ]} + contentStyle={{ + background: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + fontSize: 12, + }} + /> + + - -
-
- Income - Expenses +
+ + {" "} + Income + + + {" "} + Expenses +
diff --git a/src/components/data/invoice-view.tsx b/src/components/data/invoice-view.tsx deleted file mode 100644 index 67bf696..0000000 --- a/src/components/data/invoice-view.tsx +++ /dev/null @@ -1,516 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { api } from "~/trpc/react"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Button } from "~/components/ui/button"; - -import { StatusBadge, type StatusType } from "~/components/data/status-badge"; -import { Separator } from "~/components/ui/separator"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "~/components/ui/dialog"; -import { toast } from "sonner"; -import { format } from "date-fns"; -import { - FileText, - User, - DollarSign, - Trash2, - Download, - Send, - Clock, - MapPin, - Mail, - Phone, - AlertCircle, -} from "lucide-react"; -import Link from "next/link"; -import { generateInvoicePDF } from "~/lib/pdf-export"; -import { Skeleton } from "~/components/ui/skeleton"; - -interface InvoiceViewProps { - invoiceId: string; -} - -const statusIconConfig = { - draft: FileText, - sent: Send, - paid: DollarSign, - overdue: AlertCircle, -} as const; - -export function InvoiceView({ invoiceId }: InvoiceViewProps) { - const router = useRouter(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [isExportingPDF, setIsExportingPDF] = useState(false); - - // Fetch invoice data - const { - data: invoice, - isLoading, - refetch, - } = api.invoices.getById.useQuery({ id: invoiceId }); - - // Delete mutation - const deleteInvoice = api.invoices.delete.useMutation({ - onSuccess: () => { - toast.success("Invoice deleted successfully"); - setDeleteDialogOpen(false); - router.push("/dashboard/invoices"); - }, - onError: (error) => { - toast.error(error.message ?? "Failed to delete invoice"); - }, - }); - - // Update status mutation - const updateStatus = api.invoices.updateStatus.useMutation({ - onSuccess: () => { - toast.success("Status updated successfully"); - void refetch(); - }, - onError: (error) => { - toast.error(error.message ?? "Failed to update status"); - }, - }); - - const handleDelete = () => { - setDeleteDialogOpen(true); - }; - - const confirmDelete = () => { - deleteInvoice.mutate({ id: invoiceId }); - }; - - const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => { - updateStatus.mutate({ id: invoiceId, status: newStatus }); - }; - - const handlePDFExport = async () => { - if (!invoice) return; - - setIsExportingPDF(true); - try { - await generateInvoicePDF(invoice); - toast.success("PDF exported successfully"); - } catch (error) { - console.error("PDF export error:", error); - toast.error("Failed to export PDF. Please try again."); - } finally { - setIsExportingPDF(false); - } - }; - - const formatCurrency = (amount: number, currency = "USD") => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - }).format(amount); - }; - - const formatDate = (date: Date) => { - return format(new Date(date), "MMM dd, yyyy"); - }; - - const isOverdue = - invoice && - new Date(invoice.dueDate) < new Date() && - invoice.status !== "paid"; - - if (isLoading) { - return ( -
- - - - - - -
- - - -
-
-
-
- ); - } - - if (!invoice) { - return ( -
- -

- Invoice not found -

-

- The invoice you're looking for doesn't exist or has been - deleted. -

- -
- ); - } - - const StatusIcon = - statusIconConfig[invoice.status as keyof typeof statusIconConfig]; - - return ( -
- {/* Status Alert */} - {isOverdue && ( - - -
- - This invoice is overdue -
-
-
- )} - -
- {/* Main Content */} -
- {/* Invoice Header Card */} - - -
-
-
-
- -
-
-

- {invoice.invoiceNumber} -

-

- Professional Invoice -

-
-
- -
-
- Issue Date -

- {formatDate(invoice.issueDate)} -

-
-
- Due Date -

- {formatDate(invoice.dueDate)} -

-
-
-
- -
-
- - - -
- {formatCurrency(invoice.totalAmount)} -
-
- -
-
-
-
- - {/* Client Information */} - - - - - Bill To - - - -
-

- {invoice.client?.name} -

-
- -
- {invoice.client?.email && ( -
- - {invoice.client.email} -
- )} - {invoice.client?.phone && ( -
- - {invoice.client.phone} -
- )} - {(invoice.client?.addressLine1 ?? - invoice.client?.city ?? - invoice.client?.state) && ( -
- -
- {invoice.client?.addressLine1 && ( -
{invoice.client.addressLine1}
- )} - {invoice.client?.addressLine2 && ( -
{invoice.client.addressLine2}
- )} - {(invoice.client?.city ?? - invoice.client?.state ?? - invoice.client?.postalCode) && ( -
- {[ - invoice.client?.city, - invoice.client?.state, - invoice.client?.postalCode, - ] - .filter(Boolean) - .join(", ")} -
- )} - {invoice.client?.country && ( -
{invoice.client.country}
- )} -
-
- )} -
-
-
- - {/* Invoice Items */} - - - - - Invoice Items - - - -
- {invoice.items?.map((item, index) => ( -
-
-
- {item.description} -
-
- {formatDate(item.date)} · {item.hours}h @{" "} - {formatCurrency(item.rate)}/hr -
-
-
- {formatCurrency(item.amount)} -
-
- ))} -
-
-
- - {/* Notes */} - {invoice.notes && ( - - - Notes - - -

- {invoice.notes} -

-
-
- )} -
- - {/* Sidebar */} -
- {/* Status Actions */} - - - Status Actions - - - {invoice.status === "draft" && ( - - )} - - {invoice.status === "sent" && ( - - )} - - {invoice.status === "overdue" && ( - - )} - - {invoice.status === "paid" && ( -
- -

Invoice Paid

-
- )} -
-
- - {/* Invoice Summary */} - - - Summary - - -
-
- Subtotal - - {formatCurrency(invoice.totalAmount, invoice.currency)} - -
- {(invoice.taxRate ?? 0) > 0 && ( -
- - Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%) - - - {formatCurrency( - invoice.totalAmount * (invoice.taxRate ?? 0), - invoice.currency, - )} - -
- )} - -
- Total - - {formatCurrency( - invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), - invoice.currency, - )} - -
-
- -
-

- {invoice.items?.length ?? 0} item - {invoice.items?.length !== 1 ? "s" : ""} -

-
-
-
- - {/* Danger Zone */} - - - Danger Zone - - - - - -
-
- - {/* Delete Confirmation Dialog */} - - - - - Delete Invoice - - - Are you sure you want to delete this invoice? This action cannot - be undone and will permanently remove the invoice and all its - data. - - - - - - - - -
- ); -} diff --git a/src/components/forms/email-preview.tsx b/src/components/forms/email-preview.tsx index 9158cd0..790c39a 100644 --- a/src/components/forms/email-preview.tsx +++ b/src/components/forms/email-preview.tsx @@ -17,6 +17,8 @@ interface EmailPreviewProps { taxRate: number; status?: string; totalAmount?: number; + currency?: string | null; + notes?: string | null; client?: { name: string; email: string | null; @@ -27,8 +29,11 @@ interface EmailPreviewProps { }; items?: Array<{ id: string; + date?: Date; + description?: string; hours: number; rate: number; + amount?: number; }>; }; className?: string; @@ -66,7 +71,8 @@ export function EmailPreview({ status: invoice.status ?? "draft", totalAmount: invoice.totalAmount ?? calculateTotal(), taxRate: invoice.taxRate, - notes: null, + currency: invoice.currency, + notes: invoice.notes, client: { name: invoice.client?.name ?? "Client", email: invoice.client?.email ?? null, @@ -74,11 +80,11 @@ export function EmailPreview({ business: invoice.business ?? null, items: invoice.items?.map((item) => ({ - date: new Date(), - description: "Service", + date: item.date ?? new Date(), + description: item.description ?? "Service", hours: item.hours, rate: item.rate, - amount: item.hours * item.rate, + amount: item.amount ?? item.hours * item.rate, })) ?? [], }, customContent: content, @@ -95,7 +101,7 @@ export function EmailPreview({ return (
{/* Email Headers */} -
+
@@ -142,7 +148,7 @@ export function EmailPreview({ {/* Email Content */} {emailTemplate ? ( -
+