-
-
-
-
- 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 ? (
+
-
+ ) : (
+
+ PDF preview will appear here.
+
+ )}
diff --git a/src/lib/pdf-export.tsx b/src/lib/pdf-export.tsx
index d5a6620..2f2712a 100644
--- a/src/lib/pdf-export.tsx
+++ b/src/lib/pdf-export.tsx
@@ -5,31 +5,11 @@ import {
View,
Image,
StyleSheet,
- Font,
pdf,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
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
function downloadBlob(blob: Blob, filename: string): void {
try {
@@ -142,7 +122,7 @@ const styles = StyleSheet.create({
page: {
flexDirection: "column",
backgroundColor: "#ffffff",
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
fontSize: 10,
paddingTop: 40,
paddingBottom: 80,
@@ -169,7 +149,7 @@ const styles = StyleSheet.create({
},
businessName: {
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
fontSize: 18,
color: "#0f0f0f",
marginBottom: 4,
@@ -177,7 +157,7 @@ const styles = StyleSheet.create({
businessInfo: {
fontSize: 10,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.4,
marginBottom: 3,
@@ -185,7 +165,7 @@ const styles = StyleSheet.create({
businessAddress: {
fontSize: 10,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.4,
marginTop: 4,
@@ -198,14 +178,14 @@ const styles = StyleSheet.create({
invoiceTitle: {
fontSize: 28,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
marginBottom: 8,
},
invoiceNumber: {
fontSize: 14,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#374151",
marginBottom: 4,
},
@@ -214,7 +194,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 11,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
textAlign: "center",
},
@@ -242,13 +222,13 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 12,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
marginBottom: 12,
},
clientName: {
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
fontSize: 12,
color: "#0f0f0f",
marginBottom: 2,
@@ -256,7 +236,7 @@ const styles = StyleSheet.create({
clientInfo: {
fontSize: 10,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.4,
marginBottom: 2,
@@ -264,7 +244,7 @@ const styles = StyleSheet.create({
clientAddress: {
fontSize: 10,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.4,
marginTop: 4,
@@ -278,14 +258,14 @@ const styles = StyleSheet.create({
detailLabel: {
fontSize: 11,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
flex: 1,
},
detailValue: {
fontSize: 10,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
flex: 1,
textAlign: "right",
@@ -301,21 +281,21 @@ const styles = StyleSheet.create({
notesTitle: {
fontSize: 11,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
marginBottom: 6,
},
notesContent: {
fontSize: 10,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#374151",
lineHeight: 1.4,
},
businessContact: {
fontSize: 9,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
lineHeight: 1.4,
},
@@ -339,7 +319,7 @@ const styles = StyleSheet.create({
abridgedBusinessName: {
fontSize: 12,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
},
@@ -351,13 +331,13 @@ const styles = StyleSheet.create({
abridgedInvoiceTitle: {
fontSize: 14,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
},
abridgedInvoiceNumber: {
fontSize: 12,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#374151",
},
@@ -376,7 +356,7 @@ const styles = StyleSheet.create({
tableHeaderCell: {
fontSize: 10,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#374151",
paddingHorizontal: 4,
},
@@ -421,7 +401,7 @@ const styles = StyleSheet.create({
color: "#0f0f0f",
paddingHorizontal: 4,
paddingVertical: 2,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
},
tableCellDate: {
@@ -437,7 +417,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 2,
textAlign: "left",
flexWrap: "wrap",
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
},
tableCellHours: {
@@ -495,7 +475,7 @@ const styles = StyleSheet.create({
totalLabel: {
fontSize: 11,
color: "#6b7280",
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
},
totalAmount: {
@@ -513,7 +493,7 @@ const styles = StyleSheet.create({
finalTotalLabel: {
fontSize: 12,
- fontFamily: "Frutiger-Bold",
+ fontFamily: "Helvetica-Bold",
color: "#0f0f0f",
},
@@ -525,7 +505,7 @@ const styles = StyleSheet.create({
itemCount: {
fontSize: 9,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#9ca3af",
textAlign: "center",
marginTop: 6,
@@ -552,7 +532,7 @@ const styles = StyleSheet.create({
pageNumber: {
fontSize: 10,
- fontFamily: "Frutiger",
+ fontFamily: "Helvetica",
color: "#6b7280",
},
});
@@ -810,7 +790,7 @@ const Footer: React.FC<{ settings: Required }> = ({
");
}
+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 const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
.input(
@@ -105,6 +121,12 @@ export const emailRouter = createTRPCRouter({
"Your Name";
const userEmail =
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
const emailTemplate = generateInvoiceEmailTemplate({
@@ -124,11 +146,7 @@ export const emailRouter = createTRPCRouter({
items: invoice.items,
},
customContent: input.customContent,
- customMessage:
- input.customMessage ??
- (invoice.emailMessage
- ? plainTextToHtml(invoice.emailMessage)
- : undefined),
+ customMessage,
userName,
userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index 5af94be..5ed6c20 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -6,8 +6,16 @@ import {
invoiceItems,
clients,
businesses,
+ platformSettings,
} from "~/server/db/schema";
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({
date: z.date(),
@@ -44,6 +52,55 @@ const updateStatusSchema = z.object({
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>,
taxRate: number,
@@ -162,46 +219,10 @@ export const invoicesRouter = createTRPCRouter({
};
// Verify business exists and belongs to user (if provided)
- if (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",
- });
- }
- }
+ await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
// Verify client exists and belongs to user
- const client = await ctx.db.query.clients.findFirst({
- 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",
- });
- }
+ await verifyClientAccess(ctx, cleanInvoiceData.clientId);
const totalAmount = calculateInvoiceTotal(
items,
@@ -300,30 +321,12 @@ export const invoicesRouter = createTRPCRouter({
cleanInvoiceData.businessId &&
cleanInvoiceData.businessId.trim() !== ""
) {
- const business = await ctx.db.query.businesses.findFirst({
- 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",
- });
- }
+ await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
}
// If client is being updated, verify it belongs to user
if (cleanInvoiceData.clientId) {
- const client = await ctx.db.query.clients.findFirst({
- 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 verifyClientAccess(ctx, cleanInvoiceData.clientId);
}
await ctx.db.transaction(async (tx) => {
@@ -524,4 +527,69 @@ export const invoicesRouter = createTRPCRouter({
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,
+ });
+ }
+ }),
});