-
Income
-
Expenses
+
+
+ {" "}
+ Income
+
+
+ {" "}
+ Expenses
+
diff --git a/src/components/data/invoice-view.tsx b/src/components/data/invoice-view.tsx
deleted file mode 100644
index 67bf696..0000000
--- a/src/components/data/invoice-view.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
- if (!invoice) {
- return (
-
-
-
- Invoice not found
-
-
- The invoice you're looking for doesn't exist or has been
- deleted.
-
-
-
- );
- }
-
- const StatusIcon =
- statusIconConfig[invoice.status as keyof typeof statusIconConfig];
-
- return (
-
- {/* Status Alert */}
- {isOverdue && (
-
-
-
-
-
This invoice is overdue
-
-
-
- )}
-
-
- {/* Main Content */}
-
- {/* Invoice Header Card */}
-
-
-
-
-
-
-
-
-
-
- {invoice.invoiceNumber}
-
-
- Professional Invoice
-
-
-
-
-
-
-
Issue Date
-
- {formatDate(invoice.issueDate)}
-
-
-
-
Due Date
-
- {formatDate(invoice.dueDate)}
-
-
-
-
-
-
-
-
-
-
-
- {formatCurrency(invoice.totalAmount)}
-
-
-
-
-
-
-
-
- {/* Client Information */}
-
-
-
-
- Bill To
-
-
-
-
-
- {invoice.client?.name}
-
-
-
-
- {invoice.client?.email && (
-
-
- {invoice.client.email}
-
- )}
- {invoice.client?.phone && (
-
-
- {invoice.client.phone}
-
- )}
- {(invoice.client?.addressLine1 ??
- invoice.client?.city ??
- invoice.client?.state) && (
-
-
-
- {invoice.client?.addressLine1 && (
-
{invoice.client.addressLine1}
- )}
- {invoice.client?.addressLine2 && (
-
{invoice.client.addressLine2}
- )}
- {(invoice.client?.city ??
- invoice.client?.state ??
- invoice.client?.postalCode) && (
-
- {[
- invoice.client?.city,
- invoice.client?.state,
- invoice.client?.postalCode,
- ]
- .filter(Boolean)
- .join(", ")}
-
- )}
- {invoice.client?.country && (
-
{invoice.client.country}
- )}
-
-
- )}
-
-
-
-
- {/* Invoice Items */}
-
-
-
-
- Invoice Items
-
-
-
-
- {invoice.items?.map((item, index) => (
-
-
-
- {item.description}
-
-
- {formatDate(item.date)} · {item.hours}h @{" "}
- {formatCurrency(item.rate)}/hr
-
-
-
- {formatCurrency(item.amount)}
-
-
- ))}
-
-
-
-
- {/* Notes */}
- {invoice.notes && (
-
-
- Notes
-
-
-
- {invoice.notes}
-
-
-
- )}
-
-
- {/* Sidebar */}
-
- {/* Status Actions */}
-
-
- Status Actions
-
-
- {invoice.status === "draft" && (
-
- )}
-
- {invoice.status === "sent" && (
-
- )}
-
- {invoice.status === "overdue" && (
-
- )}
-
- {invoice.status === "paid" && (
-
- )}
-
-
-
- {/* Invoice Summary */}
-
-
- Summary
-
-
-
-
- Subtotal
-
- {formatCurrency(invoice.totalAmount, invoice.currency)}
-
-
- {(invoice.taxRate ?? 0) > 0 && (
-
-
- Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
-
-
- {formatCurrency(
- invoice.totalAmount * (invoice.taxRate ?? 0),
- invoice.currency,
- )}
-
-
- )}
-
-
- Total
-
- {formatCurrency(
- invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
- invoice.currency,
- )}
-
-
-
-
-
-
- {invoice.items?.length ?? 0} item
- {invoice.items?.length !== 1 ? "s" : ""}
-
-
-
-
-
- {/* Danger Zone */}
-
-
- Danger Zone
-
-
-
-
-
-
-
-
- {/* Delete Confirmation Dialog */}
-
-
- );
-}
diff --git a/src/components/forms/email-preview.tsx b/src/components/forms/email-preview.tsx
index 9158cd0..790c39a 100644
--- a/src/components/forms/email-preview.tsx
+++ b/src/components/forms/email-preview.tsx
@@ -17,6 +17,8 @@ interface EmailPreviewProps {
taxRate: number;
status?: string;
totalAmount?: number;
+ currency?: string | null;
+ notes?: string | null;
client?: {
name: string;
email: string | null;
@@ -27,8 +29,11 @@ interface EmailPreviewProps {
};
items?: Array<{
id: string;
+ date?: Date;
+ description?: string;
hours: number;
rate: number;
+ amount?: number;
}>;
};
className?: string;
@@ -66,7 +71,8 @@ export function EmailPreview({
status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate,
- notes: null,
+ currency: invoice.currency,
+ notes: invoice.notes,
client: {
name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null,
@@ -74,11 +80,11 @@ export function EmailPreview({
business: invoice.business ?? null,
items:
invoice.items?.map((item) => ({
- date: new Date(),
- description: "Service",
+ date: item.date ?? new Date(),
+ description: item.description ?? "Service",
hours: item.hours,
rate: item.rate,
- amount: item.hours * item.rate,
+ amount: item.amount ?? item.hours * item.rate,
})) ?? [],
},
customContent: content,
@@ -95,7 +101,7 @@ export function EmailPreview({
return (
{/* Email Headers */}
-
+
@@ -142,7 +148,7 @@ export function EmailPreview({
{/* Email Content */}
{emailTemplate ? (
-
-
+ ${
+ formattedNotes
+ ? ``
+ : ""
+ }
@@ -540,7 +562,15 @@ Subtotal: ${formatCurrency(subtotal)}${
}
Total: ${formatCurrency(total)}
-
+${
+ invoice.notes?.trim()
+ ? `
+NOTES
+═══════════════
+${invoice.notes.trim()}
+`
+ : ""
+}
ATTACHMENT
═══════════════
diff --git a/src/server/api/routers/email.ts b/src/server/api/routers/email.ts
index 53a3f63..3c88a74 100644
--- a/src/server/api/routers/email.ts
+++ b/src/server/api/routers/email.ts
@@ -105,6 +105,7 @@ export const emailRouter = createTRPCRouter({
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
+ currency: invoice.currency,
notes: invoice.notes,
client: {
name: invoice.client.name,
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index 65e41c3..83fa6e0 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -43,6 +43,15 @@ const updateStatusSchema = z.object({
status: z.enum(["draft", "sent", "paid"]),
});
+const calculateInvoiceTotal = (
+ items: Array
>,
+ 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({
getAll: protectedProcedure.query(async ({ ctx }) => {
try {
@@ -140,11 +149,19 @@ export const invoicesRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
try {
const { items, ...invoiceData } = input;
+ const cleanInvoiceData = {
+ ...invoiceData,
+ businessId:
+ !invoiceData.businessId || invoiceData.businessId.trim() === ""
+ ? null
+ : invoiceData.businessId,
+ notes: invoiceData.notes === "" ? null : invoiceData.notes,
+ };
// Verify business exists and belongs to user (if provided)
- if (invoiceData.businessId && invoiceData.businessId.trim() !== "") {
+ if (cleanInvoiceData.businessId) {
const business = await ctx.db.query.businesses.findFirst({
- where: eq(businesses.id, invoiceData.businessId),
+ where: eq(businesses.id, cleanInvoiceData.businessId),
});
if (!business) {
@@ -165,7 +182,7 @@ export const invoicesRouter = createTRPCRouter({
// Verify client exists and belongs to user
const client = await ctx.db.query.clients.findFirst({
- where: eq(clients.id, invoiceData.clientId),
+ where: eq(clients.id, cleanInvoiceData.clientId),
});
if (!client) {
@@ -183,42 +200,39 @@ export const invoicesRouter = createTRPCRouter({
});
}
- // Calculate subtotal and tax
- const subtotal = items.reduce(
- (sum, item) => sum + item.hours * item.rate,
- 0,
+ const totalAmount = calculateInvoiceTotal(
+ items,
+ cleanInvoiceData.taxRate,
);
- const taxAmount = (subtotal * invoiceData.taxRate) / 100;
- const totalAmount = subtotal + taxAmount;
- // Create invoice
- const [invoice] = await ctx.db
- .insert(invoices)
- .values({
- ...invoiceData,
- totalAmount,
- createdById: ctx.session.user.id,
- })
- .returning();
+ return await ctx.db.transaction(async (tx) => {
+ const [invoice] = await tx
+ .insert(invoices)
+ .values({
+ ...cleanInvoiceData,
+ totalAmount,
+ createdById: ctx.session.user.id,
+ })
+ .returning();
- if (!invoice) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to create invoice",
- });
- }
+ if (!invoice) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create invoice",
+ });
+ }
- // Create invoice items
- const itemsToInsert = items.map((item, idx) => ({
- ...item,
- invoiceId: invoice.id,
- amount: item.hours * item.rate,
- position: idx,
- }));
+ await tx.insert(invoiceItems).values(
+ items.map((item, idx) => ({
+ ...item,
+ invoiceId: invoice.id,
+ amount: item.hours * item.rate,
+ position: idx,
+ })),
+ );
- await ctx.db.insert(invoiceItems).values(itemsToInsert);
-
- return invoice;
+ return invoice;
+ });
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
@@ -238,11 +252,17 @@ export const invoicesRouter = createTRPCRouter({
// Clean up empty strings to null for optional string fields only
const cleanInvoiceData = {
...invoiceData,
- businessId:
- !invoiceData.businessId || invoiceData.businessId.trim() === ""
- ? null
- : invoiceData.businessId,
- notes: invoiceData.notes === "" ? null : invoiceData.notes,
+ ...(invoiceData.businessId !== undefined
+ ? {
+ businessId:
+ invoiceData.businessId.trim() === ""
+ ? null
+ : invoiceData.businessId,
+ }
+ : {}),
+ ...(invoiceData.notes !== undefined
+ ? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
+ : {}),
};
// Verify invoice exists and belongs to user
@@ -295,70 +315,58 @@ export const invoicesRouter = createTRPCRouter({
}
}
- if (items) {
- // Calculate subtotal and tax
- const subtotal = items.reduce(
- (sum, item) => sum + item.hours * item.rate,
- 0,
- );
- const taxAmount =
- (subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
- 100;
- const totalAmount = subtotal + taxAmount;
+ await ctx.db.transaction(async (tx) => {
+ if (items) {
+ const totalAmount = calculateInvoiceTotal(
+ items,
+ cleanInvoiceData.taxRate ?? existingInvoice.taxRate,
+ );
- // Update invoice
- const updateData = {
- ...cleanInvoiceData,
- totalAmount,
- updatedAt: new Date(),
- };
+ const [updatedInvoice] = await tx
+ .update(invoices)
+ .set({
+ ...cleanInvoiceData,
+ totalAmount,
+ updatedAt: new Date(),
+ })
+ .where(eq(invoices.id, id))
+ .returning();
- const [updatedInvoice] = await ctx.db
- .update(invoices)
- .set(updateData)
- .where(eq(invoices.id, id))
- .returning();
+ if (!updatedInvoice) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update invoice",
+ });
+ }
- if (!updatedInvoice) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to update invoice",
- });
+ await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
+
+ await tx.insert(invoiceItems).values(
+ items.map((item, idx) => ({
+ ...item,
+ invoiceId: id,
+ amount: item.hours * item.rate,
+ position: idx,
+ })),
+ );
+ } else {
+ const [updatedInvoice] = await tx
+ .update(invoices)
+ .set({
+ ...cleanInvoiceData,
+ updatedAt: new Date(),
+ })
+ .where(eq(invoices.id, id))
+ .returning();
+
+ if (!updatedInvoice) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update invoice",
+ });
+ }
}
-
- // Delete existing items and create new ones
- await ctx.db
- .delete(invoiceItems)
- .where(eq(invoiceItems.invoiceId, id));
-
- const itemsToInsert = items.map((item, idx) => ({
- ...item,
- invoiceId: id,
- amount: item.hours * item.rate,
- position: idx,
- }));
-
- await ctx.db.insert(invoiceItems).values(itemsToInsert);
- } else {
- // Update invoice without items
- const updateData = {
- ...cleanInvoiceData,
- updatedAt: new Date(),
- };
-
- const [updatedInvoice] = await ctx.db
- .update(invoices)
- .set(updateData)
- .where(eq(invoices.id, id))
- .returning();
-
- if (!updatedInvoice) {
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to update invoice",
- });
- }
- }
+ });
return { success: true };
} catch (error) {