mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
Update Next.js to version 15.4.2 and refactor invoice pages
- Upgrade Next.js and related packages for improved performance and security - Refactor invoice-related pages to streamline navigation and enhance user experience - Consolidate invoice editing and viewing functionality into a single page - Remove deprecated edit page and implement a new view page for invoices - Update links and routing for consistency across the dashboard
This commit is contained in:
22
bun.lock
22
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=="],
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <InvoiceForm invoiceId={invoiceId} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 pb-24">
|
||||
<PageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
|
||||
<Button asChild variant="default">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="h-5 w-5" />
|
||||
<span>Edit</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header */}
|
||||
<Card className="card-primary">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h2 className="text-foreground truncate text-2xl font-bold">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<StatusBadge status={getStatusType()} />
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||
<div className="sm:inline">
|
||||
Issued {formatDate(invoice.issueDate)}
|
||||
</div>
|
||||
<div className="sm:inline sm:before:content-['_•_']">
|
||||
Due {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Total Amount
|
||||
</p>
|
||||
<p className="text-primary text-3xl font-bold">
|
||||
{formatCurrency(total)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Invoice Overdue</p>
|
||||
<p className="text-sm">
|
||||
{Math.ceil(
|
||||
(new Date().getTime() -
|
||||
new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days past due date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Information */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="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-xl font-semibold">
|
||||
{invoice.client.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">
|
||||
{invoice.client.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.client.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{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>
|
||||
|
||||
{/* Business Information */}
|
||||
{invoice.business && (
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">
|
||||
{invoice.business.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{invoice.business.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item) => (
|
||||
<Card key={item.id} className="card-secondary">
|
||||
<CardContent className="py-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground mb-2 text-base font-medium">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<span className="inline whitespace-nowrap">
|
||||
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
||||
hours
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
@ ${item.rate}/hr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-primary text-lg font-semibold">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({invoice.taxRate}%):
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-primary">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
||||
)}
|
||||
|
||||
{invoice.status === "draft" && (
|
||||
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||
<InvoiceContent invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
);
|
||||
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 <InvoiceForm invoiceId={id} />;
|
||||
}
|
||||
|
||||
389
src/app/dashboard/invoices/[id]/view/page.tsx
Normal file
389
src/app/dashboard/invoices/[id]/view/page.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6 pb-24">
|
||||
<PageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" />
|
||||
<Button asChild variant="default">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Edit className="h-5 w-5" />
|
||||
<span>Edit</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header */}
|
||||
<Card className="card-primary">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h2 className="text-foreground truncate text-2xl font-bold">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<StatusBadge status={getStatusType()} />
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||
<div className="sm:inline">
|
||||
Issued {formatDate(invoice.issueDate)}
|
||||
</div>
|
||||
<div className="sm:inline sm:before:content-['_•_']">
|
||||
Due {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Total Amount
|
||||
</p>
|
||||
<p className="text-primary text-3xl font-bold">
|
||||
{formatCurrency(total)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Invoice Overdue</p>
|
||||
<p className="text-sm">
|
||||
{Math.ceil(
|
||||
(new Date().getTime() -
|
||||
new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days past due date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Information */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="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-xl font-semibold">
|
||||
{invoice.client.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">
|
||||
{invoice.client.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.client.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{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>
|
||||
|
||||
{/* Business Information */}
|
||||
{invoice.business && (
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">
|
||||
{invoice.business.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{invoice.business.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item) => (
|
||||
<Card key={item.id} className="card-secondary">
|
||||
<CardContent className="py-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground mb-2 text-base font-medium">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<span className="inline whitespace-nowrap">
|
||||
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
||||
hours
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
@ ${item.rate}/hr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-primary text-lg font-semibold">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({invoice.taxRate}%):
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-primary">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" />
|
||||
)}
|
||||
|
||||
{invoice.status === "draft" && (
|
||||
<SendInvoiceButton invoiceId={invoice.id} className="w-full" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function InvoiceViewPage({ params }: InvoiceViewPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||
<InvoiceViewContent invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
Loader2,
|
||||
Send,
|
||||
DollarSign,
|
||||
Hash,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoiceItem {
|
||||
tempId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface InvoiceFormData {
|
||||
invoiceNumber: string;
|
||||
businessId: string | undefined;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
notes: string;
|
||||
taxRate: number;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
function InvoiceItemCard({
|
||||
item,
|
||||
index,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
_isLast,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onUpdate: (
|
||||
index: number,
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onDelete: (index: number) => void;
|
||||
_isLast: boolean;
|
||||
}) {
|
||||
const handleFieldChange = (
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
onUpdate(index, field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="card-secondary">
|
||||
<div className="space-y-3">
|
||||
{/* Header with item number and delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Item {index + 1}
|
||||
</span>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-icon-red hover:text-error h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Item</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this line item? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(index)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => handleFieldChange("description", e.target.value)}
|
||||
placeholder="Description of work..."
|
||||
className="min-h-[48px] resize-none text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Date, Hours, Rate, Amount in compact grid */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleFieldChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleFieldChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleFieldChange("rate", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Amount</Label>
|
||||
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
||||
<span className="amount-primary">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize form data with defaults
|
||||
const today = new Date();
|
||||
const thirtyDaysFromNow = new Date(today);
|
||||
thirtyDaysFromNow.setDate(today.getDate() + 30);
|
||||
|
||||
// Auto-generate invoice number
|
||||
const generateInvoiceNumber = () => {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const timestamp = Date.now().toString().slice(-4);
|
||||
return `INV-${year}${month}-${timestamp}`;
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||
invoiceNumber: generateInvoiceNumber(),
|
||||
businessId: undefined,
|
||||
clientId: "",
|
||||
issueDate: today,
|
||||
dueDate: thirtyDaysFromNow,
|
||||
notes: "",
|
||||
taxRate: 0,
|
||||
items: [
|
||||
{
|
||||
tempId: `item-${Date.now()}`,
|
||||
date: today,
|
||||
description: "",
|
||||
hours: 0,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Queries
|
||||
const { data: clients, isLoading: clientsLoading } =
|
||||
api.clients.getAll.useQuery();
|
||||
const { data: businesses, isLoading: businessesLoading } =
|
||||
api.businesses.getAll.useQuery();
|
||||
|
||||
// Set default business when data loads
|
||||
useEffect(() => {
|
||||
if (businesses && !formData.businessId) {
|
||||
const defaultBusiness = businesses.find((b) => b.isDefault);
|
||||
if (defaultBusiness) {
|
||||
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
||||
}
|
||||
}
|
||||
}, [businesses, formData.businessId]);
|
||||
|
||||
// Mutations
|
||||
const createInvoice = api.invoices.create.useMutation({
|
||||
onSuccess: (invoice) => {
|
||||
toast.success("Invoice created successfully");
|
||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to create invoice");
|
||||
},
|
||||
});
|
||||
|
||||
const handleItemUpdate = (
|
||||
index: number,
|
||||
field: keyof InvoiceItem,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
const updatedItems = [...formData.items];
|
||||
const currentItem = updatedItems[index];
|
||||
if (currentItem) {
|
||||
updatedItems[index] = { ...currentItem, [field]: value };
|
||||
|
||||
// Recalculate amount for hours or rate changes
|
||||
if (field === "hours" || field === "rate") {
|
||||
const updatedItem = updatedItems[index];
|
||||
if (!updatedItem) return;
|
||||
updatedItem.amount = updatedItem.hours * updatedItem.rate;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({ ...formData, items: updatedItems });
|
||||
};
|
||||
|
||||
const handleItemDelete = (index: number) => {
|
||||
if (formData.items.length === 1) {
|
||||
toast.error("At least one line item is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedItems = formData.items.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, items: updatedItems });
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: InvoiceItem = {
|
||||
tempId: `item-${Date.now()}`,
|
||||
date: new Date(),
|
||||
description: "",
|
||||
hours: 0,
|
||||
rate: 0,
|
||||
amount: 0,
|
||||
};
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [...formData.items, newItem],
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
await handleSave("draft");
|
||||
};
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
await handleSave("sent");
|
||||
};
|
||||
|
||||
const handleSave = async (status: "draft" | "sent") => {
|
||||
// Validation
|
||||
if (!formData.clientId) {
|
||||
toast.error("Please select a client");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.items.length === 0) {
|
||||
toast.error("At least one line item is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all items have required fields
|
||||
const invalidItems = formData.items.some(
|
||||
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
|
||||
);
|
||||
|
||||
if (invalidItems) {
|
||||
toast.error("All line items must have description, hours, and rate");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createInvoice.mutateAsync({
|
||||
...formData,
|
||||
businessId: formData.businessId ?? undefined,
|
||||
status,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
return formData.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
return (calculateSubtotal() * formData.taxRate) / 100;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return calculateSubtotal() + calculateTax();
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
formData.clientId &&
|
||||
formData.items.length > 0 &&
|
||||
formData.items.every(
|
||||
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (clientsLoading || businessesLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Create Invoice"
|
||||
description="Loading form data..."
|
||||
variant="gradient"
|
||||
/>
|
||||
<Card className="card-primary">
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<Loader2 className="text-icon-emerald h-8 w-8 animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title="Create Invoice"
|
||||
description="Fill out the details below to create a new invoice"
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="sm" className="w-full md:w-auto">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span className="hidden md:inline">Back to Invoices</span>
|
||||
<span className="md:hidden">Back</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-secondary">
|
||||
<FileText className="text-icon-emerald h-5 w-5" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Invoice Number</Label>
|
||||
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
||||
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{formData.invoiceNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Issue Date *</Label>
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
issueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Due Date *</Label>
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
dueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business & Client */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Building className="text-icon-emerald h-5 w-5" />
|
||||
Business & Client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">From Business</Label>
|
||||
<div className="relative">
|
||||
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Select
|
||||
value={formData.businessId ?? ""}
|
||||
onValueChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
businessId: value || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="pl-9">
|
||||
<SelectValue placeholder="Select business..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{businesses?.map((business) => (
|
||||
<SelectItem key={business.id} value={business.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{business.name}</span>
|
||||
{business.isDefault && (
|
||||
<Badge className="badge-secondary text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(!businesses || businesses.length === 0) && (
|
||||
<p className="text-icon-red text-sm">
|
||||
No businesses found.{" "}
|
||||
<Link
|
||||
href="/dashboard/businesses/new"
|
||||
className="link-secondary"
|
||||
>
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Client *</Label>
|
||||
<div className="relative">
|
||||
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, clientId: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="pl-9">
|
||||
<SelectValue placeholder="Select client..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
<div>
|
||||
<div className="font-medium">{client.name}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{client.email}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(!clients || clients.length === 0) && (
|
||||
<p className="text-sm text-red-600">
|
||||
No clients found.{" "}
|
||||
<Link
|
||||
href="/dashboard/clients/new"
|
||||
className="underline hover:text-red-700"
|
||||
>
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5 text-emerald-600" />
|
||||
Line Items ({formData.items.length})
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={handleAddItem}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Add Item</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{formData.items.map((item, index) => (
|
||||
<InvoiceItemCard
|
||||
key={item.tempId}
|
||||
item={item}
|
||||
index={index}
|
||||
onUpdate={handleItemUpdate}
|
||||
onDelete={handleItemDelete}
|
||||
_isLast={index === formData.items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tax & Totals */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||
Tax & Totals
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2 md:col-span-1">
|
||||
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
taxRate: value,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
suffix="%"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Notes</Label>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notes: e.target.value })
|
||||
}
|
||||
placeholder="Payment terms, additional notes..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/20 rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-mono font-medium">
|
||||
${calculateSubtotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({formData.taxRate}%):
|
||||
</span>
|
||||
<span className="font-mono font-medium">
|
||||
${calculateTax().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-emerald-600">
|
||||
${calculateTotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
Creating a new invoice
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Complete the form to create your invoice
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
size="sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Cancel</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
variant="outline"
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 md:mr-2" />
|
||||
)}
|
||||
<span className="hidden md:inline">Save Draft</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="btn-brand-primary shadow-md"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
|
||||
) : (
|
||||
<Send className="h-4 w-4 md:mr-2" />
|
||||
)}
|
||||
<span className="hidden md:inline">Create Invoice</span>
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,13 +24,11 @@ import {
|
||||
// Modern gradient background component
|
||||
function DashboardHero({ firstName }: { firstName: string }) {
|
||||
return (
|
||||
<Card className="relative mb-8 overflow-hidden p-8 border-0 shadow-sm transition-shadow hover:shadow-md">
|
||||
<Card className="relative mb-8 overflow-hidden border-0 p-8 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="absolute inset-0" />
|
||||
<div className="relative z-10">
|
||||
<h1 className="mb-2 text-3xl font-bold">Welcome back, {firstName}!</h1>
|
||||
<p className="text-lg">
|
||||
Ready to manage your invoicing business
|
||||
</p>
|
||||
<p className="text-lg">Ready to manage your invoicing business</p>
|
||||
</div>
|
||||
<div className="absolute -top-8 -right-8 h-32 w-32 rounded-full bg-white/10" />
|
||||
<div className="absolute -right-4 -bottom-4 h-24 w-24 rounded-full bg-white/5" />
|
||||
@@ -275,7 +273,7 @@ async function CurrentWork() {
|
||||
size="sm"
|
||||
className="flex-1 bg-teal-600 hover:bg-teal-700"
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Edit className="mr-2 h-3 w-3" />
|
||||
Continue
|
||||
</Link>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function CurrentOpenInvoiceCard() {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="brand" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<Edit className="mr-2 h-3 w-3" />
|
||||
Continue
|
||||
</Link>
|
||||
|
||||
@@ -150,12 +150,12 @@ export function InvoiceList() {
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="truncate">{invoice.invoiceNumber}</span>
|
||||
<div className="flex space-x-1">
|
||||
<Link href={`/invoices/${invoice.id}`}>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/view`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/invoices/${invoice.id}/edit`}>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -134,6 +134,7 @@ export function AddressForm({
|
||||
</Label>
|
||||
{country === "United States" ? (
|
||||
<SearchableSelect
|
||||
key={`state-${state}`}
|
||||
id="state"
|
||||
options={stateOptions}
|
||||
value={state || ""}
|
||||
@@ -190,6 +191,7 @@ export function AddressForm({
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
key={`country-${country}`}
|
||||
id="country"
|
||||
options={countryOptions}
|
||||
value={country || ""}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
@@ -19,259 +17,122 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { FileText, DollarSign, Clock, Save, Check } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { InvoiceLineItems } from "~/components/forms/invoice-line-items";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { InvoiceLineItems } from "./invoice-line-items";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { FileText, DollarSign, Check, Save, Clock } from "lucide-react";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "sent", label: "Sent" },
|
||||
{ value: "paid", label: "Paid" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
] as const;
|
||||
];
|
||||
|
||||
interface InvoiceFormProps {
|
||||
invoiceId?: string;
|
||||
}
|
||||
|
||||
// Custom skeleton for invoice form
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
invoiceNumber: string;
|
||||
businessId: string;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: "draft" | "sent" | "paid" | "overdue";
|
||||
notes: string;
|
||||
taxRate: number;
|
||||
defaultHourlyRate: number;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
function InvoiceFormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="bg-muted/30 h-8 w-48 animate-pulse rounded sm:h-9 sm:w-64"></div>
|
||||
</div>
|
||||
<div className="bg-muted/30 mt-2 h-4 w-36 animate-pulse rounded sm:w-48"></div>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice form"
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left Column - Content with Tabs */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Tabs - Match actual TabsList structure */}
|
||||
<div className="bg-muted text-muted-foreground inline-flex h-9 w-full items-center justify-center rounded-lg p-[3px]">
|
||||
<div className="bg-background h-[calc(100%-1px)] flex-1 rounded-md shadow-sm"></div>
|
||||
<div className="bg-muted/30 h-[calc(100%-1px)] flex-1 rounded-md"></div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Details Card */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-6 w-32 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* First row - stacked on mobile */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-18 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Third row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-24 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status field */}
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded sm:w-48"></div>
|
||||
</div>
|
||||
|
||||
{/* Notes field */}
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-20 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-6 w-28 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Line item skeleton */}
|
||||
<div className="space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-muted/30 h-5 w-20 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-8 w-8 animate-pulse rounded"></div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-16 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
|
||||
{/* Date, Hours, Rate - stacked on mobile */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-12 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-8 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount display */}
|
||||
<div className="bg-muted/30 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-muted/50 h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/50 h-6 w-20 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add item button */}
|
||||
<div className="bg-muted/30 h-10 w-full animate-pulse rounded"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="bg-muted h-96 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Summary */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-6 w-16 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Totals */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="bg-muted/30 h-4 w-12 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<div className="bg-muted/30 h-5 w-12 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-5 w-24 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="bg-muted/30 h-3 w-12 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-4 w-8 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="bg-muted/30 h-3 w-16 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-4 w-12 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Bar Skeleton - Mobile only */}
|
||||
<div className="fixed right-4 bottom-6 left-4 lg:hidden">
|
||||
<div className="bg-background rounded-lg border p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="bg-muted/30 h-9 flex-1 animate-pulse rounded"></div>
|
||||
<div className="bg-muted/30 h-9 w-20 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted h-64 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<FormData>({
|
||||
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 = <K extends keyof FormData>(
|
||||
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 <InvoiceFormSkeleton />;
|
||||
}
|
||||
|
||||
@@ -555,15 +414,18 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<>
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title={invoiceId ? "Edit Invoice" : "Create Invoice"}
|
||||
title={
|
||||
invoiceId && invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"
|
||||
}
|
||||
description={
|
||||
invoiceId ? "Update invoice details" : "Create a new invoice"
|
||||
invoiceId && invoiceId !== "new"
|
||||
? "Update invoice details"
|
||||
: "Create a new invoice"
|
||||
}
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
form="invoice-form"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
>
|
||||
@@ -581,10 +443,8 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Form Content */}
|
||||
<form id="invoice-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left Column - Content with Tabs */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Tabs defaultValue="invoice-details" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
@@ -593,8 +453,8 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invoice-items">Invoice Items</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="invoice-details">
|
||||
{/* Invoice Details */}
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -617,16 +477,10 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
key={`status-${formData.status}`}
|
||||
value={formData.status}
|
||||
onValueChange={(
|
||||
value: "draft" | "sent" | "paid" | "overdue",
|
||||
) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
status: value,
|
||||
}))
|
||||
}
|
||||
) => updateField("status", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -642,11 +496,6 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{invoiceId && hasOnlyStatusChanged && (
|
||||
<div className="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
Only status will be updated
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -656,10 +505,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
issueDate: date ?? new Date(),
|
||||
}))
|
||||
updateField("issueDate", date ?? new Date())
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -669,10 +515,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
dueDate: date ?? new Date(),
|
||||
}))
|
||||
updateField("dueDate", date ?? new Date())
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -683,13 +526,9 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business">From (Business)</Label>
|
||||
<Select
|
||||
key={`business-${formData.businessId}-${businesses?.length}`}
|
||||
value={formData.businessId}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
businessId: value,
|
||||
}))
|
||||
updateField("businessId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -710,13 +549,9 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client">Bill To (Client)</Label>
|
||||
<Select
|
||||
key={`client-${formData.clientId}-${clients?.length}`}
|
||||
value={formData.clientId}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
clientId: value,
|
||||
}))
|
||||
updateField("clientId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -738,12 +573,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<Label htmlFor="taxRate">Tax Rate (%)</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
taxRate: value,
|
||||
}))
|
||||
}
|
||||
onChange={(value) => updateField("taxRate", value)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -758,10 +588,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<NumberInput
|
||||
value={formData.defaultHourlyRate}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
defaultHourlyRate: value,
|
||||
}))
|
||||
updateField("defaultHourlyRate", value)
|
||||
}
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -776,12 +603,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
notes: e.target.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
placeholder="Additional notes for the client..."
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
@@ -789,6 +611,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invoice-items">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
@@ -813,7 +636,6 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Summary (Always Visible) */}
|
||||
<div className="space-y-6">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader>
|
||||
@@ -878,7 +700,6 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Bar */}
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -887,7 +708,9 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{invoiceId ? "Edit Invoice" : "Create Invoice"}
|
||||
{invoiceId && invoiceId !== "new"
|
||||
? "Edit Invoice"
|
||||
: "Create Invoice"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Update invoice details
|
||||
@@ -897,8 +720,7 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
form="invoice-form"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
size="sm"
|
||||
@@ -919,5 +741,3 @@ function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { InvoiceForm };
|
||||
|
||||
@@ -18,12 +18,16 @@ const invoiceItemSchema = z.object({
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
businessId: z.string().min(1, "Business is required").optional(),
|
||||
businessId: z
|
||||
.string()
|
||||
.min(1, "Business is required")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
clientId: z.string().min(1, "Client is required"),
|
||||
issueDate: z.date(),
|
||||
dueDate: z.date(),
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]).default("draft"),
|
||||
notes: z.string().optional(),
|
||||
notes: z.string().optional().or(z.literal("")),
|
||||
taxRate: z.number().min(0).max(100).default(0),
|
||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
@@ -136,7 +140,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
const { items, ...invoiceData } = input;
|
||||
|
||||
// Verify business exists and belongs to user (if provided)
|
||||
if (invoiceData.businessId) {
|
||||
if (invoiceData.businessId && invoiceData.businessId.trim() !== "") {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
});
|
||||
@@ -229,6 +233,14 @@ export const invoicesRouter = createTRPCRouter({
|
||||
try {
|
||||
const { id, items, ...invoiceData } = input;
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Verify invoice exists and belongs to user
|
||||
const existingInvoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, id),
|
||||
@@ -249,9 +261,9 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// If business is being updated, verify it belongs to user
|
||||
if (invoiceData.businessId) {
|
||||
if (cleanInvoiceData.businessId && cleanInvoiceData.businessId.trim() !== "") {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: eq(businesses.id, invoiceData.businessId),
|
||||
where: eq(businesses.id, cleanInvoiceData.businessId),
|
||||
});
|
||||
|
||||
if (!business || business.createdById !== ctx.session.user.id) {
|
||||
@@ -263,9 +275,9 @@ export const invoicesRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// If client is being updated, verify it belongs to user
|
||||
if (invoiceData.clientId) {
|
||||
if (cleanInvoiceData.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: eq(clients.id, invoiceData.clientId),
|
||||
where: eq(clients.id, cleanInvoiceData.clientId),
|
||||
});
|
||||
|
||||
if (!client || client.createdById !== ctx.session.user.id) {
|
||||
@@ -283,18 +295,29 @@ export const invoicesRouter = createTRPCRouter({
|
||||
0,
|
||||
);
|
||||
const taxAmount =
|
||||
(subtotal * (invoiceData.taxRate ?? existingInvoice.taxRate)) / 100;
|
||||
(subtotal * (cleanInvoiceData.taxRate ?? existingInvoice.taxRate)) /
|
||||
100;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
|
||||
// Update invoice
|
||||
await ctx.db
|
||||
const updateData = {
|
||||
...cleanInvoiceData,
|
||||
totalAmount,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [updatedInvoice] = await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
...invoiceData,
|
||||
totalAmount,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id));
|
||||
.set(updateData)
|
||||
.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
|
||||
@@ -311,13 +334,23 @@ export const invoicesRouter = createTRPCRouter({
|
||||
await ctx.db.insert(invoiceItems).values(itemsToInsert);
|
||||
} else {
|
||||
// Update invoice without items
|
||||
await ctx.db
|
||||
const updateData = {
|
||||
...cleanInvoiceData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [updatedInvoice] = await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
...invoiceData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, id));
|
||||
.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 };
|
||||
@@ -399,8 +432,11 @@ export const invoicesRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(invoices.id, input.id));
|
||||
|
||||
console.log("Status update completed successfully");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("UpdateStatus error:", error);
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
||||
Reference in New Issue
Block a user