mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
feat: add PDF preview functionality and normalize email message handling
This commit is contained in:
@@ -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(/ |\u00a0/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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(/ |\u00a0/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user