From bd3181fb9da6d44235a67a0a6c506d162d2cadba Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 28 Apr 2026 01:26:47 -0400 Subject: [PATCH] feat: add PDF preview functionality and normalize email message handling --- src/app/dashboard/invoices/[id]/send/page.tsx | 108 +++++----- src/components/forms/email-composer.tsx | 4 +- src/components/forms/invoice-form.tsx | 122 ++++++------ src/lib/pdf-export.tsx | 76 +++----- src/server/api/routers/email.ts | 28 ++- src/server/api/routers/invoices.ts | 184 ++++++++++++------ 6 files changed, 293 insertions(+), 229 deletions(-) diff --git a/src/app/dashboard/invoices/[id]/send/page.tsx b/src/app/dashboard/invoices/[id]/send/page.tsx index e25b09f..33ba231 100644 --- a/src/app/dashboard/invoices/[id]/send/page.tsx +++ b/src/app/dashboard/invoices/[id]/send/page.tsx @@ -64,6 +64,22 @@ function plainTextToHtml(value: string) { .replace(/\n/g, "
"); } +function normalizeEmailNoteHtml(value: string) { + const visibleText = value + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/ |\u00a0/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + + return visibleText ? value.trim() : ""; +} + export default function SendEmailPage() { const params = useParams(); const router = useRouter(); @@ -194,6 +210,11 @@ export default function SendEmailPage() { : undefined; }, [invoiceData]); + const normalizedCustomMessage = useMemo( + () => normalizeEmailNoteHtml(customMessage), + [customMessage], + ); + // Initialize email content when invoice loads useEffect(() => { if (!invoice || isInitialized) return; @@ -241,7 +262,7 @@ export default function SendEmailPage() { invoiceId, customSubject: subject, customContent: emailContent, - customMessage: customMessage?.trim() || undefined, + customMessage: normalizedCustomMessage, useHtml: true, ccEmails: ccEmail.trim() || undefined, bccEmails: bccEmail.trim() || undefined, @@ -385,7 +406,7 @@ export default function SendEmailPage() { ccEmail={ccEmail} bccEmail={bccEmail} content={emailContent} - customMessage={customMessage} + customMessage={normalizedCustomMessage} invoice={invoice} className="min-w-0 border-0" /> @@ -569,12 +590,11 @@ export default function SendEmailPage() { {/* Confirmation Dialog */} - + - Send Invoice Email? + Confirm - This will send invoice #{invoice.invoiceNumber} to{" "} - {invoice.client?.email} + Send this invoice email to {toEmail} {ccEmail && ( <> {" "} @@ -587,60 +607,29 @@ export default function SendEmailPage() { and BCC to {bccEmail} )} - . - {retryCount > 0 && ( -
- Retry attempt {retryCount} of 2 -
- )} + ?
+ {retryCount > 0 && ( +

+ Retry attempt {retryCount} of 2 +

+ )}
-
- - - - - Edit Email Note - - - - - - - - - - - Email Preview - - - - - - +
+
+ Subject: + {subject} +
+
+ Attachment: + invoice-{invoice.invoiceNumber}.pdf +
+ {normalizedCustomMessage && ( +
+ Email note: + Included +
+ )}
diff --git a/src/components/forms/email-composer.tsx b/src/components/forms/email-composer.tsx index 9b48623..919a59f 100644 --- a/src/components/forms/email-composer.tsx +++ b/src/components/forms/email-composer.tsx @@ -96,7 +96,7 @@ export function EmailComposer({ content: customMessage, immediatelyRender: false, onUpdate: ({ editor }) => { - onCustomMessageChange?.(editor.getHTML()); + onCustomMessageChange?.(editor.isEmpty ? "" : editor.getHTML()); }, editorProps: { attributes: { @@ -109,7 +109,7 @@ export function EmailComposer({ // Update editor content when customMessage prop changes useEffect(() => { if (editor && customMessage !== undefined) { - const currentContent = editor.getHTML(); + const currentContent = editor.isEmpty ? "" : editor.getHTML(); if (currentContent !== customMessage) { editor.commands.setContent(customMessage); } diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index 4d47d2c..ce329ee 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { PDFViewer } from "@react-pdf/renderer"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Label } from "~/components/ui/label"; @@ -22,7 +21,6 @@ import { PageHeader } from "~/components/layout/page-header"; import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceCalendarView } from "./invoice-calendar-view"; import { EmailPreview } from "./email-preview"; -import { InvoicePDF } from "~/lib/pdf-export"; import { api } from "~/trpc/react"; import { toast } from "sonner"; import { @@ -129,6 +127,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { const [initialized, setInitialized] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [activeTab, setActiveTab] = useState("details"); + const [previewTab, setPreviewTab] = useState("pdf"); // Queries (Same as before) const { data: clients, isLoading: loadingClients } = @@ -222,6 +221,41 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { () => 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( () => clients?.find((client) => client.id === formData.clientId), [clients, formData.clientId], @@ -777,7 +811,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { value="preview" className="mt-6 focus-visible:outline-none" > - +
- - ({ - date: item.date, - description: item.description, - hours: item.hours, - rate: item.rate, - amount: item.hours * item.rate, - })), - }} + {!formData.clientId ? ( +
+ Select a client to generate the PDF preview. +
+ ) : formData.items.some( + (item) => item.description.trim() === "", + ) ? ( +
+ Add descriptions for all line items to generate the + PDF preview. +
+ ) : pdfPreviewLoading && !pdfPreview ? ( +
+ Generating server PDF preview... +
+ ) : pdfPreview ? ( +