diff --git a/bun.lock b/bun.lock
index a2beae1..ae1d37f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -41,7 +41,7 @@
"file-saver": "^2.0.5",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
- "next": "^15.4.1",
+ "next": "^15.4.2",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
@@ -280,25 +280,25 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
- "@next/env": ["@next/env@15.4.1", "", {}, "sha512-DXQwFGAE2VH+f2TJsKepRXpODPU+scf5fDbKOME8MMyeyswe4XwgRdiiIYmBfkXU+2ssliLYznajTrOQdnLR5A=="],
+ "@next/env": ["@next/env@15.4.2", "", {}, "sha512-kd7MvW3pAP7tmk1NaiX4yG15xb2l4gNhteKQxt3f+NGR22qwPymn9RBuv26QKfIKmfo6z2NpgU8W2RT0s0jlvg=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-BZwWPGfp9po/rAnJcwUBaM+yT/+yTWIkWdyDwc74G9jcfTrNrmsHe+hXHljV066YNdVs8cxROxX5IgMQGX190w=="],
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+81yMsiHq82VRXS2RVq6OgDwjvA4kDksGU8hfiDHEXP+ncKIUhUsadAVB+MRIp2FErs/5hpXR0u2eluWPAhig=="],
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ovqjR8NjCBdBf1U+R/Gvn0RazTtXS9n6wqs84iFaCS1NHbw9ksVE4dfmsYcLoyUVd9BWE0bjkphOWrrz8uz/uw=="],
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jfz1RXu6SzL14lFl05/MNkcN35lTLMJWPbqt7Xaj35+ZWAX342aePIJrN6xBdGeKl6jPXJm0Yqo3Xvh3Gpo3Uw=="],
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-I8d4W7tPqbdbHRI4z1iBfaoJIBrEG4fnWKIe+Rj1vIucNZ5cEinfwkBt3RcDF00bFRZRDpvKuDjgMFD3OyRBnw=="],
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-k0tOFn3dsnkaGfs6iQz8Ms6f1CyQe4GacXF979sL8PNQxjYS1swx9VsOyUQYaPoGV8nAZ7OX8cYaeiXGq9ahPQ=="],
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lvhz02dU3Ec5thzfQ2RCUeOFADjNkS/px1W7MBt7HMhf0/amMfT8Z/aXOwEA+cVWN7HSDRSUc8hHILoHmvajsg=="],
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4ogGQ/3qDzbbK3IwV88ltihHFbQVq6Qr+uEapzXHXBH1KsVBZOB50sn6BWHPcFjwSoMX2Tj9eH/fZvQnSIgc3g=="],
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-v+5PPfL8UP+KKHS3Mox7QMoeFdMlaV0zeNMIF7eLC4qTiVSO0RPNnK0nkBZSD5BEkkf//c+vI9s/iHxddCZchA=="],
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Jj0Rfw3wIgp+eahMz/tOGwlcYYEFjlBPKU7NqoOkTX0LY45i5W0WcDpgiDWSLrN8KFQq/LW7fZq46gxGCiOYlQ=="],
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-PHLYOC9W2cu6I/JEKo77+LW4uPNvyEQiSkVRUQPsOIsf01PRr8PtPhwtz3XNnC9At8CrzPkzqQ9/kYDg4R4Inw=="],
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9WlEZfnw1vFqkWsTMzZDgNL7AUI1aiBHi0S2m8jvycPyCq/fbZjtE/nDkhJRYbSjXbtRHYLDBlmP95kpjEmJbw=="],
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lpmUF9FfLFns4JbTu+5aJGA8aR9dXaA12eoNe9CJbVkGib0FDiPa4kBGTwy0xDxKNGlv3bLDViyx1U+qafmuJQ=="],
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WodRbZ9g6CQLRZsG3gtrA9w7Qfa9BwDzhFVdlI6sV0OCPq9JrOrJSp9/ioLsezbV8w9RCJ8v55uzJuJ5RgWLZg=="],
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-aMjogoGnRepas0LQ/PBPsvvUzj+IoXw2IoDSEShEtrsu2toBiaxEWzOQuPZ8nie8+1iF7TA63S7rlp3YWAjNEg=="],
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-y+wTBxelk2xiNofmDOVU7O5WxTHcvOoL3srOM0kxTzKDjQ57kPU0tpnPJ/BWrRnsOwXEv0+3QSbGR7hY4n9LkQ=="],
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-FxwauyexSFu78wEqR/+NB9MnqXVj6SxJKwcVs2CRjeSX/jBagDCgtR2W36PZUYm0WPgY1pQ3C1+nn7zSnwROuw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -1050,7 +1050,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
- "next": ["next@15.4.1", "", { "dependencies": { "@next/env": "15.4.1", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.1", "@next/swc-darwin-x64": "15.4.1", "@next/swc-linux-arm64-gnu": "15.4.1", "@next/swc-linux-arm64-musl": "15.4.1", "@next/swc-linux-x64-gnu": "15.4.1", "@next/swc-linux-x64-musl": "15.4.1", "@next/swc-win32-arm64-msvc": "15.4.1", "@next/swc-win32-x64-msvc": "15.4.1", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw=="],
+ "next": ["next@15.4.2", "", { "dependencies": { "@next/env": "15.4.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.2", "@next/swc-darwin-x64": "15.4.2", "@next/swc-linux-arm64-gnu": "15.4.2", "@next/swc-linux-arm64-musl": "15.4.2", "@next/swc-linux-x64-gnu": "15.4.2", "@next/swc-linux-x64-musl": "15.4.2", "@next/swc-win32-arm64-msvc": "15.4.2", "@next/swc-win32-x64-msvc": "15.4.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-oH1rmFso+84NIkocfuxaGKcXIjMUTmnzV2x0m8qsYtB4gD6iflLMESXt5XJ8cFgWMBei4v88rNr/j+peNg72XA=="],
"next-auth": ["next-auth@5.0.0-beta.25", "", { "dependencies": { "@auth/core": "0.37.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog=="],
diff --git a/package.json b/package.json
index a537469..f27ba2f 100644
--- a/package.json
+++ b/package.json
@@ -63,7 +63,7 @@
"file-saver": "^2.0.5",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
- "next": "^15.4.1",
+ "next": "^15.4.2",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
diff --git a/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx b/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx
index 3c1b2c3..7af3302 100644
--- a/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx
+++ b/src/app/dashboard/invoices/[id]/_components/unified-invoice-page.tsx
@@ -1,7 +1,7 @@
"use client";
import { InvoiceView } from "~/components/data/invoice-view";
-import { InvoiceForm } from "~/components/forms/invoice-form";
+import InvoiceForm from "~/components/forms/invoice-form";
interface UnifiedInvoicePageProps {
invoiceId: string;
diff --git a/src/app/dashboard/invoices/[id]/edit/page.tsx b/src/app/dashboard/invoices/[id]/edit/page.tsx
deleted file mode 100644
index 6640a22..0000000
--- a/src/app/dashboard/invoices/[id]/edit/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client";
-
-import { useParams } from "next/navigation";
-import { InvoiceForm } from "~/components/forms/invoice-form";
-
-export default function EditInvoicePage() {
- const params = useParams();
- const invoiceId = params.id as string;
-
- return ;
-}
diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx
index 44c8195..ecb84a3 100644
--- a/src/app/dashboard/invoices/[id]/page.tsx
+++ b/src/app/dashboard/invoices/[id]/page.tsx
@@ -1,389 +1,12 @@
-import { Suspense } from "react";
-import { notFound } from "next/navigation";
-import Link from "next/link";
-import { api, HydrateClient } from "~/trpc/server";
-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 { PageHeader } from "~/components/layout/page-header";
-import { PDFDownloadButton } from "./_components/pdf-download-button";
-import { SendInvoiceButton } from "./_components/send-invoice-button";
-import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
+"use client";
-import {
- Building,
- Edit,
- FileText,
- Mail,
- MapPin,
- Phone,
- User,
- AlertTriangle,
- Check,
-} from "lucide-react";
+import { useParams } from "next/navigation";
+import InvoiceForm from "~/components/forms/invoice-form";
-interface InvoicePageProps {
- params: Promise<{ id: string }>;
-}
-
-async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
- const invoice = await api.invoices.getById({ id: invoiceId });
-
- if (!invoice) {
- notFound();
- }
-
- const formatDate = (date: Date) => {
- return new Intl.DateTimeFormat("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- }).format(new Date(date));
- };
-
- const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(amount);
- };
-
- const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
- const taxAmount = (subtotal * invoice.taxRate) / 100;
- const total = subtotal + taxAmount;
- const isOverdue =
- new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
-
- const getStatusType = (): StatusType => {
- if (invoice.status === "paid") return "paid";
- if (invoice.status === "draft") return "draft";
- if (invoice.status === "overdue") return "overdue";
- if (invoice.status === "sent") {
- return isOverdue ? "overdue" : "sent";
- }
- return "draft";
- };
-
- return (
-
-
-
-
-
-
- {/* Content */}
-
- {/* Left Column */}
-
- {/* Invoice Header */}
-
-
-
-
-
-
-
- {invoice.invoiceNumber}
-
-
-
-
-
- Issued {formatDate(invoice.issueDate)}
-
-
- Due {formatDate(invoice.dueDate)}
-
-
-
-
-
- Total Amount
-
-
- {formatCurrency(total)}
-
-
-
-
-
-
-
- {/* Overdue Alert */}
- {isOverdue && (
-
-
-
-
-
-
Invoice Overdue
-
- {Math.ceil(
- (new Date().getTime() -
- new Date(invoice.dueDate).getTime()) /
- (1000 * 60 * 60 * 24),
- )}{" "}
- days past due date
-
-
-
-
-
- )}
-
- {/* Client & Business Info */}
-
- {/* 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.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}
- )}
-
-
- )}
-
-
-
-
- {/* Business Information */}
- {invoice.business && (
-
-
-
-
- From
-
-
-
-
-
- {invoice.business.name}
-
-
-
-
- {invoice.business.email && (
-
-
-
-
-
- {invoice.business.email}
-
-
- )}
-
- {invoice.business.phone && (
-
-
-
- {invoice.business.phone}
-
-
- )}
-
-
-
- )}
-
-
- {/* Invoice Items */}
-
-
-
-
- Invoice Items
-
-
-
- {invoice.items.map((item) => (
-
-
-
-
-
- {item.description}
-
-
-
- {formatDate(item.date).replace(/ /g, "\u00A0")}
-
-
- {item.hours.toString().replace(/ /g, "\u00A0")}
- hours
-
-
- @ ${item.rate}/hr
-
-
-
-
-
- {formatCurrency(item.amount)}
-
-
-
-
-
- ))}
-
- {/* Totals */}
-
-
-
- Subtotal:
-
- {formatCurrency(subtotal)}
-
-
- {invoice.taxRate > 0 && (
-
-
- Tax ({invoice.taxRate}%):
-
-
- {formatCurrency(taxAmount)}
-
-
- )}
-
-
- Total:
-
- {formatCurrency(total)}
-
-
-
-
-
-
-
- {/* Notes */}
- {invoice.notes && (
-
-
- Notes
-
-
-
- {invoice.notes}
-
-
-
- )}
-
-
- {/* Right Column - Actions */}
-
-
-
-
-
- Actions
-
-
-
-
-
- {invoice.items && invoice.client && (
-
- )}
-
- {invoice.status === "draft" && (
-
- )}
-
-
-
-
-
- );
-}
-
-export default async function InvoicePage({ params }: InvoicePageProps) {
- const { id } = await params;
-
- return (
-
- }>
-
-
-
- );
+export default function InvoiceFormPage() {
+ const params = useParams();
+ const id = params.id as string;
+
+ // Pass the actual id, let the form component handle the logic
+ return ;
}
diff --git a/src/app/dashboard/invoices/[id]/view/page.tsx b/src/app/dashboard/invoices/[id]/view/page.tsx
new file mode 100644
index 0000000..b4e959c
--- /dev/null
+++ b/src/app/dashboard/invoices/[id]/view/page.tsx
@@ -0,0 +1,389 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { api, HydrateClient } from "~/trpc/server";
+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 { PageHeader } from "~/components/layout/page-header";
+import { PDFDownloadButton } from "../_components/pdf-download-button";
+import { SendInvoiceButton } from "../_components/send-invoice-button";
+import { InvoiceDetailsSkeleton } from "../_components/invoice-details-skeleton";
+
+import {
+ Building,
+ Edit,
+ FileText,
+ Mail,
+ MapPin,
+ Phone,
+ User,
+ AlertTriangle,
+ Check,
+} from "lucide-react";
+
+interface InvoiceViewPageProps {
+ params: Promise<{ id: string }>;
+}
+
+async function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
+ const invoice = await api.invoices.getById({ id: invoiceId });
+
+ if (!invoice) {
+ notFound();
+ }
+
+ const formatDate = (date: Date) => {
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(date));
+ };
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(amount);
+ };
+
+ const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
+ const taxAmount = (subtotal * invoice.taxRate) / 100;
+ const total = subtotal + taxAmount;
+ const isOverdue =
+ new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
+
+ const getStatusType = (): StatusType => {
+ if (invoice.status === "paid") return "paid";
+ if (invoice.status === "draft") return "draft";
+ if (invoice.status === "overdue") return "overdue";
+ if (invoice.status === "sent") {
+ return isOverdue ? "overdue" : "sent";
+ }
+ return "draft";
+ };
+
+ return (
+
+
+
+
+
+
+ {/* Content */}
+
+ {/* Left Column */}
+
+ {/* Invoice Header */}
+
+
+
+
+
+
+
+ {invoice.invoiceNumber}
+
+
+
+
+
+ Issued {formatDate(invoice.issueDate)}
+
+
+ Due {formatDate(invoice.dueDate)}
+
+
+
+
+
+ Total Amount
+
+
+ {formatCurrency(total)}
+
+
+
+
+
+
+
+ {/* Overdue Alert */}
+ {isOverdue && (
+
+
+
+
+
+
Invoice Overdue
+
+ {Math.ceil(
+ (new Date().getTime() -
+ new Date(invoice.dueDate).getTime()) /
+ (1000 * 60 * 60 * 24),
+ )}{" "}
+ days past due date
+
+
+
+
+
+ )}
+
+ {/* Client & Business Info */}
+
+ {/* 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.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}
+ )}
+
+
+ )}
+
+
+
+
+ {/* Business Information */}
+ {invoice.business && (
+
+
+
+
+ From
+
+
+
+
+
+ {invoice.business.name}
+
+
+
+
+ {invoice.business.email && (
+
+
+
+
+
+ {invoice.business.email}
+
+
+ )}
+
+ {invoice.business.phone && (
+
+
+
+ {invoice.business.phone}
+
+
+ )}
+
+
+
+ )}
+
+
+ {/* Invoice Items */}
+
+
+
+
+ Invoice Items
+
+
+
+ {invoice.items.map((item) => (
+
+
+
+
+
+ {item.description}
+
+
+
+ {formatDate(item.date).replace(/ /g, "\u00A0")}
+
+
+ {item.hours.toString().replace(/ /g, "\u00A0")}
+ hours
+
+
+ @ ${item.rate}/hr
+
+
+
+
+
+ {formatCurrency(item.amount)}
+
+
+
+
+
+ ))}
+
+ {/* Totals */}
+
+
+
+ Subtotal:
+
+ {formatCurrency(subtotal)}
+
+
+ {invoice.taxRate > 0 && (
+
+
+ Tax ({invoice.taxRate}%):
+
+
+ {formatCurrency(taxAmount)}
+
+
+ )}
+
+
+ Total:
+
+ {formatCurrency(total)}
+
+
+
+
+
+
+
+ {/* Notes */}
+ {invoice.notes && (
+
+
+ Notes
+
+
+
+ {invoice.notes}
+
+
+
+ )}
+
+
+ {/* Right Column - Actions */}
+
+
+
+
+
+ Actions
+
+
+
+
+
+ {invoice.items && invoice.client && (
+
+ )}
+
+ {invoice.status === "draft" && (
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default async function InvoiceViewPage({ params }: InvoiceViewPageProps) {
+ const { id } = await params;
+
+ return (
+
+ }>
+
+
+
+ );
+}
diff --git a/src/app/dashboard/invoices/_components/invoices-data-table.tsx b/src/app/dashboard/invoices/_components/invoices-data-table.tsx
index ee566df..fc86d8a 100644
--- a/src/app/dashboard/invoices/_components/invoices-data-table.tsx
+++ b/src/app/dashboard/invoices/_components/invoices-data-table.tsx
@@ -181,7 +181,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
-
+
-
+
Continue
diff --git a/src/components/data/invoice-list.tsx b/src/components/data/invoice-list.tsx
index 24d659f..7f55a6a 100644
--- a/src/components/data/invoice-list.tsx
+++ b/src/components/data/invoice-list.tsx
@@ -150,12 +150,12 @@ export function InvoiceList() {
{invoice.invoiceNumber}
-
+
-
+
diff --git a/src/components/forms/address-form.tsx b/src/components/forms/address-form.tsx
index b24e2c5..c5dbd2f 100644
--- a/src/components/forms/address-form.tsx
+++ b/src/components/forms/address-form.tsx
@@ -134,6 +134,7 @@ export function AddressForm({
{country === "United States" ? (
*}
-
-
- {/* Form Content */}
+
+
- {/* Left Column - Content with Tabs */}
- {/* Tabs - Match actual TabsList structure */}
-
-
- {/* Invoice Details Card */}
-
-
-
-
-
- {/* First row - stacked on mobile */}
-
-
- {/* Second row */}
-
-
- {/* Third row */}
-
-
- {/* Status field */}
-
-
- {/* Notes field */}
-
-
-
-
- {/* Invoice Items Card */}
-
-
-
-
-
- {/* Line item skeleton */}
-
-
-
- {/* Description */}
-
-
- {/* Date, Hours, Rate - stacked on mobile */}
-
-
- {/* Amount display */}
-
-
-
- {/* Add item button */}
-
-
-
+
-
- {/* Right Column - Summary */}
-
-
-
-
-
- {/* Totals */}
-
-
-
-
- {/* Stats */}
-
-
-
-
-
-
- {/* Floating Action Bar Skeleton - Mobile only */}
-
);
}
-function InvoiceForm({ invoiceId }: InvoiceFormProps) {
+export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const utils = api.useUtils();
- const [formData, setFormData] = useState({
+ // Single state object for all form data
+ const [formData, setFormData] = useState({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
businessId: "",
clientId: "",
issueDate: new Date(),
dueDate: new Date(),
- status: "draft" as "draft" | "sent" | "paid" | "overdue",
+ status: "draft",
notes: "",
taxRate: 0,
- defaultHourlyRate: 100,
+ defaultHourlyRate: 25,
items: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
- rate: 100,
- amount: 100,
+ rate: 25,
+ amount: 25,
},
],
});
- const [loading, setLoading] = useState(false);
- // Fetch clients and businesses for dropdowns
+ const [loading, setLoading] = useState(false);
+ const [initialized, setInitialized] = useState(false);
+
+ // Data queries
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
-
- // Fetch existing invoice data if editing
const { data: existingInvoice, isLoading: loadingInvoice } =
- api.invoices.getById.useQuery({ id: invoiceId! }, { enabled: !!invoiceId });
+ api.invoices.getById.useQuery(
+ { id: invoiceId! },
+ { enabled: !!invoiceId && invoiceId !== "new" },
+ );
- // Populate form with existing data when editing
- React.useEffect(() => {
- if (existingInvoice && invoiceId) {
- setFormData({
+ // Single initialization effect - only runs once when data is ready
+ useEffect(() => {
+ if (initialized) return;
+
+ const dataReady =
+ !loadingClients &&
+ !loadingBusinesses &&
+ (!invoiceId || invoiceId === "new" || !loadingInvoice);
+ if (!dataReady) return;
+
+ if (invoiceId && invoiceId !== "new" && existingInvoice) {
+ // Initialize with existing invoice data
+ const formDataToSet = {
invoiceNumber: existingInvoice.invoiceNumber,
businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId,
@@ -280,51 +141,52 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
- defaultHourlyRate: 100,
-
- items: existingInvoice.items?.map((item) => ({
- id: crypto.randomUUID(),
- date: new Date(item.date),
- description: item.description,
- hours: item.hours,
- rate: item.rate,
- amount: item.amount,
- })) || [
- {
+ defaultHourlyRate: 25,
+ items:
+ existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(),
- date: new Date(),
- description: "",
- hours: 1,
- rate: 100,
- amount: 100,
- },
- ],
- });
- }
- }, [existingInvoice, invoiceId]);
-
- // Auto-fill default business for new invoices
- React.useEffect(() => {
- if (!invoiceId && businesses && !formData.businessId) {
+ date: new Date(item.date),
+ description: item.description,
+ hours: item.hours,
+ rate: item.rate,
+ amount: item.amount,
+ })) || [],
+ };
+ setFormData(formDataToSet);
+ } else if ((!invoiceId || invoiceId === "new") && businesses) {
+ // New invoice - set default business
const defaultBusiness = businesses.find((b) => b.isDefault);
if (defaultBusiness) {
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
+ } else if (businesses.length > 0) {
+ // If no default business, use the first one
+ setFormData((prev) => ({ ...prev, businessId: businesses[0]!.id }));
}
}
- }, [businesses, formData.businessId, invoiceId]);
- // Update default hourly rate when client changes
- React.useEffect(() => {
- if (formData.clientId && clients) {
- const selectedClient = clients.find((c) => c.id === formData.clientId);
- if (selectedClient?.defaultHourlyRate) {
- setFormData((prev) => ({
- ...prev,
- defaultHourlyRate: selectedClient.defaultHourlyRate,
- }));
- }
+ setInitialized(true);
+ }, [
+ loadingClients,
+ loadingBusinesses,
+ loadingInvoice,
+ existingInvoice,
+ businesses,
+ invoiceId,
+ initialized,
+ ]);
+
+ // Update default hourly rate when client changes (only during initialization)
+ useEffect(() => {
+ if (!initialized || !formData.clientId || !clients) return;
+
+ const selectedClient = clients.find((c) => c.id === formData.clientId);
+ if (selectedClient?.defaultHourlyRate) {
+ setFormData((prev) => ({
+ ...prev,
+ defaultHourlyRate: selectedClient.defaultHourlyRate,
+ }));
}
- }, [formData.clientId, clients]);
+ }, [formData.clientId, clients, initialized]);
// Calculate totals
const totals = React.useMemo(() => {
@@ -337,7 +199,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]);
- // Add new item
+ // Item management functions
const addItem = () => {
setFormData((prev) => ({
...prev,
@@ -348,14 +210,13 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
date: new Date(),
description: "",
hours: 1,
- rate: formData.defaultHourlyRate,
- amount: formData.defaultHourlyRate,
+ rate: prev.defaultHourlyRate,
+ amount: prev.defaultHourlyRate,
},
],
}));
};
- // Remove item
const removeItem = (idx: number) => {
if (formData.items.length > 1) {
setFormData((prev) => ({
@@ -365,7 +226,6 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}
};
- // Update item
const updateItem = (
idx: number,
field: string,
@@ -373,55 +233,57 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
) => {
setFormData((prev) => ({
...prev,
- items: prev.items.map((item, i) =>
- i === idx ? { ...item, [field]: value } : item,
- ),
+ items: prev.items.map((item, i) => {
+ if (i === idx) {
+ const updatedItem = { ...item, [field]: value };
+ if (field === "hours" || field === "rate") {
+ updatedItem.amount = updatedItem.hours * updatedItem.rate;
+ }
+ return updatedItem;
+ }
+ return item;
+ }),
}));
};
- // Move item up
const moveItemUp = (idx: number) => {
- if (idx === 0) return; // Already at top
+ if (idx === 0) return;
setFormData((prev) => {
const newItems = [...prev.items];
- if (idx > 0 && idx < newItems.length) {
- const temp = newItems[idx - 1]!;
- newItems[idx - 1] = newItems[idx]!;
- newItems[idx] = temp;
+ if (newItems[idx] && newItems[idx - 1]) {
+ [newItems[idx - 1], newItems[idx]] = [
+ newItems[idx]!,
+ newItems[idx - 1]!,
+ ];
}
return { ...prev, items: newItems };
});
};
- // Move item down
const moveItemDown = (idx: number) => {
- if (idx === formData.items.length - 1) return; // Already at bottom
+ if (idx === formData.items.length - 1) return;
setFormData((prev) => {
const newItems = [...prev.items];
- if (idx >= 0 && idx < newItems.length - 1) {
- const temp = newItems[idx]!;
- newItems[idx] = newItems[idx + 1]!;
- newItems[idx + 1] = temp;
+ if (newItems[idx] && newItems[idx + 1]) {
+ [newItems[idx], newItems[idx + 1]] = [
+ newItems[idx + 1]!,
+ newItems[idx]!,
+ ];
}
return { ...prev, items: newItems };
});
};
- // Reorder items
- const reorderItems = (newItems: typeof formData.items) => {
- setFormData((prev) => ({
- ...prev,
- items: newItems,
- }));
+ const reorderItems = (newItems: InvoiceItem[]) => {
+ setFormData((prev) => ({ ...prev, items: newItems }));
};
- // tRPC mutations
+ // Mutations
const createInvoice = api.invoices.create.useMutation({
- onSuccess: () => {
+ onSuccess: (invoice) => {
toast.success("Invoice created successfully");
- // Invalidate related queries to refresh cache
void utils.invoices.getAll.invalidate();
- router.push("/dashboard/invoices");
+ router.push(`/dashboard/invoices/${invoice.id}/view`);
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
@@ -429,125 +291,122 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
});
const updateInvoice = api.invoices.update.useMutation({
- onSuccess: () => {
+ onSuccess: async () => {
toast.success("Invoice updated successfully");
- // Invalidate related queries to refresh cache
- void utils.invoices.getAll.invalidate();
- if (invoiceId) {
- void utils.invoices.getById.invalidate({ id: invoiceId });
+ await utils.invoices.getAll.invalidate();
+ // The update mutation returns { success: true }, so we use the current invoiceId
+ if (invoiceId && invoiceId !== "new") {
+ router.push(`/dashboard/invoices/${invoiceId}/view`);
+ } else {
+ router.push("/dashboard/invoices");
}
- router.push("/dashboard/invoices");
},
onError: (error) => {
- console.error("Update invoice error:", error);
toast.error(error.message || "Failed to update invoice");
},
});
- const updateStatus = api.invoices.updateStatus.useMutation({
- onSuccess: () => {
- toast.success("Status updated successfully");
- // Invalidate related queries to refresh cache
- void utils.invoices.getAll.invalidate();
- if (invoiceId) {
- void utils.invoices.getById.invalidate({ id: invoiceId });
- }
- router.push("/dashboard/invoices");
- },
- onError: (error) => {
- console.error("Update status error:", error);
- toast.error(error.message || "Failed to update status");
- },
- });
-
- // Check if only status has changed compared to existing invoice
- const hasOnlyStatusChanged = React.useMemo(() => {
- if (!existingInvoice || !invoiceId) return false;
-
- return (
- formData.invoiceNumber === existingInvoice.invoiceNumber &&
- formData.businessId === (existingInvoice.businessId ?? "") &&
- formData.clientId === existingInvoice.clientId &&
- formData.issueDate.getTime() ===
- new Date(existingInvoice.issueDate).getTime() &&
- formData.dueDate.getTime() ===
- new Date(existingInvoice.dueDate).getTime() &&
- formData.status !== existingInvoice.status &&
- formData.notes === (existingInvoice.notes ?? "") &&
- formData.taxRate === existingInvoice.taxRate &&
- JSON.stringify(
- formData.items.map((item) => ({
- date: item.date.getTime(),
- description: item.description,
- hours: item.hours,
- rate: item.rate,
- })),
- ) ===
- JSON.stringify(
- (existingInvoice.items ?? []).map((item) => ({
- date: new Date(item.date).getTime(),
- description: item.description,
- hours: item.hours,
- rate: item.rate,
- })),
- )
- );
- }, [formData, existingInvoice, invoiceId]);
-
+ // Form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
- if (invoiceId && hasOnlyStatusChanged) {
- // Use dedicated status update mutation for status-only changes
- console.log("Using status-only update:", {
- id: invoiceId,
- status: formData.status,
- });
- await updateStatus.mutateAsync({
- id: invoiceId,
- status: formData.status,
- });
- } else {
- // Use full update mutation for all other changes
- const invoiceData = {
- invoiceNumber: formData.invoiceNumber,
- businessId: formData.businessId || undefined,
- clientId: formData.clientId,
- issueDate: formData.issueDate,
- dueDate: formData.dueDate,
- status: formData.status,
- notes: formData.notes,
- taxRate: formData.taxRate,
+ // Validate required fields
+ if (!formData.clientId || formData.clientId.trim() === "") {
+ toast.error("Please select a client");
+ setLoading(false);
+ return;
+ }
- items: formData.items.map((item) => ({
- date: item.date,
- description: item.description,
- hours: item.hours,
- rate: item.rate,
- amount: item.hours * item.rate,
- })),
- };
+ if (!formData.invoiceNumber.trim()) {
+ toast.error("Invoice number is required");
+ setLoading(false);
+ return;
+ }
- console.log("Submitting invoice data:", invoiceData);
+ // Business is optional in the schema, so we don't require it
+ // if (!formData.businessId || formData.businessId.trim() === "") {
+ // toast.error("Please select a business");
+ // setLoading(false);
+ // return;
+ // }
- if (invoiceId) {
- await updateInvoice.mutateAsync({ id: invoiceId, ...invoiceData });
- } else {
- await createInvoice.mutateAsync(invoiceData);
+ if (formData.items.length === 0) {
+ toast.error("At least one invoice item is required");
+ setLoading(false);
+ return;
+ }
+
+ // Validate each item
+ for (let i = 0; i < formData.items.length; i++) {
+ const item = formData.items[i];
+ if (!item) continue;
+
+ if (!item.description.trim()) {
+ toast.error(`Item ${i + 1}: Description is required`);
+ setLoading(false);
+ return;
+ }
+ if (item.hours <= 0) {
+ toast.error(`Item ${i + 1}: Hours must be greater than 0`);
+ setLoading(false);
+ return;
+ }
+ if (item.rate <= 0) {
+ toast.error(`Item ${i + 1}: Rate must be greater than 0`);
+ setLoading(false);
+ return;
}
}
+
+ // Prepare invoice data
+ const invoiceData = {
+ invoiceNumber: formData.invoiceNumber,
+ businessId: formData.businessId || "", // Ensure it's not undefined
+ clientId: formData.clientId,
+ issueDate: formData.issueDate,
+ dueDate: formData.dueDate,
+ status: formData.status,
+ notes: formData.notes,
+ taxRate: formData.taxRate,
+ items: formData.items.map((item) => ({
+ date: item.date,
+ description: item.description,
+ hours: item.hours,
+ rate: item.rate,
+ amount: item.hours * item.rate,
+ })),
+ };
+
+ if (invoiceId && invoiceId !== "new") {
+ await updateInvoice.mutateAsync({ id: invoiceId, ...invoiceData });
+ } else {
+ await createInvoice.mutateAsync(invoiceData);
+ }
} catch (error) {
- console.error("Error saving invoice:", error);
- toast.error("Failed to save invoice. Check console for details.");
+ console.error("Invoice save error:", error);
+ toast.error("Failed to save invoice. Please try again.");
} finally {
setLoading(false);
}
};
+ // Field update functions
+ const updateField = (
+ field: K,
+ value: FormData[K],
+ ) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ };
+
// Show loading state
- if (loadingClients || loadingBusinesses || (invoiceId && loadingInvoice)) {
+ if (
+ !initialized ||
+ loadingClients ||
+ loadingBusinesses ||
+ (invoiceId && invoiceId !== "new" && loadingInvoice)
+ ) {
return ;
}
@@ -555,15 +414,18 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<>
@@ -581,10 +443,8 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
- {/* Form Content */}