4 Commits

Author SHA1 Message Date
soconnor bd3181fb9d feat: add PDF preview functionality and normalize email message handling 2026-04-28 01:26:47 -04:00
soconnor 915ec103fc feat: add email message field to invoices and update related components 2026-04-28 01:06:45 -04:00
soconnor 4108019eab feat: enhance PDF generation with improved line estimation and page budgeting 2026-04-28 00:44:00 -04:00
soconnor 84a5d997b4 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.
2026-04-28 00:34:56 -04:00
21 changed files with 1251 additions and 1324 deletions
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "beenvoice_invoice"
ADD COLUMN "emailMessage" varchar(2000);
+7
View File
@@ -50,6 +50,13 @@
"when": 1777338000000, "when": 1777338000000,
"tag": "0006_pdf_generation_settings", "tag": "0006_pdf_generation_settings",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1777339000000,
"tag": "0007_invoice_email_message",
"breakpoints": true
} }
] ]
} }
@@ -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>
);
}
+2 -2
View File
@@ -99,10 +99,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}).format(new Date(date)); }).format(new Date(date));
}; };
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number, currency = invoice.currency) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency,
}).format(amount); }).format(amount);
}; };
+65 -11
View File
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
); );
} }
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export default function SendEmailPage() { export default function SendEmailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@@ -155,7 +181,10 @@ export default function SendEmailPage() {
issueDate: invoiceData.issueDate, issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate, dueDate: invoiceData.dueDate,
status: invoiceData.status, status: invoiceData.status,
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate, taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
emailMessage: invoiceData.emailMessage,
client: invoiceData.client client: invoiceData.client
? { ? {
name: invoiceData.client.name, name: invoiceData.client.name,
@@ -171,13 +200,21 @@ export default function SendEmailPage() {
: undefined, : undefined,
items: invoiceData.items?.map((item) => ({ items: invoiceData.items?.map((item) => ({
id: item.id, id: item.id,
date: item.date,
description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount,
})), })),
} }
: undefined; : undefined;
}, [invoiceData]); }, [invoiceData]);
const normalizedCustomMessage = useMemo(
() => normalizeEmailNoteHtml(customMessage),
[customMessage],
);
// Initialize email content when invoice loads // Initialize email content when invoice loads
useEffect(() => { useEffect(() => {
if (!invoice || isInitialized) return; if (!invoice || isInitialized) return;
@@ -191,6 +228,9 @@ export default function SendEmailPage() {
const defaultContent = ``; const defaultContent = ``;
setEmailContent(defaultContent); setEmailContent(defaultContent);
setCustomMessage(
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
);
setIsInitialized(true); setIsInitialized(true);
}, [invoice, isInitialized]); }, [invoice, isInitialized]);
@@ -222,7 +262,7 @@ export default function SendEmailPage() {
invoiceId, invoiceId,
customSubject: subject, customSubject: subject,
customContent: emailContent, customContent: emailContent,
customMessage: customMessage?.trim() || undefined, customMessage: normalizedCustomMessage,
useHtml: true, useHtml: true,
ccEmails: ccEmail.trim() || undefined, ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined, bccEmails: bccEmail.trim() || undefined,
@@ -366,7 +406,7 @@ export default function SendEmailPage() {
ccEmail={ccEmail} ccEmail={ccEmail}
bccEmail={bccEmail} bccEmail={bccEmail}
content={emailContent} content={emailContent}
customMessage={customMessage} customMessage={normalizedCustomMessage}
invoice={invoice} invoice={invoice}
className="min-w-0 border-0" className="min-w-0 border-0"
/> />
@@ -552,10 +592,9 @@ export default function SendEmailPage() {
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Send Invoice Email?</DialogTitle> <DialogTitle>Confirm</DialogTitle>
<DialogDescription> <DialogDescription>
This will send invoice #{invoice.invoiceNumber} to{" "} Send this invoice email to <strong>{toEmail}</strong>
<strong>{invoice.client?.email}</strong>
{ccEmail && ( {ccEmail && (
<> <>
{" "} {" "}
@@ -568,14 +607,30 @@ export default function SendEmailPage() {
and BCC to <strong>{bccEmail}</strong> and BCC to <strong>{bccEmail}</strong>
</> </>
)} )}
. ?
</DialogDescription>
{retryCount > 0 && ( {retryCount > 0 && (
<div className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground text-sm">
Retry attempt {retryCount} of 2 Retry attempt {retryCount} of 2
</p>
)}
</DialogHeader>
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
<div>
<span className="text-muted-foreground">Subject: </span>
<span className="font-medium">{subject}</span>
</div>
<div>
<span className="text-muted-foreground">Attachment: </span>
<span>invoice-{invoice.invoiceNumber}.pdf</span>
</div>
{normalizedCustomMessage && (
<div>
<span className="text-muted-foreground">Email note: </span>
<span>Included</span>
</div> </div>
)} )}
</DialogDescription> </div>
</DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@@ -584,8 +639,7 @@ export default function SendEmailPage() {
Cancel Cancel
</Button> </Button>
<Button onClick={confirmSendEmail} variant="default"> <Button onClick={confirmSendEmail} variant="default">
<Send className="mr-2 h-4 w-4" /> Confirm
Send Email
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+501 -113
View File
@@ -6,7 +6,13 @@ import { PageHeader } from "~/components/layout/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button"; import { 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 { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
@@ -23,7 +29,15 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } 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) { function toNumericChartValue(value: unknown) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0); const numericValue = typeof value === "number" ? value : Number(value ?? 0);
@@ -31,20 +45,22 @@ function toNumericChartValue(value: unknown) {
} }
export default function ReportsPage() { export default function ReportsPage() {
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery(); const { data: invoices = [], isLoading: invoicesLoading } =
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery(); api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } =
api.expenses.getAll.useQuery();
const { data: stats } = api.dashboard.getStats.useQuery(); const { data: stats } = api.dashboard.getStats.useQuery();
const isLoading = invoicesLoading || expensesLoading; const isLoading = invoicesLoading || expensesLoading;
const now = new Date(); const currentYear = new Date().getFullYear();
const currentYear = now.getFullYear();
const [taxYear, setTaxYear] = useState(String(currentYear)); const [taxYear, setTaxYear] = useState(String(currentYear));
// Overview data (last 12 months) // Overview data (last 12 months)
const overviewData = useMemo(() => { const overviewData = useMemo(() => {
if (!invoices.length) return null; if (!invoices.length) return null;
const now = new Date();
const monthMap: Record<string, number> = {}; const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) { for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
@@ -57,7 +73,10 @@ export default function ReportsPage() {
let totalHours = 0; let totalHours = 0;
for (const inv of invoices) { for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
if (status === "paid") { if (status === "paid") {
totalRevenue += inv.totalAmount; totalRevenue += inv.totalAmount;
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`; 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]) => ({ 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, revenue,
})); }));
const clientMap: Record<string, { name: string; revenue: number }> = {}; const clientMap: Record<string, { name: string; revenue: number }> = {};
for (const inv of invoices) { for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
if (status === "paid" && inv.client) { if (status === "paid" && inv.client) {
const id = inv.client.id; const id = inv.client.id;
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 }; const entry = (clientMap[id] ??= {
clientMap[id]!.revenue += inv.totalAmount; 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) { for (const inv of invoices) {
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const s = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
statusCount[s] = (statusCount[s] ?? 0) + 1; statusCount[s] = (statusCount[s] ?? 0) + 1;
} }
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount }; return {
revenueByMonth,
topClients,
totalRevenue,
totalPending,
totalHours,
statusCount,
};
}, [invoices]); }, [invoices]);
// Tax summary for selected year // Tax summary for selected year
@@ -98,16 +143,45 @@ export default function ReportsPage() {
const year = parseInt(taxYear); const year = parseInt(taxYear);
const yearInvoices = invoices.filter((inv) => { const yearInvoices = invoices.filter((inv) => {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate); const status = getEffectiveInvoiceStatus(
return status === "paid" && new Date(inv.issueDate).getFullYear() === year; 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 getSubtotal = (inv: (typeof yearInvoices)[number]) => {
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0); 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 totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
const deductibleExpenses = yearExpenses 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); .reduce((s, exp) => s + exp.amount, 0);
const netProfit = grossIncome - deductibleExpenses; const netProfit = grossIncome - deductibleExpenses;
@@ -121,23 +195,49 @@ export default function ReportsPage() {
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2]; const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
return { return {
label: `Q${q}`, label: `Q${q}`,
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0), income: yearInvoices
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0), .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]); }, [invoices, expenses, taxYear]);
const availableYears = useMemo(() => { const availableYears = useMemo(() => {
const years = new Set<number>([currentYear, currentYear - 1]); 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()); for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
return Array.from(years).sort((a, b) => b - a); return Array.from(years).sort((a, b) => b - a);
}, [invoices, expenses, currentYear]); }, [invoices, expenses, currentYear]);
const avgInvoice = invoices.length > 0 const avgInvoice =
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1) invoices.length > 0
? (overviewData?.totalRevenue ?? 0) /
(invoices.filter(
(i) =>
getEffectiveInvoiceStatus(
i.status as StoredInvoiceStatus,
i.dueDate,
) === "paid",
).length || 1)
: 0; : 0;
function exportCSV() { function exportCSV() {
@@ -148,14 +248,30 @@ export default function ReportsPage() {
"INCOME (Paid Invoices)", "INCOME (Paid Invoices)",
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total", "Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
...taxData.yearInvoices.map((inv) => { ...taxData.yearInvoices.map((inv) => {
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0); const subtotal = (inv.items ?? []).reduce(
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(","); (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)}`, `,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
"", "",
"EXPENSES", "EXPENSES",
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible", "Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
...taxData.yearExpenses.map((exp) => [ ...taxData.yearExpenses.map((exp) =>
[
new Date(exp.date).toLocaleDateString("en-US"), new Date(exp.date).toLocaleDateString("en-US"),
`"${exp.description}"`, `"${exp.description}"`,
`"${exp.category ?? ""}"`, `"${exp.category ?? ""}"`,
@@ -163,8 +279,11 @@ export default function ReportsPage() {
exp.currency, exp.currency,
exp.billable ? "Yes" : "No", exp.billable ? "Yes" : "No",
exp.reimbursable ? "Yes" : "No", exp.reimbursable ? "Yes" : "No",
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No", (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
].join(",")), ? "Yes"
: "No",
].join(","),
),
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`, `,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
"", "",
"TAX SUMMARY", "TAX SUMMARY",
@@ -176,7 +295,9 @@ export default function ReportsPage() {
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`, `Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
`Total Estimated Tax,${taxData.totalEstimated.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 url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -188,9 +309,15 @@ export default function ReportsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="page-enter space-y-6"> <div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and 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"> <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>
</div> </div>
); );
@@ -198,12 +325,20 @@ export default function ReportsPage() {
return ( return (
<div className="page-enter space-y-6 pb-6"> <div className="page-enter space-y-6 pb-6">
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" /> <PageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger> <TabsTrigger value="overview">
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger> <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> </TabsList>
{/* ── OVERVIEW TAB ── */} {/* ── OVERVIEW TAB ── */}
@@ -212,60 +347,139 @@ export default function ReportsPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div> <div className="bg-primary/10 rounded p-1.5">
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p> <DollarSign className="text-primary h-4 w-4" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p> <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>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div> <div className="rounded bg-yellow-500/10 p-1.5">
<p className="text-muted-foreground text-xs font-medium">Pending</p> <Clock className="h-4 w-4 text-yellow-500" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p> <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>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div> <div className="rounded bg-blue-500/10 p-1.5">
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p> <TrendingUp className="h-4 w-4 text-blue-500" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p> <p className="text-muted-foreground text-xs font-medium">
Avg Invoice
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div> <div className="rounded bg-green-500/10 p-1.5">
<p className="text-muted-foreground text-xs font-medium">Total Hours</p> <Users className="h-4 w-4 text-green-500" />
</div> </div>
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p> <p className="text-muted-foreground text-xs font-medium">
Total Hours
</p>
</div>
<p className="mt-2 text-2xl font-bold">
{(overviewData?.totalHours ?? 0).toFixed(1)}h
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent> <CardContent>
<div className="h-48 w-full md:h-64"> <div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={overviewData?.revenueByMonth ?? []}> <AreaChart data={overviewData?.revenueByMonth ?? []}>
<defs> <defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} /> id="revenueGrad"
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} /> 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> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} /> strokeDasharray="3 3"
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> className="stroke-border"
<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} /> <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> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -275,19 +489,62 @@ export default function ReportsPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent> <CardContent>
{!overviewData?.topClients.length ? ( {!overviewData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p> <p className="text-muted-foreground py-6 text-center text-sm">
No paid invoices yet.
</p>
) : ( ) : (
<div className="h-48 md:h-56"> <div className="h-48 md:h-56">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={overviewData.topClients} layout="vertical"> <BarChart
<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}`} /> data={overviewData.topClients}
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} /> layout="vertical"
<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]} /> <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> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -296,38 +553,76 @@ export default function ReportsPage() {
</Card> </Card>
<Card> <Card>
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader> <CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => ( {Object.entries(overviewData?.statusCount ?? {}).map(
<div key={status} className="flex items-center justify-between"> ([status, count]) => (
<div
key={status}
className="flex items-center justify-between"
>
<StatusBadge status={status as never} /> <StatusBadge status={status as never} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32"> <div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} /> <div
className="bg-primary h-full rounded-full"
style={{
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
}}
/>
</div> </div>
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span> <span className="text-muted-foreground w-8 text-right text-sm">
{count}
</span>
</div> </div>
</div> </div>
))} ),
{invoices.length === 0 && <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> </CardContent>
</Card> </Card>
</div> </div>
{stats && ( {stats && (
<Card> <Card>
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader> <CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="divide-y"> <div className="divide-y">
{stats.recentInvoices.map((inv) => ( {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> <div>
<p className="font-medium">{inv.client?.name ?? "—"}</p> <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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} /> <StatusBadge
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p> status={
getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
) as never
}
/>
<p className="font-semibold">
{formatCurrency(inv.totalAmount)}
</p>
</div> </div>
</div> </div>
))} ))}
@@ -343,9 +638,15 @@ export default function ReportsPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span> <span className="text-sm font-medium">Tax Year</span>
<Select value={taxYear} onValueChange={setTaxYear}> <Select value={taxYear} onValueChange={setTaxYear}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger> <SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)} {availableYears.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -357,17 +658,27 @@ export default function ReportsPage() {
{/* Income */} {/* Income */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Gross Income (paid invoices)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span> Gross Income (paid invoices)
</span>
<span className="font-medium">
{formatCurrency(taxData.grossIncome)}
</span>
</div> </div>
{taxData.taxCollected > 0 && ( {taxData.taxCollected > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax Collected from Clients</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span> Tax Collected from Clients
</span>
<span className="font-medium">
{formatCurrency(taxData.taxCollected)}
</span>
</div> </div>
)} )}
<Separator /> <Separator />
@@ -381,19 +692,31 @@ export default function ReportsPage() {
{/* Expenses */} {/* Expenses */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Expenses</span> <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>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax-Deductible Expenses</span> <span className="text-muted-foreground">
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span> Tax-Deductible Expenses
</span>
<span className="font-medium text-green-600">
{formatCurrency(taxData.deductibleExpenses)}
</span>
</div> </div>
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && ( {taxData.totalExpenses > 0 &&
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p> taxData.deductibleExpenses === 0 && (
<p className="text-muted-foreground text-xs">
Mark expenses as &quot;Tax Deductible&quot; in the Expenses
page to include them here.
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -401,54 +724,119 @@ export default function ReportsPage() {
{/* Estimated tax */} {/* Estimated tax */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Net Profit (income deductible expenses)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span> Net Profit (income deductible expenses)
</span>
<span className="font-medium">
{formatCurrency(taxData.netProfit)}
</span>
</div> </div>
<div className="flex justify-between text-sm"> <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="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span> Self-Employment Tax (15.3% on 92.35% of net)
</span>
<span className="font-medium">
{formatCurrency(taxData.selfEmploymentTax)}
</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span> <span className="text-muted-foreground">
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span> Federal Income Tax (est. 22% bracket)
</span>
<span className="font-medium">
{formatCurrency(taxData.federalEstimate)}
</span>
</div> </div>
<Separator /> <Separator />
<div className="flex justify-between text-lg font-bold"> <div className="flex justify-between text-lg font-bold">
<span>Total Estimated Tax</span> <span>Total Estimated Tax</span>
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span> <span className="text-destructive">
{formatCurrency(taxData.totalEstimated)}
</span>
</div> </div>
<p className="text-muted-foreground text-xs pt-1"> <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. Assumes US self-employment tax rules and the 22% federal
bracket. Consult a tax professional for accurate filing.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Quarterly chart */} {/* Quarterly chart */}
<Card> <Card>
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader> <CardHeader>
<CardTitle>Quarterly Breakdown</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="h-48 md:h-64"> <div className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={taxData.quarters}> <BarChart data={taxData.quarters}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} /> strokeDasharray="3 3"
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> className="stroke-border"
<Tooltip />
formatter={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]} <XAxis
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} 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> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground"> <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="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
-516
View File
@@ -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&apos;re looking for doesn&apos;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)} &middot; {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>
);
}
+5 -6
View File
@@ -96,7 +96,7 @@ export function EmailComposer({
content: customMessage, content: customMessage,
immediatelyRender: false, immediatelyRender: false,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
onCustomMessageChange?.(editor.getHTML()); onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML());
}, },
editorProps: { editorProps: {
attributes: { attributes: {
@@ -109,7 +109,7 @@ export function EmailComposer({
// Update editor content when customMessage prop changes // Update editor content when customMessage prop changes
useEffect(() => { useEffect(() => {
if (editor && customMessage !== undefined) { if (editor && customMessage !== undefined) {
const currentContent = editor.getHTML(); const currentContent = editor.isEmpty ? "" : editor.getHTML();
if (currentContent !== customMessage) { if (currentContent !== customMessage) {
editor.commands.setContent(customMessage); editor.commands.setContent(customMessage);
} }
@@ -222,11 +222,10 @@ export function EmailComposer({
{onCustomMessageChange && ( {onCustomMessageChange && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-sm font-medium"> <Label className="text-sm font-medium">Email Note (Optional)</Label>
Custom Message (Optional)
</Label>
<p className="text-muted-foreground mb-2 text-xs"> <p className="text-muted-foreground mb-2 text-xs">
This message will appear between the greeting and invoice summary This appears only in the email body and is not added to the
invoice PDF.
</p> </p>
</div> </div>
+9 -5
View File
@@ -17,6 +17,7 @@ interface EmailPreviewProps {
taxRate: number; taxRate: number;
status?: string; status?: string;
totalAmount?: number; totalAmount?: number;
currency?: string | null;
client?: { client?: {
name: string; name: string;
email: string | null; email: string | null;
@@ -27,8 +28,11 @@ interface EmailPreviewProps {
}; };
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date;
description?: string;
hours: number; hours: number;
rate: number; rate: number;
amount?: number;
}>; }>;
}; };
className?: string; className?: string;
@@ -66,7 +70,7 @@ export function EmailPreview({
status: invoice.status ?? "draft", status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(), totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: null, currency: invoice.currency,
client: { client: {
name: invoice.client?.name ?? "Client", name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null, email: invoice.client?.email ?? null,
@@ -74,11 +78,11 @@ export function EmailPreview({
business: invoice.business ?? null, business: invoice.business ?? null,
items: items:
invoice.items?.map((item) => ({ invoice.items?.map((item) => ({
date: new Date(), date: item.date ?? new Date(),
description: "Service", description: item.description ?? "Service",
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate, amount: item.amount ?? item.hours * item.rate,
})) ?? [], })) ?? [],
}, },
customContent: content, customContent: content,
@@ -142,7 +146,7 @@ export function EmailPreview({
{/* Email Content */} {/* Email Content */}
{emailTemplate ? ( {emailTemplate ? (
<div className=" border bg-gray-50 p-1 shadow-sm"> <div className="border bg-gray-50 p-1 shadow-sm">
<iframe <iframe
srcDoc={emailTemplate.html} srcDoc={emailTemplate.html}
className="h-[700px] w-full rounded border-0" className="h-[700px] w-full rounded border-0"
+157 -29
View File
@@ -76,6 +76,23 @@ 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;
}
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -90,6 +107,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: new Date(), dueDate: new Date(),
status: "draft", status: "draft",
notes: "", notes: "",
emailMessage: "",
taxRate: 0, taxRate: 0,
currency: "USD", currency: "USD",
defaultHourlyRate: null, defaultHourlyRate: null,
@@ -109,6 +127,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details"); const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf");
// Queries (Same as before) // Queries (Same as before)
const { data: clients, isLoading: loadingClients } = const { data: clients, isLoading: loadingClients } =
@@ -140,18 +159,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) { if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before) // ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] = const mappedItems: InvoiceItem[] =
existingInvoice.items existingInvoice.items?.map((item) => ({
?.map((item) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(item.date), date: new Date(item.date),
description: item.description, description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.amount, amount: item.amount,
})) })) || [];
.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
) || [];
setFormData({ setFormData({
invoiceNumber: existingInvoice.invoiceNumber, invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#", invoicePrefix: existingInvoice.invoicePrefix ?? "#",
@@ -161,6 +176,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: new Date(existingInvoice.dueDate), dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid", status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "", notes: existingInvoice.notes ?? "",
emailMessage: existingInvoice.emailMessage ?? "",
taxRate: existingInvoice.taxRate, taxRate: existingInvoice.taxRate,
currency: existingInvoice.currency ?? "USD", currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null, defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
@@ -201,6 +217,45 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const total = subtotal + taxAmount; const total = subtotal + taxAmount;
return { subtotal, taxAmount, total }; return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]); }, [formData.items, formData.taxRate]);
const emailPreviewMessage = React.useMemo(
() => plainTextToHtml(formData.emailMessage.trim()),
[formData.emailMessage],
);
const pdfPreviewInput = React.useMemo(
() => ({
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "",
clientId: formData.clientId,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
status: formData.status,
notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items.map((item) => ({
date: item.date,
description: item.description || "Service",
hours: item.hours,
rate: item.rate,
})),
}),
[formData],
);
const { data: pdfPreview, isFetching: pdfPreviewLoading } =
api.invoices.previewPdf.useQuery(pdfPreviewInput, {
enabled:
activeTab === "preview" &&
previewTab === "pdf" &&
Boolean(formData.clientId) &&
formData.items.length > 0 &&
formData.items.every((item) => item.description.trim() !== ""),
refetchOnWindowFocus: false,
staleTime: 0,
});
const selectedClient = React.useMemo( const selectedClient = React.useMemo(
() => clients?.find((client) => client.id === formData.clientId), () => clients?.find((client) => client.id === formData.clientId),
[clients, formData.clientId], [clients, formData.clientId],
@@ -339,13 +394,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: formData.dueDate, dueDate: formData.dueDate,
status: formData.status, status: formData.status,
notes: formData.notes, notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate, taxRate: formData.taxRate,
currency: formData.currency, currency: formData.currency,
items: formData.items items: formData.items.map((i) => ({
.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
)
.map((i) => ({
date: i.date, date: i.date,
description: i.description, description: i.description,
hours: i.hours, hours: i.hours,
@@ -454,18 +506,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const currentBusiness = businesses?.find( const currentBusiness = businesses?.find(
(b) => b.id === formData.businessId, (b) => b.id === formData.businessId,
); );
const clientRate = const clientRate = getDefaultHourlyRate(selectedClient);
selectedClient && "defaultHourlyRate" in selectedClient
? selectedClient.defaultHourlyRate
: null;
const businessRate = const businessRate =
currentBusiness && getDefaultHourlyRate(currentBusiness);
"defaultHourlyRate" in currentBusiness
? currentBusiness.defaultHourlyRate
: null;
updateField( updateField(
"defaultHourlyRate", "defaultHourlyRate",
(clientRate ?? businessRate ?? 0) as number, clientRate ?? businessRate ?? 0,
); );
// Auto-fill currency from client // Auto-fill currency from client
if ( if (
@@ -473,10 +519,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
"currency" in selectedClient && "currency" in selectedClient &&
selectedClient.currency selectedClient.currency
) { ) {
updateField( updateField("currency", selectedClient.currency);
"currency",
selectedClient.currency as string,
);
} }
}} }}
> >
@@ -630,12 +673,29 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Notes card — spans both columns */} <Card className="h-fit">
<Card className="h-fit lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base"> <CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Notes <Mail className="h-4 w-4" /> Email Message
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.emailMessage}
onChange={(e) => updateField("emailMessage", e.target.value)}
placeholder="Add a note that appears only in the email body..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 text-base">
<span className="flex items-center gap-2">
<FileText className="h-4 w-4" /> Invoice Notes
</span> </span>
{noteTemplates && noteTemplates.length > 0 && ( {noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu> <DropdownMenu>
@@ -666,8 +726,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Textarea <Textarea
value={formData.notes} value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the client…" placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[100px]" className="min-h-[140px]"
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -751,6 +811,67 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
value="preview" value="preview"
className="mt-6 focus-visible:outline-none" className="mt-6 focus-visible:outline-none"
> >
<Tabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger
value="pdf"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Email
</TabsTrigger>
</TabsList>
<TabsContent value="pdf" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<FileText className="h-5 w-5" /> PDF Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
{!formData.clientId ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client to generate the PDF preview.
</div>
) : formData.items.some(
(item) => item.description.trim() === "",
) ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Add descriptions for all line items to generate the
PDF preview.
</div>
) : pdfPreviewLoading && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Generating server PDF preview...
</div>
) : pdfPreview ? (
<iframe
title="Server-generated PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2"> <CardTitle className="flex gap-2">
@@ -765,6 +886,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
fromEmail={selectedBusiness?.email ?? ""} fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""} toEmail={selectedClient?.email ?? ""}
content="" content=""
customMessage={emailPreviewMessage}
invoice={{ invoice={{
invoiceNumber: formData.invoiceNumber, invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate, issueDate: formData.issueDate,
@@ -772,6 +894,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
taxRate: formData.taxRate, taxRate: formData.taxRate,
status: formData.status, status: formData.status,
totalAmount: totals.total, totalAmount: totals.total,
currency: formData.currency,
client: selectedClient client: selectedClient
? { ? {
name: selectedClient.name, name: selectedClient.name,
@@ -786,8 +909,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
: undefined, : undefined,
items: formData.items.map((item) => ({ items: formData.items.map((item) => ({
id: item.id, id: item.id,
date: item.date,
description: item.description,
hours: item.hours, hours: item.hours,
rate: item.rate, rate: item.rate,
amount: item.hours * item.rate,
})), })),
}} }}
/> />
@@ -795,6 +921,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</TabsContent>
</Tabs>
</div> </div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+1
View File
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
dueDate: Date; dueDate: Date;
status: "draft" | "sent" | "paid"; status: "draft" | "sent" | "paid";
notes: string; notes: string;
emailMessage: string;
taxRate: number; taxRate: number;
currency: string; currency: string;
defaultHourlyRate: number | null; defaultHourlyRate: number | null;
@@ -37,6 +37,7 @@ interface SendEmailDialogProps {
dueDate: Date; dueDate: Date;
status: string; status: string;
taxRate: number; taxRate: number;
currency?: string | null;
client?: { client?: {
name: string; name: string;
email: string | null; email: string | null;
@@ -47,8 +48,11 @@ interface SendEmailDialogProps {
}; };
items?: Array<{ items?: Array<{
id: string; id: string;
date?: Date;
description?: string;
hours: number; hours: number;
rate: number; rate: number;
amount?: number;
}>; }>;
}; };
onEmailSent?: () => void; onEmailSent?: () => void;
+2 -6
View File
@@ -6,7 +6,7 @@ interface InvoiceEmailTemplateProps {
status: string; status: string;
totalAmount: number; totalAmount: number;
taxRate: number; taxRate: number;
notes?: string | null; currency?: string | null;
client: { client: {
name: string; name: string;
email: string | null; email: string | null;
@@ -57,7 +57,7 @@ export function generateInvoiceEmailTemplate({
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: invoice.currency ?? "USD",
}).format(amount); }).format(amount);
}; };
@@ -459,8 +459,6 @@ export function generateInvoiceEmailTemplate({
</div> </div>
</div> </div>
<div class="attachment-notice"> <div class="attachment-notice">
<div class="attachment-icon"></div> <div class="attachment-icon"></div>
<div class="attachment-text"> <div class="attachment-text">
@@ -540,8 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
} }
Total: ${formatCurrency(total)} Total: ${formatCurrency(total)}
ATTACHMENT ATTACHMENT
═══════════════ ═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
+65 -179
View File
@@ -5,31 +5,11 @@ import {
View, View,
Image, Image,
StyleSheet, StyleSheet,
Font,
pdf, pdf,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import React from "react"; import React from "react";
Font.register({
family: "Frutiger",
fonts: [
{
src: "/fonts/frutiger/Frutiger.ttf",
fontWeight: "normal",
},
{
src: "/fonts/frutiger/Frutiger_bold.ttf",
fontWeight: "bold",
},
],
});
Font.register({
family: "Frutiger-Bold",
src: "/fonts/frutiger/Frutiger_bold.ttf",
});
// Fallback download function for better browser compatibility // Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void { function downloadBlob(blob: Blob, filename: string): void {
try { try {
@@ -142,7 +122,7 @@ const styles = StyleSheet.create({
page: { page: {
flexDirection: "column", flexDirection: "column",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
fontFamily: "Frutiger", fontFamily: "Helvetica",
fontSize: 10, fontSize: 10,
paddingTop: 40, paddingTop: 40,
paddingBottom: 80, paddingBottom: 80,
@@ -169,7 +149,7 @@ const styles = StyleSheet.create({
}, },
businessName: { businessName: {
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
fontSize: 18, fontSize: 18,
color: "#0f0f0f", color: "#0f0f0f",
marginBottom: 4, marginBottom: 4,
@@ -177,7 +157,7 @@ const styles = StyleSheet.create({
businessInfo: { businessInfo: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
marginBottom: 3, marginBottom: 3,
@@ -185,7 +165,7 @@ const styles = StyleSheet.create({
businessAddress: { businessAddress: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
marginTop: 4, marginTop: 4,
@@ -198,14 +178,14 @@ const styles = StyleSheet.create({
invoiceTitle: { invoiceTitle: {
fontSize: 28, fontSize: 28,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
marginBottom: 8, marginBottom: 8,
}, },
invoiceNumber: { invoiceNumber: {
fontSize: 14, fontSize: 14,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#374151", color: "#374151",
marginBottom: 4, marginBottom: 4,
}, },
@@ -214,7 +194,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 4, paddingVertical: 4,
fontSize: 11, fontSize: 11,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
textAlign: "center", textAlign: "center",
}, },
@@ -242,13 +222,13 @@ const styles = StyleSheet.create({
sectionTitle: { sectionTitle: {
fontSize: 12, fontSize: 12,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
marginBottom: 12, marginBottom: 12,
}, },
clientName: { clientName: {
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
fontSize: 12, fontSize: 12,
color: "#0f0f0f", color: "#0f0f0f",
marginBottom: 2, marginBottom: 2,
@@ -256,7 +236,7 @@ const styles = StyleSheet.create({
clientInfo: { clientInfo: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
marginBottom: 2, marginBottom: 2,
@@ -264,7 +244,7 @@ const styles = StyleSheet.create({
clientAddress: { clientAddress: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
marginTop: 4, marginTop: 4,
@@ -278,14 +258,14 @@ const styles = StyleSheet.create({
detailLabel: { detailLabel: {
fontSize: 11, fontSize: 11,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
flex: 1, flex: 1,
}, },
detailValue: { detailValue: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
flex: 1, flex: 1,
textAlign: "right", textAlign: "right",
@@ -301,21 +281,21 @@ const styles = StyleSheet.create({
notesTitle: { notesTitle: {
fontSize: 11, fontSize: 11,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
marginBottom: 6, marginBottom: 6,
}, },
notesContent: { notesContent: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#374151", color: "#374151",
lineHeight: 1.4, lineHeight: 1.4,
}, },
businessContact: { businessContact: {
fontSize: 9, fontSize: 9,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
lineHeight: 1.4, lineHeight: 1.4,
}, },
@@ -339,7 +319,7 @@ const styles = StyleSheet.create({
abridgedBusinessName: { abridgedBusinessName: {
fontSize: 12, fontSize: 12,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
}, },
@@ -351,19 +331,18 @@ const styles = StyleSheet.create({
abridgedInvoiceTitle: { abridgedInvoiceTitle: {
fontSize: 14, fontSize: 14,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
}, },
abridgedInvoiceNumber: { abridgedInvoiceNumber: {
fontSize: 12, fontSize: 12,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#374151", color: "#374151",
}, },
// Table styles // Table styles
tableContainer: { tableContainer: {
flex: 1,
marginBottom: 20, marginBottom: 20,
}, },
@@ -377,7 +356,7 @@ const styles = StyleSheet.create({
tableHeaderCell: { tableHeaderCell: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#374151", color: "#374151",
paddingHorizontal: 4, paddingHorizontal: 4,
}, },
@@ -422,7 +401,7 @@ const styles = StyleSheet.create({
color: "#0f0f0f", color: "#0f0f0f",
paddingHorizontal: 4, paddingHorizontal: 4,
paddingVertical: 2, paddingVertical: 2,
fontFamily: "Frutiger", fontFamily: "Helvetica",
}, },
tableCellDate: { tableCellDate: {
@@ -438,7 +417,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 2, paddingHorizontal: 2,
textAlign: "left", textAlign: "left",
flexWrap: "wrap", flexWrap: "wrap",
fontFamily: "Frutiger", fontFamily: "Helvetica",
}, },
tableCellHours: { tableCellHours: {
@@ -496,7 +475,7 @@ const styles = StyleSheet.create({
totalLabel: { totalLabel: {
fontSize: 11, fontSize: 11,
color: "#6b7280", color: "#6b7280",
fontFamily: "Frutiger", fontFamily: "Helvetica",
}, },
totalAmount: { totalAmount: {
@@ -514,7 +493,7 @@ const styles = StyleSheet.create({
finalTotalLabel: { finalTotalLabel: {
fontSize: 12, fontSize: 12,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
}, },
@@ -526,7 +505,7 @@ const styles = StyleSheet.create({
itemCount: { itemCount: {
fontSize: 9, fontSize: 9,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#9ca3af", color: "#9ca3af",
textAlign: "center", textAlign: "center",
marginTop: 6, marginTop: 6,
@@ -553,7 +532,7 @@ const styles = StyleSheet.create({
pageNumber: { pageNumber: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
}, },
}); });
@@ -607,83 +586,15 @@ const getStatusStyle = (status: string) => {
} }
}; };
function pageContentBudget(isFirstPage: boolean, hasNotes: boolean): number {
// 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
let h = 672;
h -= isFirstPage ? 285 : 50; // dense vs abridged header
h -= hasNotes ? 185 : 130; // totals box (+ notes section if present)
h -= 28; // table header row
return h;
}
function estimateRowHeight(
item: NonNullable<NonNullable<InvoiceData["items"]>[0]>,
showRate: boolean,
): number {
// 532pt usable width (612 - 80pt horizontal padding); description takes 40% or 48%
const descColWidth = 532 * (showRate ? 0.4 : 0.48);
// Frutiger at 10pt: 0.45em gives ~47 chars/line, matching real wrap behaviour
const charsPerLine = Math.max(1, Math.floor(descColWidth / (10 * 0.45)));
const lines = Math.ceil((item.description.length || 1) / charsPerLine);
// row paddingVertical:6 (×2=12) + cell paddingVertical:4 (×2=8) = 20pt overhead,
// but react-pdf measures the line box at slightly under full lineHeight, so 16pt in practice
return lines * 10 * 1.4 + 16;
}
function paginateItems(
items: NonNullable<InvoiceData["items"]>,
hasNotes = false,
showRate = true,
) {
const validItems = items.filter(Boolean) as NonNullable<typeof items[0]>[];
if (validItems.length === 0) return [[]];
const rowHeights = validItems.map((item) => estimateRowHeight(item, showRate));
function pack(startIdx: number, budget: number): number {
let used = 0, count = 0;
for (let i = startIdx; i < validItems.length; i++) {
if (used + rowHeights[i]! > budget) break;
used += rowHeights[i]!;
count++;
}
return Math.max(1, count);
}
const pages: (typeof validItems)[] = [];
let idx = 0;
while (idx < validItems.length) {
const isFirst = pages.length === 0;
const countFull = pack(idx, pageContentBudget(isFirst, false));
if (idx + countFull >= validItems.length) {
// All remaining items fit — if there are notes, verify they also fit with the notes reservation
if (hasNotes) {
const countWithNotes = pack(idx, pageContentBudget(isFirst, true));
if (idx + countWithNotes >= validItems.length) {
pages.push(validItems.slice(idx));
break;
}
// Notes don't fit alongside all items — push what fits, notes go on next page
pages.push(validItems.slice(idx, idx + countWithNotes));
idx += countWithNotes;
} else {
pages.push(validItems.slice(idx));
break;
}
} else {
pages.push(validItems.slice(idx, idx + countFull));
idx += countFull;
}
}
return pages;
}
function getColumnWidths(showRate: boolean) { function getColumnWidths(showRate: boolean) {
return showRate return showRate
? { date: "15%", description: "40%", hours: "12%", rate: "15%", amount: "18%" } ? {
date: "15%",
description: "40%",
hours: "12%",
rate: "15%",
amount: "18%",
}
: { date: "15%", description: "48%", hours: "14%", amount: "23%" }; : { date: "15%", description: "48%", hours: "14%", amount: "23%" };
} }
@@ -795,27 +706,6 @@ const DenseHeader: React.FC<{
</View> </View>
); );
// Abridged header component (other pages)
const AbridgedHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => (
<View style={styles.abridgedHeader}>
<Text
style={[styles.abridgedBusinessName, { color: settings.pdfAccentColor }]}
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
<View style={styles.abridgedInvoiceInfo}>
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
<Text style={styles.abridgedInvoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
</View>
</View>
);
// Table header component // Table header component
const TableHeader: React.FC<{ const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>; settings: Required<PDFGenerationSettings>;
@@ -826,7 +716,9 @@ const TableHeader: React.FC<{
<View <View
style={[ style={[
styles.tableHeader, styles.tableHeader,
settings.pdfTemplate === "minimal" ? { backgroundColor: "#ffffff" } : {}, settings.pdfTemplate === "minimal"
? { backgroundColor: "#ffffff" }
: {},
]} ]}
> >
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text> <Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
@@ -898,7 +790,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
<Text <Text
style={{ style={{
fontSize: 9, fontSize: 9,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0, marginLeft: settings.pdfShowLogo ? 8 : 0,
}} }}
@@ -945,7 +837,7 @@ const TotalsSection: React.FC<{
<Text <Text
style={{ style={{
fontSize: 11, fontSize: 11,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
textAlign: "center", textAlign: "center",
marginBottom: 8, marginBottom: 8,
@@ -992,7 +884,7 @@ const TotalsSection: React.FC<{
}; };
// Main PDF component // Main PDF component
const InvoicePDF: React.FC<{ export const InvoicePDF: React.FC<{
invoice: InvoiceData; invoice: InvoiceData;
settings?: PDFGenerationSettings; settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => { }> = ({ invoice, settings: inputSettings }) => {
@@ -1001,33 +893,21 @@ const InvoicePDF: React.FC<{
const currency = invoice.currency ?? "USD"; const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1; const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate); const cols = getColumnWidths(showRate);
const paginatedItems = paginateItems(items, Boolean(invoice.notes), showRate);
return ( return (
<Document> <Document>
{paginatedItems.map((pageItems, pageIndex) => { <Page size="LETTER" style={styles.page}>
const isFirstPage = pageIndex === 0;
const isLastPage = pageIndex === paginatedItems.length - 1;
const hasItems = pageItems.length > 0;
return (
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
{/* Header */}
{isFirstPage ? (
<DenseHeader invoice={invoice} settings={settings} /> <DenseHeader invoice={invoice} settings={settings} />
) : (
<AbridgedHeader invoice={invoice} settings={settings} />
)}
{/* Table */} {items.length > 0 && (
{hasItems && (
<View style={styles.tableContainer}> <View style={styles.tableContainer}>
<TableHeader settings={settings} showRate={showRate} /> <TableHeader settings={settings} showRate={showRate} />
{pageItems.map( {items.map(
(item, index) => (item, index) =>
item && ( item && (
<View <View
key={`${pageIndex}-${index}`} key={`invoice-item-${index}`}
wrap={false}
style={[ style={[
styles.tableRow, styles.tableRow,
settings.pdfTemplate === "classic" && index % 2 === 0 settings.pdfTemplate === "classic" && index % 2 === 0
@@ -1035,7 +915,13 @@ const InvoicePDF: React.FC<{
: {}, : {},
]} ]}
> >
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}> <Text
style={[
styles.tableCell,
styles.tableCellDate,
{ width: cols.date },
]}
>
{formatDate(item.date)} {formatDate(item.date)}
</Text> </Text>
<Text <Text
@@ -1047,7 +933,13 @@ const InvoicePDF: React.FC<{
> >
{item.description} {item.description}
</Text> </Text>
<Text style={[styles.tableCell, styles.tableCellHours, { width: cols.hours }]}> <Text
style={[
styles.tableCell,
styles.tableCellHours,
{ width: cols.hours },
]}
>
{item.hours} {item.hours}
</Text> </Text>
{showRate && ( {showRate && (
@@ -1062,7 +954,11 @@ const InvoicePDF: React.FC<{
</Text> </Text>
)} )}
<Text <Text
style={[styles.tableCell, styles.tableCellAmount, { width: cols.amount }]} style={[
styles.tableCell,
styles.tableCellAmount,
{ width: cols.amount },
]}
> >
{formatCurrency(item.amount, currency)} {formatCurrency(item.amount, currency)}
</Text> </Text>
@@ -1072,23 +968,13 @@ const InvoicePDF: React.FC<{
</View> </View>
)} )}
{/* Bottom section with notes and totals (only on last page) */} <View style={styles.bottomSection} wrap={false}>
{isLastPage && (
<View style={styles.bottomSection}>
{invoice.notes && <NotesSection invoice={invoice} />} {invoice.notes && <NotesSection invoice={invoice} />}
<TotalsSection <TotalsSection invoice={invoice} items={items} settings={settings} />
invoice={invoice}
items={items}
settings={settings}
/>
</View> </View>
)}
{/* Footer */}
<Footer settings={settings} /> <Footer settings={settings} />
</Page> </Page>
);
})}
</Document> </Document>
); );
}; };
+34 -2
View File
@@ -7,6 +7,32 @@ import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
function normalizeEmailNoteHtml(value: string) {
const visibleText = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;|\u00a0/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return visibleText ? value.trim() : "";
}
export const emailRouter = createTRPCRouter({ export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure sendInvoice: protectedProcedure
.input( .input(
@@ -95,6 +121,12 @@ export const emailRouter = createTRPCRouter({
"Your Name"; "Your Name";
const userEmail = const userEmail =
invoice.business?.email ?? ctx.session.user?.email ?? ""; invoice.business?.email ?? ctx.session.user?.email ?? "";
const customMessage =
input.customMessage !== undefined
? normalizeEmailNoteHtml(input.customMessage)
: invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined;
// Generate branded email template // Generate branded email template
const emailTemplate = generateInvoiceEmailTemplate({ const emailTemplate = generateInvoiceEmailTemplate({
@@ -105,7 +137,7 @@ export const emailRouter = createTRPCRouter({
status: invoice.status, status: invoice.status,
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: invoice.notes, currency: invoice.currency,
client: { client: {
name: invoice.client.name, name: invoice.client.name,
email: invoice.client.email, email: invoice.client.email,
@@ -114,7 +146,7 @@ export const emailRouter = createTRPCRouter({
items: invoice.items, items: invoice.items,
}, },
customContent: input.customContent, customContent: input.customContent,
customMessage: input.customMessage, customMessage,
userName, userName,
userEmail, userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
+191 -104
View File
@@ -6,8 +6,16 @@ import {
invoiceItems, invoiceItems,
clients, clients,
businesses, businesses,
platformSettings,
} from "~/server/db/schema"; } from "~/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import type { db } from "~/server/db";
type InvoiceRouterContext = {
db: typeof db;
session: { user: { id: string } };
};
const invoiceItemSchema = z.object({ const invoiceItemSchema = z.object({
date: z.date(), date: z.date(),
@@ -29,6 +37,7 @@ const createInvoiceSchema = z.object({
dueDate: z.date(), dueDate: z.date(),
status: z.enum(["draft", "sent", "paid"]).default("draft"), status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")), notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0), taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"), currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"), items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -43,6 +52,64 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]), status: z.enum(["draft", "sent", "paid"]),
}); });
async function verifyBusinessAccess(
ctx: InvoiceRouterContext,
businessId?: string | null,
) {
if (!businessId) return null;
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
return business;
}
async function verifyClientAccess(ctx: InvoiceRouterContext, clientId: string) {
const client = await ctx.db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this client",
});
}
return client;
}
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({ export const invoicesRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(async ({ ctx }) => {
try { try {
@@ -140,62 +207,33 @@ export const invoicesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const { items, ...invoiceData } = input; const { items, ...invoiceData } = input;
const cleanInvoiceData = {
...invoiceData,
businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === ""
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
emailMessage:
invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
};
// Verify business exists and belongs to user (if provided) // Verify business exists and belongs to user (if provided)
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") { await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, invoiceData.businessId),
});
if (!business) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Business not found",
});
}
if (business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You don't have permission to create invoices for this business",
});
}
}
// Verify client exists and belongs to user // Verify client exists and belongs to user
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
where: eq(clients.id, invoiceData.clientId),
});
if (!client) { const totalAmount = calculateInvoiceTotal(
throw new TRPCError({ items,
code: "BAD_REQUEST", cleanInvoiceData.taxRate,
message: "Client not found",
});
}
if (client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"You don't have permission to create invoices for this client",
});
}
// Calculate subtotal and tax
const subtotal = items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
); );
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
const totalAmount = subtotal + taxAmount;
// Create invoice return await ctx.db.transaction(async (tx) => {
const [invoice] = await ctx.db const [invoice] = await tx
.insert(invoices) .insert(invoices)
.values({ .values({
...invoiceData, ...cleanInvoiceData,
totalAmount, totalAmount,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
@@ -208,17 +246,17 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
// Create invoice items await tx.insert(invoiceItems).values(
const itemsToInsert = items.map((item, idx) => ({ items.map((item, idx) => ({
...item, ...item,
invoiceId: invoice.id, invoiceId: invoice.id,
amount: item.hours * item.rate, amount: item.hours * item.rate,
position: idx, position: idx,
})); })),
);
await ctx.db.insert(invoiceItems).values(itemsToInsert);
return invoice; return invoice;
});
} catch (error) { } catch (error) {
if (error instanceof TRPCError) throw error; if (error instanceof TRPCError) throw error;
throw new TRPCError({ throw new TRPCError({
@@ -238,11 +276,25 @@ export const invoicesRouter = createTRPCRouter({
// Clean up empty strings to null for optional string fields only // Clean up empty strings to null for optional string fields only
const cleanInvoiceData = { const cleanInvoiceData = {
...invoiceData, ...invoiceData,
...(invoiceData.businessId !== undefined
? {
businessId: businessId:
!invoiceData.businessId || invoiceData.businessId.trim() === "" invoiceData.businessId.trim() === ""
? null ? null
: invoiceData.businessId, : invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes, }
: {}),
...(invoiceData.notes !== undefined
? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
: {}),
...(invoiceData.emailMessage !== undefined
? {
emailMessage:
invoiceData.emailMessage === ""
? null
: invoiceData.emailMessage,
}
: {}),
}; };
// Verify invoice exists and belongs to user // Verify invoice exists and belongs to user
@@ -269,53 +321,28 @@ export const invoicesRouter = createTRPCRouter({
cleanInvoiceData.businessId && cleanInvoiceData.businessId &&
cleanInvoiceData.businessId.trim() !== "" cleanInvoiceData.businessId.trim() !== ""
) { ) {
const business = await ctx.db.query.businesses.findFirst({ await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
where: eq(businesses.id, cleanInvoiceData.businessId),
});
if (!business || business.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this business",
});
}
} }
// If client is being updated, verify it belongs to user // If client is being updated, verify it belongs to user
if (cleanInvoiceData.clientId) { if (cleanInvoiceData.clientId) {
const client = await ctx.db.query.clients.findFirst({ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
where: eq(clients.id, cleanInvoiceData.clientId),
});
if (!client || client.createdById !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to use this client",
});
}
} }
await ctx.db.transaction(async (tx) => {
if (items) { if (items) {
// Calculate subtotal and tax const totalAmount = calculateInvoiceTotal(
const subtotal = items.reduce( items,
(sum, item) => sum + item.hours * item.rate, cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
0,
); );
const taxAmount =
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
100;
const totalAmount = subtotal + taxAmount;
// Update invoice const [updatedInvoice] = await tx
const updateData = { .update(invoices)
.set({
...cleanInvoiceData, ...cleanInvoiceData,
totalAmount, totalAmount,
updatedAt: new Date(), updatedAt: new Date(),
}; })
const [updatedInvoice] = await ctx.db
.update(invoices)
.set(updateData)
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
.returning(); .returning();
@@ -326,29 +353,23 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
// Delete existing items and create new ones await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
await ctx.db
.delete(invoiceItems)
.where(eq(invoiceItems.invoiceId, id));
const itemsToInsert = items.map((item, idx) => ({ await tx.insert(invoiceItems).values(
items.map((item, idx) => ({
...item, ...item,
invoiceId: id, invoiceId: id,
amount: item.hours * item.rate, amount: item.hours * item.rate,
position: idx, position: idx,
})); })),
);
await ctx.db.insert(invoiceItems).values(itemsToInsert);
} else { } else {
// Update invoice without items const [updatedInvoice] = await tx
const updateData = { .update(invoices)
.set({
...cleanInvoiceData, ...cleanInvoiceData,
updatedAt: new Date(), updatedAt: new Date(),
}; })
const [updatedInvoice] = await ctx.db
.update(invoices)
.set(updateData)
.where(eq(invoices.id, id)) .where(eq(invoices.id, id))
.returning(); .returning();
@@ -359,6 +380,7 @@ export const invoicesRouter = createTRPCRouter({
}); });
} }
} }
});
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -505,4 +527,69 @@ export const invoicesRouter = createTRPCRouter({
return { success: true, deleted: ownedIds.length }; return { success: true, deleted: ownedIds.length };
}), }),
previewPdf: protectedProcedure
.input(createInvoiceSchema)
.query(async ({ ctx, input }) => {
try {
const businessId =
input.businessId && input.businessId.trim() !== ""
? input.businessId
: null;
const [client, business, settings] = await Promise.all([
verifyClientAccess(ctx, input.clientId),
verifyBusinessAccess(ctx, businessId),
ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
}),
]);
const totalAmount = calculateInvoiceTotal(input.items, input.taxRate);
const pdfBlob = await generateInvoicePDFBlob(
{
invoiceNumber: input.invoiceNumber,
invoicePrefix: input.invoicePrefix,
issueDate: input.issueDate,
dueDate: input.dueDate,
status: input.status,
totalAmount,
taxRate: input.taxRate,
currency: input.currency,
notes: input.notes,
client,
business,
items: input.items.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
},
{
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
},
);
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
return {
contentType: "application/pdf",
base64: buffer.toString("base64"),
};
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF preview",
cause: error,
});
}
}),
}); });
+3
View File
@@ -94,6 +94,7 @@ const InvoiceBackupSchema = z.object({
totalAmount: z.number().default(0), totalAmount: z.number().default(0),
taxRate: z.number().default(0), taxRate: z.number().default(0),
notes: z.string().optional(), notes: z.string().optional(),
emailMessage: z.string().optional(),
items: z.array(InvoiceItemBackupSchema), items: z.array(InvoiceItemBackupSchema),
}); });
@@ -562,6 +563,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoice.totalAmount, totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate, taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined, notes: invoice.notes ?? undefined,
emailMessage: invoice.emailMessage ?? undefined,
items: invoice.items, items: invoice.items,
})), })),
}; };
@@ -641,6 +643,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoiceData.totalAmount, totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate, taxRate: invoiceData.taxRate,
notes: invoiceData.notes, notes: invoiceData.notes,
emailMessage: invoiceData.emailMessage,
createdById: userId, createdById: userId,
}) })
.returning({ id: invoices.id }); .returning({ id: invoices.id });
+3
View File
@@ -237,6 +237,9 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
"pdfTemplate", "pdfTemplate",
); );
} }
if (tag === "0007_invoice_email_message") {
return columnExists(client, "public", "beenvoice_invoice", "emailMessage");
}
// Unknown migration — assume not applied so it runs // Unknown migration — assume not applied so it runs
return false; return false;
} }
+1
View File
@@ -320,6 +320,7 @@ export const invoices = createTable(
totalAmount: d.real().notNull().default(0), totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0), taxRate: d.real().notNull().default(0.0),
notes: d.varchar({ length: 1000 }), notes: d.varchar({ length: 1000 }),
emailMessage: d.varchar({ length: 2000 }),
currency: d.varchar({ length: 3 }).default("USD").notNull(), currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
+1
View File
@@ -12,6 +12,7 @@ export interface Invoice {
totalAmount: number; totalAmount: number;
taxRate: number; taxRate: number;
notes: string | null; notes: string | null;
emailMessage: string | null;
createdById: string; createdById: string;
createdAt: Date; createdAt: Date;
updatedAt: Date | null; updatedAt: Date | null;