mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd3181fb9d | |||
| 915ec103fc | |||
| 4108019eab | |||
| 84a5d997b4 |
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "beenvoice_invoice"
|
||||||
|
ADD COLUMN "emailMessage" varchar(2000);
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1777338000000,
|
"when": 1777338000000,
|
||||||
"tag": "0006_pdf_generation_settings",
|
"tag": "0006_pdf_generation_settings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777339000000,
|
||||||
|
"tag": "0007_invoice_email_message",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { Send, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface SendInvoiceButtonProps {
|
|
||||||
invoiceId: string;
|
|
||||||
variant?: "default" | "outline" | "ghost" | "icon";
|
|
||||||
className?: string;
|
|
||||||
showResend?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SendInvoiceButton({
|
|
||||||
invoiceId,
|
|
||||||
variant = "outline",
|
|
||||||
className,
|
|
||||||
showResend = false,
|
|
||||||
}: SendInvoiceButtonProps) {
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
|
|
||||||
// Get utils for cache invalidation
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
// Use the new email API mutation
|
|
||||||
const sendInvoiceMutation = api.email.sendInvoice.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// Show detailed success message with delivery info
|
|
||||||
toast.success(data.message, {
|
|
||||||
description: `Email ID: ${data.emailId}`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh invoice data to show updated status
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
// Enhanced error handling with specific error types
|
|
||||||
console.error("Email send error:", error);
|
|
||||||
|
|
||||||
let errorMessage = "Failed to send invoice email";
|
|
||||||
let errorDescription = "";
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid recipient")) {
|
|
||||||
errorMessage = "Invalid Email Address";
|
|
||||||
errorDescription =
|
|
||||||
"Please check the client's email address and try again.";
|
|
||||||
} else if (error.message.includes("domain not verified")) {
|
|
||||||
errorMessage = "Email Configuration Issue";
|
|
||||||
errorDescription = "Please contact support to configure email sending.";
|
|
||||||
} else if (error.message.includes("rate limit")) {
|
|
||||||
errorMessage = "Too Many Emails";
|
|
||||||
errorDescription = "Please wait a moment before sending another email.";
|
|
||||||
} else if (error.message.includes("no email address")) {
|
|
||||||
errorMessage = "No Email Address";
|
|
||||||
errorDescription = "This client doesn't have an email address on file.";
|
|
||||||
} else {
|
|
||||||
errorDescription = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(errorMessage, {
|
|
||||||
description: errorDescription,
|
|
||||||
duration: 6000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSendInvoice = async () => {
|
|
||||||
if (isSending) return;
|
|
||||||
|
|
||||||
setIsSending(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendInvoiceMutation.mutateAsync({
|
|
||||||
invoiceId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Error is already handled by the mutation's onError
|
|
||||||
console.error("Send invoice error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === "icon") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvoice}
|
|
||||||
disabled={isSending}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={handleSendInvoice}
|
|
||||||
disabled={isSending}
|
|
||||||
variant={variant}
|
|
||||||
size="default"
|
|
||||||
className={`w-full shadow-sm ${className}`}
|
|
||||||
data-testid="send-invoice-button"
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<span>Sending Email...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { InvoiceView } from "~/components/data/invoice-view";
|
|
||||||
import InvoiceForm from "~/components/forms/invoice-form";
|
|
||||||
|
|
||||||
interface UnifiedInvoicePageProps {
|
|
||||||
invoiceId: string;
|
|
||||||
mode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnifiedInvoicePage({
|
|
||||||
invoiceId,
|
|
||||||
mode,
|
|
||||||
}: UnifiedInvoicePageProps) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Always render InvoiceForm to preserve state, but hide when in view mode */}
|
|
||||||
<div className={mode === "edit" ? "block" : "hidden"}>
|
|
||||||
<InvoiceForm invoiceId={invoiceId} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show InvoiceView only when in view mode */}
|
|
||||||
{mode === "view" && <InvoiceView invoiceId={invoiceId} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -99,10 +99,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
}).format(new Date(date));
|
}).format(new Date(date));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number, currency = invoice.currency) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,32 @@ function SendEmailPageSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.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();
|
||||||
@@ -155,7 +181,10 @@ export default function SendEmailPage() {
|
|||||||
issueDate: invoiceData.issueDate,
|
issueDate: invoiceData.issueDate,
|
||||||
dueDate: invoiceData.dueDate,
|
dueDate: invoiceData.dueDate,
|
||||||
status: invoiceData.status,
|
status: invoiceData.status,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
taxRate: invoiceData.taxRate,
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency,
|
||||||
|
emailMessage: invoiceData.emailMessage,
|
||||||
client: invoiceData.client
|
client: invoiceData.client
|
||||||
? {
|
? {
|
||||||
name: invoiceData.client.name,
|
name: invoiceData.client.name,
|
||||||
@@ -171,13 +200,21 @@ export default function SendEmailPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
items: invoiceData.items?.map((item) => ({
|
items: invoiceData.items?.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
|
amount: item.amount,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: 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;
|
||||||
@@ -191,6 +228,9 @@ export default function SendEmailPage() {
|
|||||||
const defaultContent = ``;
|
const defaultContent = ``;
|
||||||
|
|
||||||
setEmailContent(defaultContent);
|
setEmailContent(defaultContent);
|
||||||
|
setCustomMessage(
|
||||||
|
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
|
||||||
|
);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}, [invoice, isInitialized]);
|
}, [invoice, isInitialized]);
|
||||||
|
|
||||||
@@ -222,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,
|
||||||
@@ -366,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"
|
||||||
/>
|
/>
|
||||||
@@ -552,10 +592,9 @@ export default function SendEmailPage() {
|
|||||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
<DialogContent>
|
<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 && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -568,14 +607,30 @@ export default function SendEmailPage() {
|
|||||||
and BCC to <strong>{bccEmail}</strong>
|
and BCC to <strong>{bccEmail}</strong>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
.
|
?
|
||||||
|
</DialogDescription>
|
||||||
{retryCount > 0 && (
|
{retryCount > 0 && (
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Retry attempt {retryCount} of 2
|
Retry attempt {retryCount} of 2
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Subject: </span>
|
||||||
|
<span className="font-medium">{subject}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Attachment: </span>
|
||||||
|
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||||
|
</div>
|
||||||
|
{normalizedCustomMessage && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Email note: </span>
|
||||||
|
<span>Included</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</div>
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -584,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>
|
||||||
|
|||||||
+501
-113
@@ -6,7 +6,13 @@ import { PageHeader } from "~/components/layout/page-header";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { StatusBadge } from "~/components/data/status-badge";
|
import { StatusBadge } from "~/components/data/status-badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { formatCurrency } from "~/lib/currency";
|
import { formatCurrency } from "~/lib/currency";
|
||||||
@@ -23,7 +29,15 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Download,
|
||||||
|
Receipt,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
function toNumericChartValue(value: unknown) {
|
function toNumericChartValue(value: unknown) {
|
||||||
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
||||||
@@ -31,20 +45,22 @@ function toNumericChartValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
|
const { data: invoices = [], isLoading: invoicesLoading } =
|
||||||
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
|
api.invoices.getAll.useQuery();
|
||||||
|
const { data: expenses = [], isLoading: expensesLoading } =
|
||||||
|
api.expenses.getAll.useQuery();
|
||||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||||
|
|
||||||
const isLoading = invoicesLoading || expensesLoading;
|
const isLoading = invoicesLoading || expensesLoading;
|
||||||
|
|
||||||
const now = new Date();
|
const currentYear = new Date().getFullYear();
|
||||||
const currentYear = now.getFullYear();
|
|
||||||
const [taxYear, setTaxYear] = useState(String(currentYear));
|
const [taxYear, setTaxYear] = useState(String(currentYear));
|
||||||
|
|
||||||
// Overview data (last 12 months)
|
// Overview data (last 12 months)
|
||||||
const overviewData = useMemo(() => {
|
const overviewData = useMemo(() => {
|
||||||
if (!invoices.length) return null;
|
if (!invoices.length) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const monthMap: Record<string, number> = {};
|
const monthMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
@@ -57,7 +73,10 @@ export default function ReportsPage() {
|
|||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
|
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
if (status === "paid") {
|
if (status === "paid") {
|
||||||
totalRevenue += inv.totalAmount;
|
totalRevenue += inv.totalAmount;
|
||||||
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||||
@@ -69,28 +88,54 @@ export default function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||||
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
month: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
revenue,
|
revenue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
const clientMap: Record<string, { name: string; revenue: number }> = {};
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
if (status === "paid" && inv.client) {
|
if (status === "paid" && inv.client) {
|
||||||
const id = inv.client.id;
|
const id = inv.client.id;
|
||||||
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0 };
|
const entry = (clientMap[id] ??= {
|
||||||
clientMap[id]!.revenue += inv.totalAmount;
|
name: inv.client.name,
|
||||||
|
revenue: 0,
|
||||||
|
});
|
||||||
|
entry.revenue += inv.totalAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const topClients = Object.values(clientMap).sort((a, b) => b.revenue - a.revenue).slice(0, 6);
|
const topClients = Object.values(clientMap)
|
||||||
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
const statusCount: Record<string, number> = {
|
||||||
|
draft: 0,
|
||||||
|
sent: 0,
|
||||||
|
paid: 0,
|
||||||
|
overdue: 0,
|
||||||
|
};
|
||||||
for (const inv of invoices) {
|
for (const inv of invoices) {
|
||||||
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const s = getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, statusCount };
|
return {
|
||||||
|
revenueByMonth,
|
||||||
|
topClients,
|
||||||
|
totalRevenue,
|
||||||
|
totalPending,
|
||||||
|
totalHours,
|
||||||
|
statusCount,
|
||||||
|
};
|
||||||
}, [invoices]);
|
}, [invoices]);
|
||||||
|
|
||||||
// Tax summary for selected year
|
// Tax summary for selected year
|
||||||
@@ -98,16 +143,45 @@ export default function ReportsPage() {
|
|||||||
const year = parseInt(taxYear);
|
const year = parseInt(taxYear);
|
||||||
|
|
||||||
const yearInvoices = invoices.filter((inv) => {
|
const yearInvoices = invoices.filter((inv) => {
|
||||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
const status = getEffectiveInvoiceStatus(
|
||||||
return status === "paid" && new Date(inv.issueDate).getFullYear() === year;
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
status === "paid" && new Date(inv.issueDate).getFullYear() === year
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const yearExpenses = expenses.filter((exp) => new Date(exp.date).getFullYear() === year);
|
const yearExpenses = expenses.filter(
|
||||||
|
(exp) => new Date(exp.date).getFullYear() === year,
|
||||||
|
);
|
||||||
|
|
||||||
const grossIncome = yearInvoices.reduce((s, inv) => s + inv.totalAmount, 0);
|
const getSubtotal = (inv: (typeof yearInvoices)[number]) => {
|
||||||
const taxCollected = yearInvoices.reduce((s, inv) => s + inv.totalAmount * (inv.taxRate ?? 0), 0);
|
const itemSubtotal = (inv.items ?? []).reduce(
|
||||||
|
(s, item) => s + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (itemSubtotal > 0) return itemSubtotal;
|
||||||
|
|
||||||
|
const taxMultiplier = 1 + (inv.taxRate ?? 0) / 100;
|
||||||
|
return taxMultiplier > 0
|
||||||
|
? inv.totalAmount / taxMultiplier
|
||||||
|
: inv.totalAmount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const grossIncome = yearInvoices.reduce(
|
||||||
|
(s, inv) => s + getSubtotal(inv),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const taxCollected = yearInvoices.reduce(
|
||||||
|
(s, inv) => s + (inv.totalAmount - getSubtotal(inv)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
const totalExpenses = yearExpenses.reduce((s, exp) => s + exp.amount, 0);
|
||||||
const deductibleExpenses = yearExpenses
|
const deductibleExpenses = yearExpenses
|
||||||
.filter((exp) => (exp as typeof exp & { taxDeductible?: boolean }).taxDeductible)
|
.filter(
|
||||||
|
(exp) =>
|
||||||
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible,
|
||||||
|
)
|
||||||
.reduce((s, exp) => s + exp.amount, 0);
|
.reduce((s, exp) => s + exp.amount, 0);
|
||||||
|
|
||||||
const netProfit = grossIncome - deductibleExpenses;
|
const netProfit = grossIncome - deductibleExpenses;
|
||||||
@@ -121,23 +195,49 @@ export default function ReportsPage() {
|
|||||||
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
const qMonths = [(q - 1) * 3, (q - 1) * 3 + 1, (q - 1) * 3 + 2];
|
||||||
return {
|
return {
|
||||||
label: `Q${q}`,
|
label: `Q${q}`,
|
||||||
income: yearInvoices.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth())).reduce((s, inv) => s + inv.totalAmount, 0),
|
income: yearInvoices
|
||||||
expenses: yearExpenses.filter((exp) => qMonths.includes(new Date(exp.date).getMonth())).reduce((s, exp) => s + exp.amount, 0),
|
.filter((inv) => qMonths.includes(new Date(inv.issueDate).getMonth()))
|
||||||
|
.reduce((s, inv) => s + getSubtotal(inv), 0),
|
||||||
|
expenses: yearExpenses
|
||||||
|
.filter((exp) => qMonths.includes(new Date(exp.date).getMonth()))
|
||||||
|
.reduce((s, exp) => s + exp.amount, 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { grossIncome, taxCollected, totalInvoiced: grossIncome + taxCollected, totalExpenses, deductibleExpenses, netProfit, selfEmploymentTax, federalEstimate, totalEstimated, quarters, yearInvoices, yearExpenses };
|
return {
|
||||||
|
grossIncome,
|
||||||
|
taxCollected,
|
||||||
|
totalInvoiced: grossIncome + taxCollected,
|
||||||
|
totalExpenses,
|
||||||
|
deductibleExpenses,
|
||||||
|
netProfit,
|
||||||
|
selfEmploymentTax,
|
||||||
|
federalEstimate,
|
||||||
|
totalEstimated,
|
||||||
|
quarters,
|
||||||
|
yearInvoices,
|
||||||
|
yearExpenses,
|
||||||
|
};
|
||||||
}, [invoices, expenses, taxYear]);
|
}, [invoices, expenses, taxYear]);
|
||||||
|
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo(() => {
|
||||||
const years = new Set<number>([currentYear, currentYear - 1]);
|
const years = new Set<number>([currentYear, currentYear - 1]);
|
||||||
for (const inv of invoices) years.add(new Date(inv.issueDate).getFullYear());
|
for (const inv of invoices)
|
||||||
|
years.add(new Date(inv.issueDate).getFullYear());
|
||||||
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
for (const exp of expenses) years.add(new Date(exp.date).getFullYear());
|
||||||
return Array.from(years).sort((a, b) => b - a);
|
return Array.from(years).sort((a, b) => b - a);
|
||||||
}, [invoices, expenses, currentYear]);
|
}, [invoices, expenses, currentYear]);
|
||||||
|
|
||||||
const avgInvoice = invoices.length > 0
|
const avgInvoice =
|
||||||
? (overviewData?.totalRevenue ?? 0) / (invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 1)
|
invoices.length > 0
|
||||||
|
? (overviewData?.totalRevenue ?? 0) /
|
||||||
|
(invoices.filter(
|
||||||
|
(i) =>
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
i.status as StoredInvoiceStatus,
|
||||||
|
i.dueDate,
|
||||||
|
) === "paid",
|
||||||
|
).length || 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
@@ -148,14 +248,30 @@ export default function ReportsPage() {
|
|||||||
"INCOME (Paid Invoices)",
|
"INCOME (Paid Invoices)",
|
||||||
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
"Date,Invoice #,Client,Subtotal,Tax Rate,Tax Amount,Total",
|
||||||
...taxData.yearInvoices.map((inv) => {
|
...taxData.yearInvoices.map((inv) => {
|
||||||
const taxAmt = inv.totalAmount * (inv.taxRate ?? 0);
|
const subtotal = (inv.items ?? []).reduce(
|
||||||
return [new Date(inv.issueDate).toLocaleDateString("en-US"), inv.invoiceNumber, `"${inv.client?.name ?? ""}"`, inv.totalAmount.toFixed(2), `${((inv.taxRate ?? 0) * 100).toFixed(1)}%`, taxAmt.toFixed(2), (inv.totalAmount + taxAmt).toFixed(2)].join(",");
|
(s, item) => s + item.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const fallbackSubtotal =
|
||||||
|
inv.totalAmount / (1 + (inv.taxRate ?? 0) / 100);
|
||||||
|
const invoiceSubtotal = subtotal > 0 ? subtotal : fallbackSubtotal;
|
||||||
|
const taxAmt = inv.totalAmount - invoiceSubtotal;
|
||||||
|
return [
|
||||||
|
new Date(inv.issueDate).toLocaleDateString("en-US"),
|
||||||
|
inv.invoiceNumber,
|
||||||
|
`"${inv.client?.name ?? ""}"`,
|
||||||
|
invoiceSubtotal.toFixed(2),
|
||||||
|
`${(inv.taxRate ?? 0).toFixed(1)}%`,
|
||||||
|
taxAmt.toFixed(2),
|
||||||
|
inv.totalAmount.toFixed(2),
|
||||||
|
].join(",");
|
||||||
}),
|
}),
|
||||||
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
`,,Totals,${taxData.grossIncome.toFixed(2)},,${taxData.taxCollected.toFixed(2)},${taxData.totalInvoiced.toFixed(2)}`,
|
||||||
"",
|
"",
|
||||||
"EXPENSES",
|
"EXPENSES",
|
||||||
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
"Date,Description,Category,Amount,Currency,Billable,Reimbursable,Tax Deductible",
|
||||||
...taxData.yearExpenses.map((exp) => [
|
...taxData.yearExpenses.map((exp) =>
|
||||||
|
[
|
||||||
new Date(exp.date).toLocaleDateString("en-US"),
|
new Date(exp.date).toLocaleDateString("en-US"),
|
||||||
`"${exp.description}"`,
|
`"${exp.description}"`,
|
||||||
`"${exp.category ?? ""}"`,
|
`"${exp.category ?? ""}"`,
|
||||||
@@ -163,8 +279,11 @@ export default function ReportsPage() {
|
|||||||
exp.currency,
|
exp.currency,
|
||||||
exp.billable ? "Yes" : "No",
|
exp.billable ? "Yes" : "No",
|
||||||
exp.reimbursable ? "Yes" : "No",
|
exp.reimbursable ? "Yes" : "No",
|
||||||
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible ? "Yes" : "No",
|
(exp as typeof exp & { taxDeductible?: boolean }).taxDeductible
|
||||||
].join(",")),
|
? "Yes"
|
||||||
|
: "No",
|
||||||
|
].join(","),
|
||||||
|
),
|
||||||
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
`,,Totals,${taxData.totalExpenses.toFixed(2)},,,,"Deductible: ${taxData.deductibleExpenses.toFixed(2)}"`,
|
||||||
"",
|
"",
|
||||||
"TAX SUMMARY",
|
"TAX SUMMARY",
|
||||||
@@ -176,7 +295,9 @@ export default function ReportsPage() {
|
|||||||
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
`Est. Federal Income Tax (22%),${taxData.federalEstimate.toFixed(2)}`,
|
||||||
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
`Total Estimated Tax,${taxData.totalEstimated.toFixed(2)}`,
|
||||||
];
|
];
|
||||||
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([rows.join("\n")], {
|
||||||
|
type: "text/csv;charset=utf-8;",
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -188,9 +309,15 @@ export default function ReportsPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6">
|
<div className="page-enter space-y-6">
|
||||||
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Revenue and tax analytics"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -198,12 +325,20 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-enter space-y-6 pb-6">
|
<div className="page-enter space-y-6 pb-6">
|
||||||
<PageHeader title="Reports" description="Revenue and tax analytics" variant="gradient" />
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
description="Revenue and tax analytics"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<Tabs defaultValue="overview">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="overview"><TrendingUp className="mr-1.5 h-4 w-4" /> Overview</TabsTrigger>
|
<TabsTrigger value="overview">
|
||||||
<TabsTrigger value="tax"><FileText className="mr-1.5 h-4 w-4" /> Tax Summary</TabsTrigger>
|
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tax">
|
||||||
|
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ── OVERVIEW TAB ── */}
|
{/* ── OVERVIEW TAB ── */}
|
||||||
@@ -212,60 +347,139 @@ export default function ReportsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 rounded p-1.5"><DollarSign className="text-primary h-4 w-4" /></div>
|
<div className="bg-primary/10 rounded p-1.5">
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
<DollarSign className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalRevenue ?? 0)}</p>
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Total Revenue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(overviewData?.totalRevenue ?? 0)}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-yellow-500/10 rounded p-1.5"><Clock className="h-4 w-4 text-yellow-500" /></div>
|
<div className="rounded bg-yellow-500/10 p-1.5">
|
||||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
<Clock className="h-4 w-4 text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(overviewData?.totalPending ?? 0)}</p>
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Pending
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(overviewData?.totalPending ?? 0)}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-blue-500/10 rounded p-1.5"><TrendingUp className="h-4 w-4 text-blue-500" /></div>
|
<div className="rounded bg-blue-500/10 p-1.5">
|
||||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Avg Invoice
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-green-500/10 rounded p-1.5"><Users className="h-4 w-4 text-green-500" /></div>
|
<div className="rounded bg-green-500/10 p-1.5">
|
||||||
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
<Users className="h-4 w-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-2xl font-bold">{(overviewData?.totalHours ?? 0).toFixed(1)}h</p>
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Total Hours
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold">
|
||||||
|
{(overviewData?.totalHours ?? 0).toFixed(1)}h
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-48 w-full md:h-64">
|
<div className="h-48 w-full md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
<AreaChart data={overviewData?.revenueByMonth ?? []}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient
|
||||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
id="revenueGrad"
|
||||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(142, 76%, 36%)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(142, 76%, 36%)"
|
||||||
|
stopOpacity={0.02}
|
||||||
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
strokeDasharray="3 3"
|
||||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
className="stroke-border"
|
||||||
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
/>
|
||||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
"Revenue",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(142, 76%, 36%)"
|
||||||
|
fill="url(#revenueGrad)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,19 +489,62 @@ export default function ReportsPage() {
|
|||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><Users className="h-5 w-5" /> Top Clients by Revenue</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" /> Top Clients by Revenue
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!overviewData?.topClients.length ? (
|
{!overviewData?.topClients.length ? (
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No paid invoices yet.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-48 md:h-56">
|
<div className="h-48 md:h-56">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={overviewData.topClients} layout="vertical">
|
<BarChart
|
||||||
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
data={overviewData.topClients}
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
layout="vertical"
|
||||||
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
>
|
||||||
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
"Revenue",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="revenue"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,38 +553,76 @@ export default function ReportsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Invoice Status Breakdown</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>Invoice Status Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{Object.entries(overviewData?.statusCount ?? {}).map(([status, count]) => (
|
{Object.entries(overviewData?.statusCount ?? {}).map(
|
||||||
<div key={status} className="flex items-center justify-between">
|
([status, count]) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
<StatusBadge status={status as never} />
|
<StatusBadge status={status as never} />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||||
<div className="bg-primary h-full rounded-full" style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }} />
|
<div
|
||||||
|
className="bg-primary h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${invoices.length ? (count / invoices.length) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
<span className="text-muted-foreground w-8 text-right text-sm">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
{invoices.length === 0 && <p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>}
|
)}
|
||||||
|
{invoices.length === 0 && (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
No invoices yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Recent Activity</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{stats.recentInvoices.map((inv) => (
|
{stats.recentInvoices.map((inv) => (
|
||||||
<div key={inv.id} className="flex items-center justify-between py-3">
|
<div
|
||||||
|
key={inv.id}
|
||||||
|
className="flex items-center justify-between py-3"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||||
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{new Date(inv.issueDate).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
<StatusBadge
|
||||||
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
status={
|
||||||
|
getEffectiveInvoiceStatus(
|
||||||
|
inv.status as StoredInvoiceStatus,
|
||||||
|
inv.dueDate,
|
||||||
|
) as never
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{formatCurrency(inv.totalAmount)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -343,9 +638,15 @@ export default function ReportsPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium">Tax Year</span>
|
<span className="text-sm font-medium">Tax Year</span>
|
||||||
<Select value={taxYear} onValueChange={setTaxYear}>
|
<Select value={taxYear} onValueChange={setTaxYear}>
|
||||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableYears.map((y) => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
{availableYears.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,17 +658,27 @@ export default function ReportsPage() {
|
|||||||
{/* Income */}
|
{/* Income */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><DollarSign className="h-5 w-5" /> Income</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5" /> Income
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Gross Income (paid invoices)</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatCurrency(taxData.grossIncome)}</span>
|
Gross Income (paid invoices)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.grossIncome)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{taxData.taxCollected > 0 && (
|
{taxData.taxCollected > 0 && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Tax Collected from Clients</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatCurrency(taxData.taxCollected)}</span>
|
Tax Collected from Clients
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.taxCollected)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -381,19 +692,31 @@ export default function ReportsPage() {
|
|||||||
{/* Expenses */}
|
{/* Expenses */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><Receipt className="h-5 w-5" /> Expenses & Deductions</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Receipt className="h-5 w-5" /> Expenses & Deductions
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Total Expenses</span>
|
<span className="text-muted-foreground">Total Expenses</span>
|
||||||
<span className="font-medium">{formatCurrency(taxData.totalExpenses)}</span>
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.totalExpenses)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Tax-Deductible Expenses</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium text-green-600">{formatCurrency(taxData.deductibleExpenses)}</span>
|
Tax-Deductible Expenses
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{formatCurrency(taxData.deductibleExpenses)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{taxData.totalExpenses > 0 && taxData.deductibleExpenses === 0 && (
|
{taxData.totalExpenses > 0 &&
|
||||||
<p className="text-muted-foreground text-xs">Mark expenses as "Tax Deductible" in the Expenses page to include them here.</p>
|
taxData.deductibleExpenses === 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Mark expenses as "Tax Deductible" in the Expenses
|
||||||
|
page to include them here.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -401,54 +724,119 @@ export default function ReportsPage() {
|
|||||||
{/* Estimated tax */}
|
{/* Estimated tax */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><FileText className="h-5 w-5" /> Estimated Tax Liability</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" /> Estimated Tax Liability
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Net Profit (income − deductible expenses)</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatCurrency(taxData.netProfit)}</span>
|
Net Profit (income − deductible expenses)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.netProfit)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Self-Employment Tax (15.3% on 92.35% of net)</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatCurrency(taxData.selfEmploymentTax)}</span>
|
Self-Employment Tax (15.3% on 92.35% of net)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.selfEmploymentTax)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Federal Income Tax (est. 22% bracket)</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="font-medium">{formatCurrency(taxData.federalEstimate)}</span>
|
Federal Income Tax (est. 22% bracket)
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(taxData.federalEstimate)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between text-lg font-bold">
|
<div className="flex justify-between text-lg font-bold">
|
||||||
<span>Total Estimated Tax</span>
|
<span>Total Estimated Tax</span>
|
||||||
<span className="text-destructive">{formatCurrency(taxData.totalEstimated)}</span>
|
<span className="text-destructive">
|
||||||
|
{formatCurrency(taxData.totalEstimated)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs pt-1">
|
<p className="text-muted-foreground pt-1 text-xs">
|
||||||
Assumes US self-employment tax rules and the 22% federal bracket. Consult a tax professional for accurate filing.
|
Assumes US self-employment tax rules and the 22% federal
|
||||||
|
bracket. Consult a tax professional for accurate filing.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quarterly chart */}
|
{/* Quarterly chart */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Quarterly Breakdown</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>Quarterly Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-48 md:h-64">
|
<div className="h-48 md:h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={taxData.quarters}>
|
<BarChart data={taxData.quarters}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
strokeDasharray="3 3"
|
||||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
className="stroke-border"
|
||||||
<Tooltip
|
/>
|
||||||
formatter={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]}
|
<XAxis
|
||||||
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
|
dataKey="label"
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "hsl(var(--muted-foreground))",
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
`$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => [
|
||||||
|
formatCurrency(toNumericChartValue(value)),
|
||||||
|
name === "income" ? "Income" : "Expenses",
|
||||||
|
]}
|
||||||
|
contentStyle={{
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="income"
|
||||||
|
name="income"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="expenses"
|
||||||
|
name="expenses"
|
||||||
|
fill="hsl(0, 84%, 60%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
opacity={0.75}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="expenses" name="expenses" fill="hsl(0, 84%, 60%)" radius={[4, 4, 0, 0]} opacity={0.75} />
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex justify-center gap-6 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-2 flex justify-center gap-6 text-xs">
|
||||||
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" /> Income</span>
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" /> Expenses</span>
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-green-600" />{" "}
|
||||||
|
Income
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-red-500/75" />{" "}
|
||||||
|
Expenses
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,516 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
|
|
||||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
User,
|
|
||||||
DollarSign,
|
|
||||||
Trash2,
|
|
||||||
Download,
|
|
||||||
Send,
|
|
||||||
Clock,
|
|
||||||
MapPin,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
AlertCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
|
||||||
|
|
||||||
interface InvoiceViewProps {
|
|
||||||
invoiceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIconConfig = {
|
|
||||||
draft: FileText,
|
|
||||||
sent: Send,
|
|
||||||
paid: DollarSign,
|
|
||||||
overdue: AlertCircle,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
|
||||||
|
|
||||||
// Fetch invoice data
|
|
||||||
const {
|
|
||||||
data: invoice,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
} = api.invoices.getById.useQuery({ id: invoiceId });
|
|
||||||
|
|
||||||
// Delete mutation
|
|
||||||
const deleteInvoice = api.invoices.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Invoice deleted successfully");
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
router.push("/dashboard/invoices");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update status mutation
|
|
||||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Status updated successfully");
|
|
||||||
void refetch();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to update status");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
deleteInvoice.mutate({ id: invoiceId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
|
|
||||||
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePDFExport = async () => {
|
|
||||||
if (!invoice) return;
|
|
||||||
|
|
||||||
setIsExportingPDF(true);
|
|
||||||
try {
|
|
||||||
await generateInvoicePDF(invoice);
|
|
||||||
toast.success("PDF exported successfully");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("PDF export error:", error);
|
|
||||||
toast.error("Failed to export PDF. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsExportingPDF(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number, currency = "USD") => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return format(new Date(date), "MMM dd, yyyy");
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOverdue =
|
|
||||||
invoice &&
|
|
||||||
new Date(invoice.dueDate) < new Date() &&
|
|
||||||
invoice.status !== "paid";
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invoice) {
|
|
||||||
return (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
|
|
||||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
|
||||||
Invoice not found
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted mb-4">
|
|
||||||
The invoice you're looking for doesn't exist or has been
|
|
||||||
deleted.
|
|
||||||
</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/dashboard/invoices">Back to Invoices</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusIcon =
|
|
||||||
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Alert */}
|
|
||||||
{isOverdue && (
|
|
||||||
<Card className="border-destructive/20 bg-destructive/10">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<span className="font-medium">This invoice is overdue</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="space-y-6 lg:col-span-2">
|
|
||||||
{/* Invoice Header Card */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="min-w-0 flex-1 space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="bg-primary/10 flex-shrink-0 p-2">
|
|
||||||
<FileText className="text-primary h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-foreground truncate text-2xl font-bold">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Professional Invoice
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Issue Date</span>
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{formatDate(invoice.issueDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Due Date</span>
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{formatDate(invoice.dueDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center justify-between gap-3 sm:flex-col sm:items-end sm:text-right">
|
|
||||||
<div>
|
|
||||||
<StatusBadge
|
|
||||||
status={invoice.status as StatusType}
|
|
||||||
className="px-3 py-1 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<StatusIcon className="mr-1 h-3 w-3" />
|
|
||||||
</StatusBadge>
|
|
||||||
<div className="text-primary mt-1 text-2xl font-bold sm:text-3xl">
|
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handlePDFExport}
|
|
||||||
disabled={isExportingPDF}
|
|
||||||
variant="default"
|
|
||||||
className="flex-shrink-0 transform-none"
|
|
||||||
>
|
|
||||||
{isExportingPDF ? (
|
|
||||||
<>
|
|
||||||
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
|
|
||||||
Generating PDF...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download PDF
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Client Information */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary flex items-center gap-2">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
Bill To
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-foreground text-lg font-semibold">
|
|
||||||
{invoice.client?.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
|
||||||
{invoice.client?.email && (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
|
||||||
<Mail className="text-muted-foreground h-4 w-4" />
|
|
||||||
{invoice.client.email}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invoice.client?.phone && (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
|
||||||
<Phone className="text-muted-foreground h-4 w-4" />
|
|
||||||
{invoice.client.phone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(invoice.client?.addressLine1 ??
|
|
||||||
invoice.client?.city ??
|
|
||||||
invoice.client?.state) && (
|
|
||||||
<div className="text-muted-foreground flex items-start gap-2 md:col-span-2">
|
|
||||||
<MapPin className="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
{invoice.client?.addressLine1 && (
|
|
||||||
<div>{invoice.client.addressLine1}</div>
|
|
||||||
)}
|
|
||||||
{invoice.client?.addressLine2 && (
|
|
||||||
<div>{invoice.client.addressLine2}</div>
|
|
||||||
)}
|
|
||||||
{(invoice.client?.city ??
|
|
||||||
invoice.client?.state ??
|
|
||||||
invoice.client?.postalCode) && (
|
|
||||||
<div>
|
|
||||||
{[
|
|
||||||
invoice.client?.city,
|
|
||||||
invoice.client?.state,
|
|
||||||
invoice.client?.postalCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invoice.client?.country && (
|
|
||||||
<div>{invoice.client.country}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invoice Items */}
|
|
||||||
<Card className="bg-secondary border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" />
|
|
||||||
Invoice Items
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{invoice.items?.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.id || index}
|
|
||||||
className="bg-background flex flex-col gap-1 rounded-lg p-4 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-foreground font-medium break-words">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-0.5 text-sm">
|
|
||||||
{formatDate(item.date)} · {item.hours}h @{" "}
|
|
||||||
{formatCurrency(item.rate)}/hr
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-foreground flex-shrink-0 font-medium sm:text-right">
|
|
||||||
{formatCurrency(item.amount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
{invoice.notes && (
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary">Notes</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
|
||||||
{invoice.notes}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Status Actions */}
|
|
||||||
<Card className="bg-secondary border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary">Status Actions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{invoice.status === "draft" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleStatusUpdate("sent")}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Send className="mr-2 h-4 w-4" />
|
|
||||||
Mark as Sent
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.status === "sent" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleStatusUpdate("paid")}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<DollarSign className="mr-2 h-4 w-4" />
|
|
||||||
Mark as Paid
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.status === "overdue" && (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleStatusUpdate("paid")}
|
|
||||||
disabled={updateStatus.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<DollarSign className="mr-2 h-4 w-4" />
|
|
||||||
Mark as Paid
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.status === "paid" && (
|
|
||||||
<div className="py-4 text-center">
|
|
||||||
<DollarSign className="text-primary mx-auto mb-2 h-8 w-8" />
|
|
||||||
<p className="text-primary font-medium">Invoice Paid</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Invoice Summary */}
|
|
||||||
<Card className="bg-card border-border border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-primary">Summary</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Subtotal</span>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(invoice.taxRate ?? 0) > 0 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{formatCurrency(
|
|
||||||
invoice.totalAmount * (invoice.taxRate ?? 0),
|
|
||||||
invoice.currency,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between text-lg font-bold">
|
|
||||||
<span className="text-foreground">Total</span>
|
|
||||||
<span className="text-primary">
|
|
||||||
{formatCurrency(
|
|
||||||
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
|
|
||||||
invoice.currency,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border border-t pt-4 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{invoice.items?.length ?? 0} item
|
|
||||||
{invoice.items?.length !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<Card className="bg-card border-destructive/20 border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete Invoice
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent className="bg-card border-border border">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-foreground text-xl font-bold">
|
|
||||||
Delete Invoice
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
|
||||||
Are you sure you want to delete this invoice? This action cannot
|
|
||||||
be undone and will permanently remove the invoice and all its
|
|
||||||
data.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={confirmDelete}
|
|
||||||
disabled={deleteInvoice.isPending}
|
|
||||||
className="bg-destructive hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -222,11 +222,10 @@ export function EmailComposer({
|
|||||||
{onCustomMessageChange && (
|
{onCustomMessageChange && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">Email Note (Optional)</Label>
|
||||||
Custom Message (Optional)
|
|
||||||
</Label>
|
|
||||||
<p className="text-muted-foreground mb-2 text-xs">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface EmailPreviewProps {
|
|||||||
taxRate: number;
|
taxRate: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
|
currency?: string | null;
|
||||||
client?: {
|
client?: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -27,8 +28,11 @@ interface EmailPreviewProps {
|
|||||||
};
|
};
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
date?: Date;
|
||||||
|
description?: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
|
amount?: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -66,7 +70,7 @@ export function EmailPreview({
|
|||||||
status: invoice.status ?? "draft",
|
status: invoice.status ?? "draft",
|
||||||
totalAmount: invoice.totalAmount ?? calculateTotal(),
|
totalAmount: invoice.totalAmount ?? calculateTotal(),
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
notes: null,
|
currency: invoice.currency,
|
||||||
client: {
|
client: {
|
||||||
name: invoice.client?.name ?? "Client",
|
name: invoice.client?.name ?? "Client",
|
||||||
email: invoice.client?.email ?? null,
|
email: invoice.client?.email ?? null,
|
||||||
@@ -74,11 +78,11 @@ export function EmailPreview({
|
|||||||
business: invoice.business ?? null,
|
business: invoice.business ?? null,
|
||||||
items:
|
items:
|
||||||
invoice.items?.map((item) => ({
|
invoice.items?.map((item) => ({
|
||||||
date: new Date(),
|
date: item.date ?? new Date(),
|
||||||
description: "Service",
|
description: item.description ?? "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.hours * item.rate,
|
amount: item.amount ?? item.hours * item.rate,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
},
|
},
|
||||||
customContent: content,
|
customContent: content,
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ function InvoiceFormSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultHourlyRate(value: unknown) {
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
|
||||||
|
const rate = (value as { defaultHourlyRate?: unknown }).defaultHourlyRate;
|
||||||
|
return typeof rate === "number" ? rate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -90,6 +107,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
dueDate: new Date(),
|
dueDate: new Date(),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
emailMessage: "",
|
||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
@@ -109,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 } =
|
||||||
@@ -140,18 +159,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
// ... (Mapping logic same as before)
|
||||||
const mappedItems: InvoiceItem[] =
|
const mappedItems: InvoiceItem[] =
|
||||||
existingInvoice.items
|
existingInvoice.items?.map((item) => ({
|
||||||
?.map((item) => ({
|
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
date: new Date(item.date),
|
date: new Date(item.date),
|
||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
}))
|
})) || [];
|
||||||
.sort(
|
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
|
||||||
) || [];
|
|
||||||
setFormData({
|
setFormData({
|
||||||
invoiceNumber: existingInvoice.invoiceNumber,
|
invoiceNumber: existingInvoice.invoiceNumber,
|
||||||
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||||
@@ -161,6 +176,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
dueDate: new Date(existingInvoice.dueDate),
|
dueDate: new Date(existingInvoice.dueDate),
|
||||||
status: existingInvoice.status as "draft" | "sent" | "paid",
|
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||||
notes: existingInvoice.notes ?? "",
|
notes: existingInvoice.notes ?? "",
|
||||||
|
emailMessage: existingInvoice.emailMessage ?? "",
|
||||||
taxRate: existingInvoice.taxRate,
|
taxRate: existingInvoice.taxRate,
|
||||||
currency: existingInvoice.currency ?? "USD",
|
currency: existingInvoice.currency ?? "USD",
|
||||||
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
||||||
@@ -201,6 +217,45 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
return { subtotal, taxAmount, total };
|
return { subtotal, taxAmount, total };
|
||||||
}, [formData.items, formData.taxRate]);
|
}, [formData.items, formData.taxRate]);
|
||||||
|
const emailPreviewMessage = React.useMemo(
|
||||||
|
() => 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(
|
const selectedClient = React.useMemo(
|
||||||
() => clients?.find((client) => client.id === formData.clientId),
|
() => clients?.find((client) => client.id === formData.clientId),
|
||||||
[clients, formData.clientId],
|
[clients, formData.clientId],
|
||||||
@@ -339,13 +394,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
dueDate: formData.dueDate,
|
dueDate: formData.dueDate,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
|
emailMessage: formData.emailMessage,
|
||||||
taxRate: formData.taxRate,
|
taxRate: formData.taxRate,
|
||||||
currency: formData.currency,
|
currency: formData.currency,
|
||||||
items: formData.items
|
items: formData.items.map((i) => ({
|
||||||
.sort(
|
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
|
||||||
)
|
|
||||||
.map((i) => ({
|
|
||||||
date: i.date,
|
date: i.date,
|
||||||
description: i.description,
|
description: i.description,
|
||||||
hours: i.hours,
|
hours: i.hours,
|
||||||
@@ -454,18 +506,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const currentBusiness = businesses?.find(
|
const currentBusiness = businesses?.find(
|
||||||
(b) => b.id === formData.businessId,
|
(b) => b.id === formData.businessId,
|
||||||
);
|
);
|
||||||
const clientRate =
|
const clientRate = getDefaultHourlyRate(selectedClient);
|
||||||
selectedClient && "defaultHourlyRate" in selectedClient
|
|
||||||
? selectedClient.defaultHourlyRate
|
|
||||||
: null;
|
|
||||||
const businessRate =
|
const businessRate =
|
||||||
currentBusiness &&
|
getDefaultHourlyRate(currentBusiness);
|
||||||
"defaultHourlyRate" in currentBusiness
|
|
||||||
? currentBusiness.defaultHourlyRate
|
|
||||||
: null;
|
|
||||||
updateField(
|
updateField(
|
||||||
"defaultHourlyRate",
|
"defaultHourlyRate",
|
||||||
(clientRate ?? businessRate ?? 0) as number,
|
clientRate ?? businessRate ?? 0,
|
||||||
);
|
);
|
||||||
// Auto-fill currency from client
|
// Auto-fill currency from client
|
||||||
if (
|
if (
|
||||||
@@ -473,10 +519,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
"currency" in selectedClient &&
|
"currency" in selectedClient &&
|
||||||
selectedClient.currency
|
selectedClient.currency
|
||||||
) {
|
) {
|
||||||
updateField(
|
updateField("currency", selectedClient.currency);
|
||||||
"currency",
|
|
||||||
selectedClient.currency as string,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -630,12 +673,29 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Notes card — spans both columns */}
|
<Card className="h-fit">
|
||||||
<Card className="h-fit lg:col-span-2">
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||||
<span className="flex items-center gap-2">
|
<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>
|
</span>
|
||||||
{noteTemplates && noteTemplates.length > 0 && (
|
{noteTemplates && noteTemplates.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -666,8 +726,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => updateField("notes", e.target.value)}
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
placeholder="Add notes, payment terms, or other information for the client…"
|
placeholder="Add notes, payment terms, or other information for the invoice/PDF..."
|
||||||
className="min-h-[100px]"
|
className="min-h-[140px]"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -751,6 +811,67 @@ 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
|
||||||
|
value={previewTab}
|
||||||
|
onValueChange={setPreviewTab}
|
||||||
|
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">
|
||||||
|
{!formData.clientId ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Select a client to generate the PDF preview.
|
||||||
|
</div>
|
||||||
|
) : formData.items.some(
|
||||||
|
(item) => item.description.trim() === "",
|
||||||
|
) ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Add descriptions for all line items to generate the
|
||||||
|
PDF preview.
|
||||||
|
</div>
|
||||||
|
) : pdfPreviewLoading && !pdfPreview ? (
|
||||||
|
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
|
||||||
|
Generating server PDF preview...
|
||||||
|
</div>
|
||||||
|
) : pdfPreview ? (
|
||||||
|
<iframe
|
||||||
|
title="Server-generated PDF preview"
|
||||||
|
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="email" className="mt-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-2">
|
<CardTitle className="flex gap-2">
|
||||||
@@ -765,6 +886,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
fromEmail={selectedBusiness?.email ?? ""}
|
fromEmail={selectedBusiness?.email ?? ""}
|
||||||
toEmail={selectedClient?.email ?? ""}
|
toEmail={selectedClient?.email ?? ""}
|
||||||
content=""
|
content=""
|
||||||
|
customMessage={emailPreviewMessage}
|
||||||
invoice={{
|
invoice={{
|
||||||
invoiceNumber: formData.invoiceNumber,
|
invoiceNumber: formData.invoiceNumber,
|
||||||
issueDate: formData.issueDate,
|
issueDate: formData.issueDate,
|
||||||
@@ -772,6 +894,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
taxRate: formData.taxRate,
|
taxRate: formData.taxRate,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
totalAmount: totals.total,
|
totalAmount: totals.total,
|
||||||
|
currency: formData.currency,
|
||||||
client: selectedClient
|
client: selectedClient
|
||||||
? {
|
? {
|
||||||
name: selectedClient.name,
|
name: selectedClient.name,
|
||||||
@@ -786,8 +909,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
items: formData.items.map((item) => ({
|
items: formData.items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
|
amount: item.hours * item.rate,
|
||||||
})),
|
})),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -795,6 +921,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
|
|||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: "draft" | "sent" | "paid";
|
status: "draft" | "sent" | "paid";
|
||||||
notes: string;
|
notes: string;
|
||||||
|
emailMessage: string;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
defaultHourlyRate: number | null;
|
defaultHourlyRate: number | null;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface SendEmailDialogProps {
|
|||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency?: string | null;
|
||||||
client?: {
|
client?: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -47,8 +48,11 @@ interface SendEmailDialogProps {
|
|||||||
};
|
};
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
date?: Date;
|
||||||
|
description?: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
|
amount?: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
onEmailSent?: () => void;
|
onEmailSent?: () => void;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface InvoiceEmailTemplateProps {
|
|||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
notes?: string | null;
|
currency?: string | null;
|
||||||
client: {
|
client: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -57,7 +57,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: invoice.currency ?? "USD",
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -459,8 +459,6 @@ export function generateInvoiceEmailTemplate({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="attachment-notice">
|
<div class="attachment-notice">
|
||||||
<div class="attachment-icon"></div>
|
<div class="attachment-icon"></div>
|
||||||
<div class="attachment-text">
|
<div class="attachment-text">
|
||||||
@@ -540,8 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
|
|||||||
}
|
}
|
||||||
Total: ${formatCurrency(total)}
|
Total: ${formatCurrency(total)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ATTACHMENT
|
ATTACHMENT
|
||||||
═══════════════
|
═══════════════
|
||||||
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
||||||
|
|||||||
+65
-179
@@ -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,19 +331,18 @@ 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",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Table styles
|
// Table styles
|
||||||
tableContainer: {
|
tableContainer: {
|
||||||
flex: 1,
|
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -377,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,
|
||||||
},
|
},
|
||||||
@@ -422,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: {
|
||||||
@@ -438,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: {
|
||||||
@@ -496,7 +475,7 @@ const styles = StyleSheet.create({
|
|||||||
totalLabel: {
|
totalLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
fontFamily: "Frutiger",
|
fontFamily: "Helvetica",
|
||||||
},
|
},
|
||||||
|
|
||||||
totalAmount: {
|
totalAmount: {
|
||||||
@@ -514,7 +493,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
finalTotalLabel: {
|
finalTotalLabel: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Frutiger-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -526,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,
|
||||||
@@ -553,7 +532,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
pageNumber: {
|
pageNumber: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Frutiger",
|
fontFamily: "Helvetica",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -607,83 +586,15 @@ const getStatusStyle = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function pageContentBudget(isFirstPage: boolean, hasNotes: boolean): number {
|
|
||||||
// 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
|
|
||||||
let h = 672;
|
|
||||||
h -= isFirstPage ? 285 : 50; // dense vs abridged header
|
|
||||||
h -= hasNotes ? 185 : 130; // totals box (+ notes section if present)
|
|
||||||
h -= 28; // table header row
|
|
||||||
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 = Math.ceil((item.description.length || 1) / charsPerLine);
|
|
||||||
// row paddingVertical:6 (×2=12) + cell paddingVertical:4 (×2=8) = 20pt overhead,
|
|
||||||
// but react-pdf measures the line box at slightly under full lineHeight, so 16pt in practice
|
|
||||||
return lines * 10 * 1.4 + 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
function paginateItems(
|
|
||||||
items: NonNullable<InvoiceData["items"]>,
|
|
||||||
hasNotes = false,
|
|
||||||
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 countFull = pack(idx, pageContentBudget(isFirst, false));
|
|
||||||
|
|
||||||
if (idx + countFull >= validItems.length) {
|
|
||||||
// All remaining items fit — if there are notes, verify they also fit with the notes reservation
|
|
||||||
if (hasNotes) {
|
|
||||||
const countWithNotes = pack(idx, pageContentBudget(isFirst, true));
|
|
||||||
if (idx + countWithNotes >= validItems.length) {
|
|
||||||
pages.push(validItems.slice(idx));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Notes don't fit alongside all items — push what fits, notes go on next page
|
|
||||||
pages.push(validItems.slice(idx, idx + countWithNotes));
|
|
||||||
idx += countWithNotes;
|
|
||||||
} else {
|
|
||||||
pages.push(validItems.slice(idx));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pages.push(validItems.slice(idx, idx + countFull));
|
|
||||||
idx += countFull;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getColumnWidths(showRate: boolean) {
|
function getColumnWidths(showRate: boolean) {
|
||||||
return showRate
|
return showRate
|
||||||
? { date: "15%", description: "40%", hours: "12%", rate: "15%", amount: "18%" }
|
? {
|
||||||
|
date: "15%",
|
||||||
|
description: "40%",
|
||||||
|
hours: "12%",
|
||||||
|
rate: "15%",
|
||||||
|
amount: "18%",
|
||||||
|
}
|
||||||
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
|
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,27 +706,6 @@ const DenseHeader: React.FC<{
|
|||||||
</View>
|
</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
|
// Table header component
|
||||||
const TableHeader: React.FC<{
|
const TableHeader: React.FC<{
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
@@ -826,7 +716,9 @@ const TableHeader: React.FC<{
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.tableHeader,
|
styles.tableHeader,
|
||||||
settings.pdfTemplate === "minimal" ? { backgroundColor: "#ffffff" } : {},
|
settings.pdfTemplate === "minimal"
|
||||||
|
? { backgroundColor: "#ffffff" }
|
||||||
|
: {},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
|
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
|
||||||
@@ -898,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,
|
||||||
}}
|
}}
|
||||||
@@ -945,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,
|
||||||
@@ -992,7 +884,7 @@ const TotalsSection: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Main PDF component
|
// Main PDF component
|
||||||
const InvoicePDF: React.FC<{
|
export const InvoicePDF: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings?: PDFGenerationSettings;
|
settings?: PDFGenerationSettings;
|
||||||
}> = ({ invoice, settings: inputSettings }) => {
|
}> = ({ invoice, settings: inputSettings }) => {
|
||||||
@@ -1001,33 +893,21 @@ const InvoicePDF: React.FC<{
|
|||||||
const currency = invoice.currency ?? "USD";
|
const currency = invoice.currency ?? "USD";
|
||||||
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
|
||||||
const cols = getColumnWidths(showRate);
|
const cols = getColumnWidths(showRate);
|
||||||
const paginatedItems = paginateItems(items, Boolean(invoice.notes), showRate);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
{paginatedItems.map((pageItems, pageIndex) => {
|
<Page size="LETTER" style={styles.page}>
|
||||||
const isFirstPage = pageIndex === 0;
|
|
||||||
const isLastPage = pageIndex === paginatedItems.length - 1;
|
|
||||||
const hasItems = pageItems.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
|
|
||||||
{/* Header */}
|
|
||||||
{isFirstPage ? (
|
|
||||||
<DenseHeader invoice={invoice} settings={settings} />
|
<DenseHeader invoice={invoice} settings={settings} />
|
||||||
) : (
|
|
||||||
<AbridgedHeader invoice={invoice} settings={settings} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
{items.length > 0 && (
|
||||||
{hasItems && (
|
|
||||||
<View style={styles.tableContainer}>
|
<View style={styles.tableContainer}>
|
||||||
<TableHeader settings={settings} showRate={showRate} />
|
<TableHeader settings={settings} showRate={showRate} />
|
||||||
{pageItems.map(
|
{items.map(
|
||||||
(item, index) =>
|
(item, index) =>
|
||||||
item && (
|
item && (
|
||||||
<View
|
<View
|
||||||
key={`${pageIndex}-${index}`}
|
key={`invoice-item-${index}`}
|
||||||
|
wrap={false}
|
||||||
style={[
|
style={[
|
||||||
styles.tableRow,
|
styles.tableRow,
|
||||||
settings.pdfTemplate === "classic" && index % 2 === 0
|
settings.pdfTemplate === "classic" && index % 2 === 0
|
||||||
@@ -1035,7 +915,13 @@ const InvoicePDF: React.FC<{
|
|||||||
: {},
|
: {},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellDate,
|
||||||
|
{ width: cols.date },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{formatDate(item.date)}
|
{formatDate(item.date)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -1047,7 +933,13 @@ const InvoicePDF: React.FC<{
|
|||||||
>
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.tableCell, styles.tableCellHours, { width: cols.hours }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellHours,
|
||||||
|
{ width: cols.hours },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{item.hours}
|
{item.hours}
|
||||||
</Text>
|
</Text>
|
||||||
{showRate && (
|
{showRate && (
|
||||||
@@ -1062,7 +954,11 @@ const InvoicePDF: React.FC<{
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
style={[styles.tableCell, styles.tableCellAmount, { width: cols.amount }]}
|
style={[
|
||||||
|
styles.tableCell,
|
||||||
|
styles.tableCellAmount,
|
||||||
|
{ width: cols.amount },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{formatCurrency(item.amount, currency)}
|
{formatCurrency(item.amount, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1072,23 +968,13 @@ const InvoicePDF: React.FC<{
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom section with notes and totals (only on last page) */}
|
<View style={styles.bottomSection} wrap={false}>
|
||||||
{isLastPage && (
|
|
||||||
<View style={styles.bottomSection}>
|
|
||||||
{invoice.notes && <NotesSection invoice={invoice} />}
|
{invoice.notes && <NotesSection invoice={invoice} />}
|
||||||
<TotalsSection
|
<TotalsSection invoice={invoice} items={items} settings={settings} />
|
||||||
invoice={invoice}
|
|
||||||
items={items}
|
|
||||||
settings={settings}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<Footer settings={settings} />
|
<Footer settings={settings} />
|
||||||
</Page>
|
</Page>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import { env } from "~/env";
|
|||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
|
|
||||||
|
function plainTextToHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.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(
|
||||||
@@ -95,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({
|
||||||
@@ -105,7 +137,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
status: invoice.status,
|
status: invoice.status,
|
||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
notes: invoice.notes,
|
currency: invoice.currency,
|
||||||
client: {
|
client: {
|
||||||
name: invoice.client.name,
|
name: invoice.client.name,
|
||||||
email: invoice.client.email,
|
email: invoice.client.email,
|
||||||
@@ -114,7 +146,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
items: invoice.items,
|
items: invoice.items,
|
||||||
},
|
},
|
||||||
customContent: input.customContent,
|
customContent: input.customContent,
|
||||||
customMessage: input.customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||||
|
|||||||
+191
-104
@@ -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(),
|
||||||
@@ -29,6 +37,7 @@ const createInvoiceSchema = z.object({
|
|||||||
dueDate: z.date(),
|
dueDate: z.date(),
|
||||||
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
||||||
notes: z.string().optional().or(z.literal("")),
|
notes: z.string().optional().or(z.literal("")),
|
||||||
|
emailMessage: z.string().optional().or(z.literal("")),
|
||||||
taxRate: z.number().min(0).max(100).default(0),
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
currency: z.string().length(3).default("USD"),
|
currency: z.string().length(3).default("USD"),
|
||||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||||
@@ -43,6 +52,64 @@ 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 = (
|
||||||
|
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||||
|
taxRate: number,
|
||||||
|
) => {
|
||||||
|
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
||||||
|
const taxAmount = (subtotal * taxRate) / 100;
|
||||||
|
return subtotal + taxAmount;
|
||||||
|
};
|
||||||
|
|
||||||
export const invoicesRouter = createTRPCRouter({
|
export const invoicesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
try {
|
try {
|
||||||
@@ -140,62 +207,33 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
const { items, ...invoiceData } = input;
|
const { items, ...invoiceData } = input;
|
||||||
|
const cleanInvoiceData = {
|
||||||
|
...invoiceData,
|
||||||
|
businessId:
|
||||||
|
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
||||||
|
? null
|
||||||
|
: invoiceData.businessId,
|
||||||
|
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
||||||
|
emailMessage:
|
||||||
|
invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
|
||||||
|
};
|
||||||
|
|
||||||
// Verify business exists and belongs to user (if provided)
|
// Verify business exists and belongs to user (if provided)
|
||||||
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") {
|
await verifyBusinessAccess(ctx, cleanInvoiceData.businessId);
|
||||||
const business = await ctx.db.query.businesses.findFirst({
|
|
||||||
where: eq(businesses.id, invoiceData.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, invoiceData.clientId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!client) {
|
const totalAmount = calculateInvoiceTotal(
|
||||||
throw new TRPCError({
|
items,
|
||||||
code: "BAD_REQUEST",
|
cleanInvoiceData.taxRate,
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate subtotal and tax
|
|
||||||
const subtotal = items.reduce(
|
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
const taxAmount = (subtotal * invoiceData.taxRate) / 100;
|
|
||||||
const totalAmount = subtotal + taxAmount;
|
|
||||||
|
|
||||||
// Create invoice
|
return await ctx.db.transaction(async (tx) => {
|
||||||
const [invoice] = await ctx.db
|
const [invoice] = await tx
|
||||||
.insert(invoices)
|
.insert(invoices)
|
||||||
.values({
|
.values({
|
||||||
...invoiceData,
|
...cleanInvoiceData,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
@@ -208,17 +246,17 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create invoice items
|
await tx.insert(invoiceItems).values(
|
||||||
const itemsToInsert = items.map((item, idx) => ({
|
items.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
amount: item.hours * item.rate,
|
amount: item.hours * item.rate,
|
||||||
position: idx,
|
position: idx,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
|
||||||
|
|
||||||
return invoice;
|
return invoice;
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -238,11 +276,25 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
// Clean up empty strings to null for optional string fields only
|
// Clean up empty strings to null for optional string fields only
|
||||||
const cleanInvoiceData = {
|
const cleanInvoiceData = {
|
||||||
...invoiceData,
|
...invoiceData,
|
||||||
|
...(invoiceData.businessId !== undefined
|
||||||
|
? {
|
||||||
businessId:
|
businessId:
|
||||||
!invoiceData.businessId || invoiceData.businessId.trim() === ""
|
invoiceData.businessId.trim() === ""
|
||||||
? null
|
? null
|
||||||
: invoiceData.businessId,
|
: invoiceData.businessId,
|
||||||
notes: invoiceData.notes === "" ? null : invoiceData.notes,
|
}
|
||||||
|
: {}),
|
||||||
|
...(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
|
// Verify invoice exists and belongs to user
|
||||||
@@ -269,53 +321,28 @@ 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) => {
|
||||||
if (items) {
|
if (items) {
|
||||||
// Calculate subtotal and tax
|
const totalAmount = calculateInvoiceTotal(
|
||||||
const subtotal = items.reduce(
|
items,
|
||||||
(sum, item) => sum + item.hours * item.rate,
|
cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
const taxAmount =
|
|
||||||
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
|
|
||||||
100;
|
|
||||||
const totalAmount = subtotal + taxAmount;
|
|
||||||
|
|
||||||
// Update invoice
|
const [updatedInvoice] = await tx
|
||||||
const updateData = {
|
.update(invoices)
|
||||||
|
.set({
|
||||||
...cleanInvoiceData,
|
...cleanInvoiceData,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
})
|
||||||
|
|
||||||
const [updatedInvoice] = await ctx.db
|
|
||||||
.update(invoices)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(invoices.id, id))
|
.where(eq(invoices.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -326,29 +353,23 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete existing items and create new ones
|
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
|
||||||
await ctx.db
|
|
||||||
.delete(invoiceItems)
|
|
||||||
.where(eq(invoiceItems.invoiceId, id));
|
|
||||||
|
|
||||||
const itemsToInsert = items.map((item, idx) => ({
|
await tx.insert(invoiceItems).values(
|
||||||
|
items.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
amount: item.hours * item.rate,
|
amount: item.hours * item.rate,
|
||||||
position: idx,
|
position: idx,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
|
||||||
} else {
|
} else {
|
||||||
// Update invoice without items
|
const [updatedInvoice] = await tx
|
||||||
const updateData = {
|
.update(invoices)
|
||||||
|
.set({
|
||||||
...cleanInvoiceData,
|
...cleanInvoiceData,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
})
|
||||||
|
|
||||||
const [updatedInvoice] = await ctx.db
|
|
||||||
.update(invoices)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(invoices.id, id))
|
.where(eq(invoices.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -359,6 +380,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -505,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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const InvoiceBackupSchema = z.object({
|
|||||||
totalAmount: z.number().default(0),
|
totalAmount: z.number().default(0),
|
||||||
taxRate: z.number().default(0),
|
taxRate: z.number().default(0),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
emailMessage: z.string().optional(),
|
||||||
items: z.array(InvoiceItemBackupSchema),
|
items: z.array(InvoiceItemBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -562,6 +563,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
taxRate: invoice.taxRate,
|
taxRate: invoice.taxRate,
|
||||||
notes: invoice.notes ?? undefined,
|
notes: invoice.notes ?? undefined,
|
||||||
|
emailMessage: invoice.emailMessage ?? undefined,
|
||||||
items: invoice.items,
|
items: invoice.items,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -641,6 +643,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
totalAmount: invoiceData.totalAmount,
|
totalAmount: invoiceData.totalAmount,
|
||||||
taxRate: invoiceData.taxRate,
|
taxRate: invoiceData.taxRate,
|
||||||
notes: invoiceData.notes,
|
notes: invoiceData.notes,
|
||||||
|
emailMessage: invoiceData.emailMessage,
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
})
|
})
|
||||||
.returning({ id: invoices.id });
|
.returning({ id: invoices.id });
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
|
|||||||
"pdfTemplate",
|
"pdfTemplate",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (tag === "0007_invoice_email_message") {
|
||||||
|
return columnExists(client, "public", "beenvoice_invoice", "emailMessage");
|
||||||
|
}
|
||||||
// Unknown migration — assume not applied so it runs
|
// Unknown migration — assume not applied so it runs
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ export const invoices = createTable(
|
|||||||
totalAmount: d.real().notNull().default(0),
|
totalAmount: d.real().notNull().default(0),
|
||||||
taxRate: d.real().notNull().default(0.0),
|
taxRate: d.real().notNull().default(0.0),
|
||||||
notes: d.varchar({ length: 1000 }),
|
notes: d.varchar({ length: 1000 }),
|
||||||
|
emailMessage: d.varchar({ length: 2000 }),
|
||||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Invoice {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
emailMessage: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user