feat: add email message field to invoices and update related components

This commit is contained in:
2026-04-28 01:06:45 -04:00
parent 4108019eab
commit 915ec103fc
16 changed files with 361 additions and 356 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,
"tag": "0006_pdf_generation_settings",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1777339000000,
"tag": "0007_invoice_email_message",
"breakpoints": true
}
]
}
+62 -2
View File
@@ -54,6 +54,16 @@ function SendEmailPageSkeleton() {
);
}
function plainTextToHtml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "<br>");
}
export default function SendEmailPage() {
const params = useParams();
const router = useRouter();
@@ -158,7 +168,7 @@ export default function SendEmailPage() {
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
notes: invoiceData.notes,
emailMessage: invoiceData.emailMessage,
client: invoiceData.client
? {
name: invoiceData.client.name,
@@ -197,6 +207,9 @@ export default function SendEmailPage() {
const defaultContent = ``;
setEmailContent(defaultContent);
setCustomMessage(
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
);
setIsInitialized(true);
}, [invoice, isInitialized]);
@@ -556,7 +569,7 @@ export default function SendEmailPage() {
{/* Confirmation Dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>Send Invoice Email?</DialogTitle>
<DialogDescription>
@@ -582,6 +595,53 @@ export default function SendEmailPage() {
)}
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Edit3 className="h-4 w-4" />
Edit Email Note
</CardTitle>
</CardHeader>
<CardContent>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
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>
<DialogFooter>
<Button
variant="outline"
+7 -8
View File
@@ -133,9 +133,9 @@ export function EmailComposer({
if (!editor) {
return (
<div className="bg-muted flex h-[200px] items-center justify-center border">
<div className="bg-muted flex h-[200px] items-center justify-center border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">Loading editor...</p>
</div>
</div>
@@ -145,7 +145,7 @@ export function EmailComposer({
return (
<div className={className}>
{/* Email Headers */}
<div className="bg-muted/20 space-y-4 border p-4">
<div className="bg-muted/20 space-y-4 border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from-email" className="text-sm font-medium">
@@ -222,16 +222,15 @@ export function EmailComposer({
{onCustomMessageChange && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">
Custom Message (Optional)
</Label>
<Label className="text-sm font-medium">Email Note (Optional)</Label>
<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>
</div>
{/* Editor Toolbar */}
<div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2">
<div className="bg-muted/20 flex flex-wrap items-center gap-1 border p-2">
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
-2
View File
@@ -18,7 +18,6 @@ interface EmailPreviewProps {
status?: string;
totalAmount?: number;
currency?: string | null;
notes?: string | null;
client?: {
name: string;
email: string | null;
@@ -72,7 +71,6 @@ export function EmailPreview({
totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate,
currency: invoice.currency,
notes: invoice.notes,
client: {
name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null,
+175 -52
View File
@@ -3,6 +3,7 @@
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";
@@ -21,6 +22,7 @@ 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 {
@@ -83,6 +85,16 @@ function getDefaultHourlyRate(value: unknown) {
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) {
const router = useRouter();
const utils = api.useUtils();
@@ -97,6 +109,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: new Date(),
status: "draft",
notes: "",
emailMessage: "",
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
@@ -164,6 +177,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "",
emailMessage: existingInvoice.emailMessage ?? "",
taxRate: existingInvoice.taxRate,
currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
@@ -204,6 +218,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const total = subtotal + taxAmount;
return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]);
const emailPreviewMessage = React.useMemo(
() => plainTextToHtml(formData.emailMessage.trim()),
[formData.emailMessage],
);
const selectedClient = React.useMemo(
() => clients?.find((client) => client.id === formData.clientId),
[clients, formData.clientId],
@@ -342,6 +360,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
dueDate: formData.dueDate,
status: formData.status,
notes: formData.notes,
emailMessage: formData.emailMessage,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items.map((i) => ({
@@ -620,12 +639,29 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent>
</Card>
{/* Notes card — spans both columns */}
<Card className="h-fit lg:col-span-2">
<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" /> 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>
{noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu>
@@ -656,8 +692,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Textarea
value={formData.notes}
onChange={(e) => updateField("notes", e.target.value)}
placeholder="Add notes, payment terms, or other information for the client…"
className="min-h-[100px]"
placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
className="min-h-[140px]"
/>
</CardContent>
</Card>
@@ -741,53 +777,140 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
}`}
fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""}
content=""
invoice={{
invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
taxRate: formData.taxRate,
status: formData.status,
totalAmount: totals.total,
currency: formData.currency,
notes: formData.notes,
client: selectedClient
? {
name: selectedClient.name,
email: selectedClient.email,
}
: undefined,
business: selectedBusiness
? {
name: selectedBusiness.name,
email: selectedBusiness.email,
}
: undefined,
items: formData.items.map((item) => ({
id: item.id,
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
}}
/>
</CardContent>
</Card>
<Tabs defaultValue="pdf" 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">
<PDFViewer
showToolbar
style={{ width: "100%", height: "100%", border: 0 }}
>
<InvoicePDF
invoice={{
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
status: formData.status,
totalAmount: totals.total,
taxRate: formData.taxRate,
currency: formData.currency,
notes: formData.notes,
client: selectedClient
? {
name: selectedClient.name,
email: selectedClient.email,
phone: selectedClient.phone,
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>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
}`}
fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""}
content=""
customMessage={emailPreviewMessage}
invoice={{
invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
taxRate: formData.taxRate,
status: formData.status,
totalAmount: totals.total,
currency: formData.currency,
client: selectedClient
? {
name: selectedClient.name,
email: selectedClient.email,
}
: undefined,
business: selectedBusiness
? {
name: selectedBusiness.name,
email: selectedBusiness.email,
}
: undefined,
items: formData.items.map((item) => ({
id: item.id,
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})),
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</div>
+1
View File
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
dueDate: Date;
status: "draft" | "sent" | "paid";
notes: string;
emailMessage: string;
taxRate: number;
currency: string;
defaultHourlyRate: number | null;
@@ -38,7 +38,6 @@ interface SendEmailDialogProps {
status: string;
taxRate: number;
currency?: string | null;
notes?: string | null;
client?: {
name: string;
email: string | null;
-34
View File
@@ -7,7 +7,6 @@ interface InvoiceEmailTemplateProps {
totalAmount: number;
taxRate: number;
currency?: string | null;
notes?: string | null;
client: {
name: string;
email: string | null;
@@ -62,18 +61,6 @@ export function generateInvoiceEmailTemplate({
}).format(amount);
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const formattedNotes = invoice.notes?.trim()
? escapeHtml(invoice.notes).replace(/\n/g, "<br>")
: "";
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
@@ -472,17 +459,6 @@ export function generateInvoiceEmailTemplate({
</div>
</div>
${
formattedNotes
? `<div class="invoice-card">
<div class="invoice-summary">
<div class="invoice-number" style="font-size: 18px;">Notes</div>
</div>
<div class="message" style="margin-bottom: 0;">${formattedNotes}</div>
</div>`
: ""
}
<div class="attachment-notice">
<div class="attachment-icon"></div>
<div class="attachment-text">
@@ -562,16 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
}
Total: ${formatCurrency(total)}
${
invoice.notes?.trim()
? `
NOTES
═══════════════
${invoice.notes.trim()}
`
: ""
}
ATTACHMENT
═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
+73 -255
View File
@@ -363,7 +363,6 @@ const styles = StyleSheet.create({
// Table styles
tableContainer: {
flex: 1,
marginBottom: 20,
},
@@ -607,144 +606,6 @@ const getStatusStyle = (status: string) => {
}
};
const PDF_PAGE_USABLE_HEIGHT = 672;
const TABLE_HEADER_HEIGHT = 28;
const TABLE_BOTTOM_MARGIN = 20;
const FIRST_PAGE_HEADER_RESERVE = 285;
const CONTINUATION_HEADER_RESERVE = 50;
const TOTALS_HEIGHT = 108;
function estimateWrappedLines(text: string, charsPerLine: number): number {
const paragraphs = text.split(/\r?\n/);
return paragraphs.reduce((total, paragraph) => {
const words = paragraph.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return total + 1;
let lines = 1;
let currentLineLength = 0;
for (const word of words) {
if (word.length > charsPerLine) {
const longWordLines = Math.ceil(word.length / charsPerLine);
if (currentLineLength > 0) {
lines += longWordLines;
currentLineLength = word.length % charsPerLine;
} else {
lines += longWordLines - 1;
currentLineLength = word.length % charsPerLine;
}
continue;
}
const nextLength =
currentLineLength === 0
? word.length
: currentLineLength + 1 + word.length;
if (nextLength > charsPerLine) {
lines++;
currentLineLength = word.length;
} else {
currentLineLength = nextLength;
}
}
return total + lines;
}, 0);
}
function estimateBottomSectionHeight(notes?: string | null): number {
if (!notes?.trim()) return 20 + TOTALS_HEIGHT;
const notesWidth = 240;
const charsPerLine = Math.max(1, Math.floor(notesWidth / (10 * 0.45)));
const noteLines = estimateWrappedLines(notes, charsPerLine);
const notesHeight = 24 + 17 + noteLines * 10 * 1.4;
return 20 + Math.max(TOTALS_HEIGHT, notesHeight);
}
function pageContentBudget(
isFirstPage: boolean,
options: { reserveBottom?: boolean; notes?: string | null } = {},
): number {
// 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
let h = PDF_PAGE_USABLE_HEIGHT;
h -= isFirstPage ? FIRST_PAGE_HEADER_RESERVE : CONTINUATION_HEADER_RESERVE;
h -= TABLE_HEADER_HEIGHT;
h -= TABLE_BOTTOM_MARGIN;
if (options.reserveBottom) {
h -= estimateBottomSectionHeight(options.notes);
}
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 = estimateWrappedLines(item.description || " ", charsPerLine);
return Math.max(24, lines * 10 * 1.4 + 20);
}
function paginateItems(
items: NonNullable<InvoiceData["items"]>,
notes?: string | null,
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 finalPageCount = pack(
idx,
pageContentBudget(isFirst, { reserveBottom: true, notes }),
);
if (idx + finalPageCount >= validItems.length) {
pages.push(validItems.slice(idx));
break;
}
let count = pack(idx, pageContentBudget(isFirst));
// If the rows fit only when this is not the final page, leave at least one
// row for the final page so notes/totals are never squeezed below the table.
if (idx + count >= validItems.length) {
count = Math.max(1, validItems.length - idx - 1);
}
pages.push(validItems.slice(idx, idx + count));
idx += count;
}
return pages;
}
function getColumnWidths(showRate: boolean) {
return showRate
? {
@@ -865,27 +726,6 @@ const DenseHeader: React.FC<{
</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
const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>;
@@ -1064,7 +904,7 @@ const TotalsSection: React.FC<{
};
// Main PDF component
const InvoicePDF: React.FC<{
export const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
@@ -1073,110 +913,88 @@ const InvoicePDF: React.FC<{
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate);
const paginatedItems = paginateItems(items, invoice.notes, showRate);
return (
<Document>
{paginatedItems.map((pageItems, pageIndex) => {
const isFirstPage = pageIndex === 0;
const isLastPage = pageIndex === paginatedItems.length - 1;
const hasItems = pageItems.length > 0;
<Page size="LETTER" style={styles.page}>
<DenseHeader invoice={invoice} settings={settings} />
return (
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
{/* Header */}
{isFirstPage ? (
<DenseHeader invoice={invoice} settings={settings} />
) : (
<AbridgedHeader invoice={invoice} settings={settings} />
)}
{/* Table */}
{hasItems && (
<View style={styles.tableContainer}>
<TableHeader settings={settings} showRate={showRate} />
{pageItems.map(
(item, index) =>
item && (
<View
key={`${pageIndex}-${index}`}
{items.length > 0 && (
<View style={styles.tableContainer}>
<TableHeader settings={settings} showRate={showRate} />
{items.map(
(item, index) =>
item && (
<View
key={`invoice-item-${index}`}
wrap={false}
style={[
styles.tableRow,
settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt
: {},
]}
>
<Text
style={[
styles.tableCell,
styles.tableCellDate,
{ width: cols.date },
]}
>
{formatDate(item.date)}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellDescription,
{ width: cols.description },
]}
>
{item.description}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellHours,
{ width: cols.hours },
]}
>
{item.hours}
</Text>
{showRate && (
<Text
style={[
styles.tableRow,
settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt
: {},
styles.tableCell,
styles.tableCellRate,
{ width: cols.rate },
]}
>
<Text
style={[
styles.tableCell,
styles.tableCellDate,
{ width: cols.date },
]}
>
{formatDate(item.date)}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellDescription,
{ width: cols.description },
]}
>
{item.description}
</Text>
<Text
style={[
styles.tableCell,
styles.tableCellHours,
{ width: cols.hours },
]}
>
{item.hours}
</Text>
{showRate && (
<Text
style={[
styles.tableCell,
styles.tableCellRate,
{ width: cols.rate },
]}
>
{formatCurrency(item.rate, currency)}
</Text>
)}
<Text
style={[
styles.tableCell,
styles.tableCellAmount,
{ width: cols.amount },
]}
>
{formatCurrency(item.amount, currency)}
</Text>
</View>
),
)}
</View>
{formatCurrency(item.rate, currency)}
</Text>
)}
<Text
style={[
styles.tableCell,
styles.tableCellAmount,
{ width: cols.amount },
]}
>
{formatCurrency(item.amount, currency)}
</Text>
</View>
),
)}
</View>
)}
{/* Bottom section with notes and totals (only on last page) */}
{isLastPage && (
<View style={styles.bottomSection}>
{invoice.notes && <NotesSection invoice={invoice} />}
<TotalsSection
invoice={invoice}
items={items}
settings={settings}
/>
</View>
)}
<View style={styles.bottomSection} wrap={false}>
{invoice.notes && <NotesSection invoice={invoice} />}
<TotalsSection invoice={invoice} items={items} settings={settings} />
</View>
{/* Footer */}
<Footer settings={settings} />
</Page>
);
})}
<Footer settings={settings} />
</Page>
</Document>
);
};
+15 -2
View File
@@ -7,6 +7,16 @@ import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
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>");
}
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
.input(
@@ -106,7 +116,6 @@ export const emailRouter = createTRPCRouter({
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
currency: invoice.currency,
notes: invoice.notes,
client: {
name: invoice.client.name,
email: invoice.client.email,
@@ -115,7 +124,11 @@ export const emailRouter = createTRPCRouter({
items: invoice.items,
},
customContent: input.customContent,
customMessage: input.customMessage,
customMessage:
input.customMessage ??
(invoice.emailMessage
? plainTextToHtml(invoice.emailMessage)
: undefined),
userName,
userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
+11
View File
@@ -29,6 +29,7 @@ const createInvoiceSchema = z.object({
dueDate: z.date(),
status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")),
emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -156,6 +157,8 @@ export const invoicesRouter = createTRPCRouter({
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
emailMessage:
invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
};
// Verify business exists and belongs to user (if provided)
@@ -263,6 +266,14 @@ export const invoicesRouter = createTRPCRouter({
...(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
+3
View File
@@ -94,6 +94,7 @@ const InvoiceBackupSchema = z.object({
totalAmount: z.number().default(0),
taxRate: z.number().default(0),
notes: z.string().optional(),
emailMessage: z.string().optional(),
items: z.array(InvoiceItemBackupSchema),
});
@@ -562,6 +563,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined,
emailMessage: invoice.emailMessage ?? undefined,
items: invoice.items,
})),
};
@@ -641,6 +643,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
notes: invoiceData.notes,
emailMessage: invoiceData.emailMessage,
createdById: userId,
})
.returning({ id: invoices.id });
+3
View File
@@ -237,6 +237,9 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
"pdfTemplate",
);
}
if (tag === "0007_invoice_email_message") {
return columnExists(client, "public", "beenvoice_invoice", "emailMessage");
}
// Unknown migration — assume not applied so it runs
return false;
}
+1
View File
@@ -320,6 +320,7 @@ export const invoices = createTable(
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
notes: d.varchar({ length: 1000 }),
emailMessage: d.varchar({ length: 2000 }),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d
.varchar({ length: 255 })
+1
View File
@@ -12,6 +12,7 @@ export interface Invoice {
totalAmount: number;
taxRate: number;
notes: string | null;
emailMessage: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;