feat: add PDF preview functionality and normalize email message handling

This commit is contained in:
2026-04-28 01:26:47 -04:00
parent 915ec103fc
commit bd3181fb9d
6 changed files with 293 additions and 229 deletions
+48 -60
View File
@@ -64,6 +64,22 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>"); .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();
@@ -194,6 +210,11 @@ export default function SendEmailPage() {
: 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;
@@ -241,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,
@@ -385,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"
/> />
@@ -569,12 +590,11 @@ export default function SendEmailPage() {
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden"> <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 && (
<> <>
{" "} {" "}
@@ -587,60 +607,29 @@ export default function SendEmailPage() {
and BCC to <strong>{bccEmail}</strong> and BCC to <strong>{bccEmail}</strong>
</> </>
)} )}
. ?
{retryCount > 0 && (
<div className="text-muted-foreground mt-2 text-sm">
Retry attempt {retryCount} of 2
</div>
)}
</DialogDescription> </DialogDescription>
{retryCount > 0 && (
<p className="text-muted-foreground text-sm">
Retry attempt {retryCount} of 2
</p>
)}
</DialogHeader> </DialogHeader>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto lg:grid-cols-2"> <div className="bg-muted/30 space-y-2 border p-3 text-sm">
<Card> <div>
<CardHeader> <span className="text-muted-foreground">Subject: </span>
<CardTitle className="flex items-center gap-2 text-base"> <span className="font-medium">{subject}</span>
<Edit3 className="h-4 w-4" /> </div>
Edit Email Note <div>
</CardTitle> <span className="text-muted-foreground">Attachment: </span>
</CardHeader> <span>invoice-{invoice.invoiceNumber}.pdf</span>
<CardContent> </div>
<EmailComposer {normalizedCustomMessage && (
subject={subject} <div>
onSubjectChange={setSubject} <span className="text-muted-foreground">Email note: </span>
content={emailContent} <span>Included</span>
onContentChange={setEmailContent} </div>
customMessage={customMessage} )}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Eye className="h-4 w-4" />
Email Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="min-w-0 border-0"
/>
</CardContent>
</Card>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -650,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>
+2 -2
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);
} }
+66 -56
View File
@@ -3,7 +3,6 @@
import * as React from "react"; import * as React from "react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PDFViewer } from "@react-pdf/renderer";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@@ -22,7 +21,6 @@ import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view"; import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview"; import { EmailPreview } from "./email-preview";
import { InvoicePDF } from "~/lib/pdf-export";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -129,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 } =
@@ -222,6 +221,41 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
() => plainTextToHtml(formData.emailMessage.trim()), () => plainTextToHtml(formData.emailMessage.trim()),
[formData.emailMessage], [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],
@@ -777,7 +811,11 @@ 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 defaultValue="pdf" className="w-full"> <Tabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1"> <TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger <TabsTrigger
value="pdf" value="pdf"
@@ -802,60 +840,32 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="bg-muted/20 h-[760px] overflow-hidden border-t"> <div className="bg-muted/20 h-[760px] overflow-hidden border-t">
<PDFViewer {!formData.clientId ? (
showToolbar <div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
style={{ width: "100%", height: "100%", border: 0 }} Select a client to generate the PDF preview.
> </div>
<InvoicePDF ) : formData.items.some(
invoice={{ (item) => item.description.trim() === "",
invoiceNumber: formData.invoiceNumber, ) ? (
invoicePrefix: formData.invoicePrefix, <div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
issueDate: formData.issueDate, Add descriptions for all line items to generate the
dueDate: formData.dueDate, PDF preview.
status: formData.status, </div>
totalAmount: totals.total, ) : pdfPreviewLoading && !pdfPreview ? (
taxRate: formData.taxRate, <div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
currency: formData.currency, Generating server PDF preview...
notes: formData.notes, </div>
client: selectedClient ) : pdfPreview ? (
? { <iframe
name: selectedClient.name, title="Server-generated PDF preview"
email: selectedClient.email, src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
phone: selectedClient.phone, className="h-full w-full border-0"
addressLine1: selectedClient.addressLine1,
addressLine2: selectedClient.addressLine2,
city: selectedClient.city,
state: selectedClient.state,
postalCode: selectedClient.postalCode,
country: selectedClient.country,
}
: null,
business: selectedBusiness
? {
name: selectedBusiness.name,
nickname: selectedBusiness.nickname,
email: selectedBusiness.email,
phone: selectedBusiness.phone,
addressLine1: selectedBusiness.addressLine1,
addressLine2: selectedBusiness.addressLine2,
city: selectedBusiness.city,
state: selectedBusiness.state,
postalCode: selectedBusiness.postalCode,
country: selectedBusiness.country,
website: selectedBusiness.website,
taxId: selectedBusiness.taxId,
}
: null,
items: formData.items.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
}}
/> />
</PDFViewer> ) : (
<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> </div>
</CardContent> </CardContent>
</Card> </Card>
+28 -48
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,13 +331,13 @@ 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",
}, },
@@ -376,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,
}, },
@@ -421,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: {
@@ -437,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: {
@@ -495,7 +475,7 @@ const styles = StyleSheet.create({
totalLabel: { totalLabel: {
fontSize: 11, fontSize: 11,
color: "#6b7280", color: "#6b7280",
fontFamily: "Frutiger", fontFamily: "Helvetica",
}, },
totalAmount: { totalAmount: {
@@ -513,7 +493,7 @@ const styles = StyleSheet.create({
finalTotalLabel: { finalTotalLabel: {
fontSize: 12, fontSize: 12,
fontFamily: "Frutiger-Bold", fontFamily: "Helvetica-Bold",
color: "#0f0f0f", color: "#0f0f0f",
}, },
@@ -525,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,
@@ -552,7 +532,7 @@ const styles = StyleSheet.create({
pageNumber: { pageNumber: {
fontSize: 10, fontSize: 10,
fontFamily: "Frutiger", fontFamily: "Helvetica",
color: "#6b7280", color: "#6b7280",
}, },
}); });
@@ -810,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,
}} }}
@@ -857,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,
+23 -5
View File
@@ -17,6 +17,22 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>"); .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(
@@ -105,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({
@@ -124,11 +146,7 @@ export const emailRouter = createTRPCRouter({
items: invoice.items, items: invoice.items,
}, },
customContent: input.customContent, customContent: input.customContent,
customMessage: customMessage,
input.customMessage ??
(invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined),
userName, userName,
userEmail, userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
+126 -58
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(),
@@ -44,6 +52,55 @@ 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 = ( const calculateInvoiceTotal = (
items: Array<z.infer<typeof invoiceItemSchema>>, items: Array<z.infer<typeof invoiceItemSchema>>,
taxRate: number, taxRate: number,
@@ -162,46 +219,10 @@ export const invoicesRouter = createTRPCRouter({
}; };
// Verify business exists and belongs to user (if provided) // Verify business exists and belongs to user (if provided)
if (cleanInvoiceData.businessId) { await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
const business = await ctx.db.query.businesses.findFirst({
where: eq(businesses.id, cleanInvoiceData.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, cleanInvoiceData.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 create invoices for this client",
});
}
const totalAmount = calculateInvoiceTotal( const totalAmount = calculateInvoiceTotal(
items, items,
@@ -300,30 +321,12 @@ 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) => { await ctx.db.transaction(async (tx) => {
@@ -524,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,
});
}
}),
}); });