mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
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.
This commit is contained in:
@@ -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 (
|
||||
<Button
|
||||
onClick={handleSendInvoice}
|
||||
disabled={isSending}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={className}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleSendInvoice}
|
||||
disabled={isSending}
|
||||
variant={variant}
|
||||
size="default"
|
||||
className={`w-full shadow-sm ${className}`}
|
||||
data-testid="send-invoice-button"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Sending Email...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
|
||||
<div className={mode === "edit" ? "block" : "hidden"}>
|
||||
<InvoiceForm invoiceId={invoiceId} />
|
||||
</div>
|
||||
|
||||
{/* Show InvoiceView only when in view mode */}
|
||||
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
+514
-126
@@ -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<string, number> = {};
|
||||
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<string, { name: string; revenue: number }> = {};
|
||||
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<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) {
|
||||
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<number>([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 (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -198,12 +325,20 @@ export default function ReportsPage() {
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
description="Revenue and tax analytics"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
|
||||
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
|
||||
<TabsTrigger value="overview">
|
||||
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tax">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── OVERVIEW TAB ── */}
|
||||
@@ -212,60 +347,139 @@ export default function ReportsPage() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||
<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(overviewData?.totalRevenue ?? 0)}</p>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{formatCurrency(overviewData?.totalRevenue ?? 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||
<div className="rounded bg-yellow-500/10 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(overviewData?.totalPending ?? 0)}</p>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{formatCurrency(overviewData?.totalPending ?? 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
||||
<div className="rounded bg-blue-500/10 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>
|
||||
<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 className="rounded bg-green-500/10 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>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{(overviewData?.totalHours ?? 0).toFixed(1)}h
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
||||
<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={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
||||
<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={(value) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
"Revenue",
|
||||
]}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="hsl(142, 76%, 36%)"
|
||||
fill="url(#revenueGrad)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -275,19 +489,62 @@ export default function ReportsPage() {
|
||||
<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>
|
||||
<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>
|
||||
<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={(value) => [formatCurrency(toNumericChartValue(value)), "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
|
||||
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={(value) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
"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>
|
||||
@@ -296,38 +553,76 @@ export default function ReportsPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
|
||||
<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}%` }} />
|
||||
{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>
|
||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
|
||||
),
|
||||
)}
|
||||
{invoices.length === 0 && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
No invoices yet.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
<StatusBadge
|
||||
status={
|
||||
getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
) as never
|
||||
}
|
||||
/>
|
||||
<p className="font-semibold">
|
||||
{formatCurrency(inv.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -343,9 +638,15 @@ export default function ReportsPage() {
|
||||
<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>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
{availableYears.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -357,17 +658,27 @@ export default function ReportsPage() {
|
||||
{/* Income */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-muted-foreground">
|
||||
Tax Collected from Clients
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxData.taxCollected)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
@@ -381,74 +692,151 @@ export default function ReportsPage() {
|
||||
{/* Expenses */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 className="text-muted-foreground pt-1 text-xs">
|
||||
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>
|
||||
<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={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]}
|
||||
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
|
||||
<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={(value, name) => [
|
||||
formatCurrency(toNumericChartValue(value)),
|
||||
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}
|
||||
/>
|
||||
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
|
||||
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
|
||||
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
|
||||
Income
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
|
||||
Expenses
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
||||
Invoice not found
|
||||
</h3>
|
||||
<p className="text-muted mb-4">
|
||||
The invoice you're looking for doesn't exist or has been
|
||||
deleted.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/invoices">Back to Invoices</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon =
|
||||
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/10">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="font-medium">This invoice is overdue</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header Card */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex-shrink-0 p-2">
|
||||
<FileText className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-foreground truncate text-2xl font-bold">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Professional Invoice
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Issue Date</span>
|
||||
<p className="text-foreground font-medium">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Due Date</span>
|
||||
<p className="text-foreground font-medium">
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
|
||||
<div>
|
||||
<StatusBadge
|
||||
status={invoice.status as StatusType}
|
||||
className="px-3 py-1 text-sm font-medium"
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
</StatusBadge>
|
||||
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePDFExport}
|
||||
disabled={isExportingPDF}
|
||||
variant="default"
|
||||
className="flex-shrink-0 transform-none"
|
||||
>
|
||||
{isExportingPDF ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
|
||||
Generating PDF...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client Information */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-lg font-semibold">
|
||||
{invoice.client?.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
{invoice.client?.email && (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Mail className="text-muted-foreground h-4 w-4" />
|
||||
{invoice.client.email}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client?.phone && (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Phone className="text-muted-foreground h-4 w-4" />
|
||||
{invoice.client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(invoice.client?.addressLine1 ??
|
||||
invoice.client?.city ??
|
||||
invoice.client?.state) && (
|
||||
<div className="text-muted-foreground flex items-start gap-2 md:col-span-2">
|
||||
<MapPin className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div>
|
||||
{invoice.client?.addressLine1 && (
|
||||
<div>{invoice.client.addressLine1}</div>
|
||||
)}
|
||||
{invoice.client?.addressLine2 && (
|
||||
<div>{invoice.client.addressLine2}</div>
|
||||
)}
|
||||
{(invoice.client?.city ??
|
||||
invoice.client?.state ??
|
||||
invoice.client?.postalCode) && (
|
||||
<div>
|
||||
{[
|
||||
invoice.client?.city,
|
||||
invoice.client?.state,
|
||||
invoice.client?.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client?.country && (
|
||||
<div>{invoice.client.country}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="bg-secondary border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{invoice.items?.map((item, index) => (
|
||||
<div
|
||||
key={item.id || index}
|
||||
className="bg-background flex flex-col gap-1 rounded-lg p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-foreground font-medium break-words">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-sm">
|
||||
{formatDate(item.date)} · {item.hours}h @{" "}
|
||||
{formatCurrency(item.rate)}/hr
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-foreground flex-shrink-0 font-medium sm:text-right">
|
||||
{formatCurrency(item.amount)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status Actions */}
|
||||
<Card className="bg-secondary border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Status Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{invoice.status === "draft" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("sent")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Mark as Sent
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "sent" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "overdue" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "paid" && (
|
||||
<div className="py-4 text-center">
|
||||
<DollarSign className="text-primary mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-primary font-medium">Invoice Paid</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Summary */}
|
||||
<Card className="bg-card border-border border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{(invoice.taxRate ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
|
||||
</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatCurrency(
|
||||
invoice.totalAmount * (invoice.taxRate ?? 0),
|
||||
invoice.currency,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-foreground">Total</span>
|
||||
<span className="text-primary">
|
||||
{formatCurrency(
|
||||
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
|
||||
invoice.currency,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t pt-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{invoice.items?.length ?? 0} item
|
||||
{invoice.items?.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="bg-card border-destructive/20 border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-card border-border border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground text-xl font-bold">
|
||||
Delete Invoice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Are you sure you want to delete this invoice? This action cannot
|
||||
be undone and will permanently remove the invoice and all its
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteInvoice.isPending}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={className}>
|
||||
{/* Email Headers */}
|
||||
<div className="bg-muted/20 mb-4 space-y-3 p-4">
|
||||
<div className="bg-muted/20 mb-4 space-y-3 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs font-medium">
|
||||
@@ -142,7 +148,7 @@ export function EmailPreview({
|
||||
|
||||
{/* Email Content */}
|
||||
{emailTemplate ? (
|
||||
<div className=" border bg-gray-50 p-1 shadow-sm">
|
||||
<div className="border bg-gray-50 p-1 shadow-sm">
|
||||
<iframe
|
||||
srcDoc={emailTemplate.html}
|
||||
className="h-[700px] w-full rounded border-0"
|
||||
|
||||
@@ -76,6 +76,13 @@ function InvoiceFormSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultHourlyRate(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
|
||||
return typeof rate === "number" ? rate : null;
|
||||
}
|
||||
|
||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
@@ -140,18 +147,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||
// ... (Mapping logic same as before)
|
||||
const mappedItems: InvoiceItem[] =
|
||||
existingInvoice.items
|
||||
?.map((item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
) || [];
|
||||
existingInvoice.items?.map((item) => ({
|
||||
id: crypto.randomUUID(),
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
})) || [];
|
||||
setFormData({
|
||||
invoiceNumber: existingInvoice.invoiceNumber,
|
||||
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||
@@ -341,17 +344,13 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
notes: formData.notes,
|
||||
taxRate: formData.taxRate,
|
||||
currency: formData.currency,
|
||||
items: formData.items
|
||||
.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
)
|
||||
.map((i) => ({
|
||||
date: i.date,
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
amount: i.hours * i.rate,
|
||||
})),
|
||||
items: formData.items.map((i) => ({
|
||||
date: i.date,
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
amount: i.hours * i.rate,
|
||||
})),
|
||||
};
|
||||
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
||||
await updateInvoice.mutateAsync({ id: invoiceId, ...payload });
|
||||
@@ -454,18 +453,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
const currentBusiness = businesses?.find(
|
||||
(b) => b.id === formData.businessId,
|
||||
);
|
||||
const clientRate =
|
||||
selectedClient && "defaultHourlyRate" in selectedClient
|
||||
? selectedClient.defaultHourlyRate
|
||||
: null;
|
||||
const clientRate = getDefaultHourlyRate(selectedClient);
|
||||
const businessRate =
|
||||
currentBusiness &&
|
||||
"defaultHourlyRate" in currentBusiness
|
||||
? currentBusiness.defaultHourlyRate
|
||||
: null;
|
||||
getDefaultHourlyRate(currentBusiness);
|
||||
updateField(
|
||||
"defaultHourlyRate",
|
||||
(clientRate ?? businessRate ?? 0) as number,
|
||||
clientRate ?? businessRate ?? 0,
|
||||
);
|
||||
// Auto-fill currency from client
|
||||
if (
|
||||
@@ -473,10 +466,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
"currency" in selectedClient &&
|
||||
selectedClient.currency
|
||||
) {
|
||||
updateField(
|
||||
"currency",
|
||||
selectedClient.currency as string,
|
||||
);
|
||||
updateField("currency", selectedClient.currency);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -772,6 +762,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
taxRate: formData.taxRate,
|
||||
status: formData.status,
|
||||
totalAmount: totals.total,
|
||||
currency: formData.currency,
|
||||
notes: formData.notes,
|
||||
client: selectedClient
|
||||
? {
|
||||
name: selectedClient.name,
|
||||
@@ -786,8 +778,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
: undefined,
|
||||
items: formData.items.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,8 @@ interface SendEmailDialogProps {
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
taxRate: number;
|
||||
currency?: string | null;
|
||||
notes?: string | null;
|
||||
client?: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
@@ -47,8 +49,11 @@ interface SendEmailDialogProps {
|
||||
};
|
||||
items?: Array<{
|
||||
id: string;
|
||||
date?: Date;
|
||||
description?: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount?: number;
|
||||
}>;
|
||||
};
|
||||
onEmailSent?: () => void;
|
||||
|
||||
@@ -6,6 +6,7 @@ interface InvoiceEmailTemplateProps {
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
currency?: string | null;
|
||||
notes?: string | null;
|
||||
client: {
|
||||
name: string;
|
||||
@@ -57,10 +58,22 @@ export function generateInvoiceEmailTemplate({
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
currency: invoice.currency ?? "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const formattedNotes = invoice.notes?.trim()
|
||||
? escapeHtml(invoice.notes).replace(/\n/g, "<br>")
|
||||
: "";
|
||||
|
||||
const getTimeOfDayGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
@@ -459,7 +472,16 @@ export function generateInvoiceEmailTemplate({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
${
|
||||
formattedNotes
|
||||
? `<div class="invoice-card">
|
||||
<div class="invoice-summary">
|
||||
<div class="invoice-number" style="font-size: 18px;">Notes</div>
|
||||
</div>
|
||||
<div class="message" style="margin-bottom: 0;">${formattedNotes}</div>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<div class="attachment-notice">
|
||||
<div class="attachment-icon"></div>
|
||||
@@ -540,7 +562,15 @@ Subtotal: ${formatCurrency(subtotal)}${
|
||||
}
|
||||
Total: ${formatCurrency(total)}
|
||||
|
||||
|
||||
${
|
||||
invoice.notes?.trim()
|
||||
? `
|
||||
NOTES
|
||||
═══════════════
|
||||
${invoice.notes.trim()}
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
ATTACHMENT
|
||||
═══════════════
|
||||
|
||||
@@ -105,6 +105,7 @@ export const emailRouter = createTRPCRouter({
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency,
|
||||
notes: invoice.notes,
|
||||
client: {
|
||||
name: invoice.client.name,
|
||||
|
||||
@@ -43,6 +43,15 @@ const updateStatusSchema = z.object({
|
||||
status: z.enum(["draft", "sent", "paid"]),
|
||||
});
|
||||
|
||||
const calculateInvoiceTotal = (
|
||||
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||
taxRate: number,
|
||||
) => {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
||||
const taxAmount = (subtotal * taxRate) / 100;
|
||||
return subtotal + taxAmount;
|
||||
};
|
||||
|
||||
export const invoicesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
@@ -140,11 +149,19 @@ export const invoicesRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { items, ...invoiceData } = input;
|
||||
const cleanInvoiceData = {
|
||||
...invoiceData,
|
||||
businessId:
|
||||
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
||||
? null
|
||||
: invoiceData.businessId,
|
||||
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
||||
};
|
||||
|
||||
// Verify business exists and belongs to user (if provided)
|
||||
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") {
|
||||
if (cleanInvoiceData.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
where: eq(businesses.id, cleanInvoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
@@ -165,7 +182,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
|
||||
// Verify client exists and belongs to user
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, invoiceData.clientId),
|
||||
where: eq(clients.id, cleanInvoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
@@ -183,42 +200,39 @@ export const invoicesRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
0,
|
||||
const totalAmount = calculateInvoiceTotal(
|
||||
items,
|
||||
cleanInvoiceData.taxRate,
|
||||
);
|
||||
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
// Create invoice
|
||||
const [invoice] = await ctx.db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
...invoiceData,
|
||||
totalAmount,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
const [invoice] = await tx
|
||||
.insert(invoices)
|
||||
.values({
|
||||
...cleanInvoiceData,
|
||||
totalAmount,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create invoice",
|
||||
});
|
||||
}
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create invoice",
|
||||
});
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const itemsToInsert = items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
}));
|
||||
await tx.insert(invoiceItems).values(
|
||||
items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
|
||||
return invoice;
|
||||
return invoice;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
@@ -238,11 +252,17 @@ export const invoicesRouter = createTRPCRouter({
|
||||
// Clean up empty strings to null for optional string fields only
|
||||
const cleanInvoiceData = {
|
||||
...invoiceData,
|
||||
businessId:
|
||||
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
||||
? null
|
||||
: invoiceData.businessId,
|
||||
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
||||
...(invoiceData.businessId !== undefined
|
||||
? {
|
||||
businessId:
|
||||
invoiceData.businessId.trim() === ""
|
||||
? null
|
||||
: invoiceData.businessId,
|
||||
}
|
||||
: {}),
|
||||
...(invoiceData.notes !== undefined
|
||||
? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Verify invoice exists and belongs to user
|
||||
@@ -295,70 +315,58 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
if (items) {
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = items.reduce(
|
||||
(sum, item) => sum + item.hours * item.rate,
|
||||
0,
|
||||
);
|
||||
const taxAmount =
|
||||
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
|
||||
100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
if (items) {
|
||||
const totalAmount = calculateInvoiceTotal(
|
||||
items,
|
||||
cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
|
||||
);
|
||||
|
||||
// Update invoice
|
||||
const updateData = {
|
||||
...cleanInvoiceData,
|
||||
totalAmount,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const [updatedInvoice] = await tx
|
||||
.update(invoices)
|
||||
.set({
|
||||
...cleanInvoiceData,
|
||||
totalAmount,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
const [updatedInvoice] = await ctx.db
|
||||
.update(invoices)
|
||||
.set(updateData)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
if (!updatedInvoice) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice",
|
||||
});
|
||||
}
|
||||
|
||||
if (!updatedInvoice) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice",
|
||||
});
|
||||
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||
|
||||
await tx.insert(invoiceItems).values(
|
||||
items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
const [updatedInvoice] = await tx
|
||||
.update(invoices)
|
||||
.set({
|
||||
...cleanInvoiceData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updatedInvoice) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete existing items and create new ones
|
||||
await ctx.db
|
||||
.delete(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, id));
|
||||
|
||||
const itemsToInsert = items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
position: idx,
|
||||
}));
|
||||
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
} else {
|
||||
// Update invoice without items
|
||||
const updateData = {
|
||||
...cleanInvoiceData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [updatedInvoice] = await ctx.db
|
||||
.update(invoices)
|
||||
.set(updateData)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updatedInvoice) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user