diff --git a/src/app/clients/[id]/edit/page.tsx b/src/app/clients/[id]/edit/page.tsx
deleted file mode 100644
index e30d148..0000000
--- a/src/app/clients/[id]/edit/page.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { auth } from "~/server/auth";
-import { HydrateClient } from "~/trpc/server";
-import { Button } from "~/components/ui/button";
-import { ClientForm } from "~/components/forms/client-form";
-import Link from "next/link";
-
-interface EditClientPageProps {
- params: Promise<{
- id: string;
- }>;
-}
-
-export default async function EditClientPage({ params }: EditClientPageProps) {
- const { id } = await params;
- const session = await auth();
-
- if (!session?.user) {
- return (
-
-
-
Access Denied
-
- Please sign in to edit clients
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
Edit Client
-
Update client information
-
-
-
-
- );
-}
diff --git a/src/app/clients/layout.tsx b/src/app/clients/layout.tsx
deleted file mode 100644
index d9e6f7d..0000000
--- a/src/app/clients/layout.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Navbar } from "~/components/layout/navbar";
-import { Sidebar } from "~/components/layout/sidebar";
-
-export default function ClientsLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return (
- <>
-
-
-
-
- {children}
-
-
- >
- );
-}
diff --git a/src/app/clients/new/page.tsx b/src/app/clients/new/page.tsx
deleted file mode 100644
index 34090da..0000000
--- a/src/app/clients/new/page.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { auth } from "~/server/auth";
-import { HydrateClient } from "~/trpc/server";
-import { Button } from "~/components/ui/button";
-import { ClientForm } from "~/components/forms/client-form";
-import Link from "next/link";
-
-export default async function NewClientPage() {
- const session = await auth();
-
- if (!session?.user) {
- return (
-
-
-
Access Denied
-
Please sign in to create clients
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
Add New Client
-
- Create a new client profile
-
-
-
-
-
- );
-}
diff --git a/src/app/clients/page.tsx b/src/app/clients/page.tsx
deleted file mode 100644
index f08f938..0000000
--- a/src/app/clients/page.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import Link from "next/link";
-import { auth } from "~/server/auth";
-import { api, HydrateClient } from "~/trpc/server";
-import { Button } from "~/components/ui/button";
-import { ClientList } from "~/components/data/client-list";
-import { Plus } from "lucide-react";
-
-export default async function ClientsPage() {
- const session = await auth();
-
- if (!session?.user) {
- return (
-
-
-
Access Denied
-
Please sign in to view clients
-
-
-
-
-
- );
- }
-
- // Prefetch clients data
- void api.clients.getAll.prefetch();
-
- return (
-
-
-
-
Clients
-
- Manage your client relationships
-
-
-
-
-
-
- );
-}
diff --git a/src/app/dashboard/_components/status-manager.tsx b/src/app/dashboard/_components/status-manager.tsx
new file mode 100644
index 0000000..da3db2d
--- /dev/null
+++ b/src/app/dashboard/_components/status-manager.tsx
@@ -0,0 +1,343 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "~/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Badge } from "~/components/ui/badge";
+import { toast } from "sonner";
+import { api } from "~/trpc/react";
+import {
+ Send,
+ DollarSign,
+ FileText,
+ AlertCircle,
+ Clock,
+ CheckCircle,
+ RefreshCw,
+ Calendar,
+ Loader2,
+} from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "~/components/ui/alert-dialog";
+import {
+ getEffectiveInvoiceStatus,
+ isInvoiceOverdue,
+ getDaysPastDue,
+ getStatusConfig,
+} from "~/lib/invoice-status";
+import type { StoredInvoiceStatus } from "~/types/invoice";
+
+interface StatusManagerProps {
+ invoiceId: string;
+ currentStatus: StoredInvoiceStatus;
+ dueDate: Date;
+ clientEmail?: string | null;
+ onStatusChange?: () => void;
+}
+
+const statusIconConfig = {
+ draft: FileText,
+ sent: Send,
+ paid: CheckCircle,
+ overdue: AlertCircle,
+};
+
+export function StatusManager({
+ invoiceId,
+ currentStatus,
+ dueDate,
+ clientEmail,
+ onStatusChange,
+}: StatusManagerProps) {
+ const [isChangingStatus, setIsChangingStatus] = useState(false);
+ const utils = api.useUtils();
+
+ const updateStatus = api.invoices.updateStatus.useMutation({
+ onSuccess: (data) => {
+ toast.success(data.message);
+ void utils.invoices.getById.invalidate({ id: invoiceId });
+ void utils.invoices.getAll.invalidate();
+ onStatusChange?.();
+ setIsChangingStatus(false);
+ },
+ onError: (error) => {
+ toast.error(error.message ?? "Failed to update status");
+ setIsChangingStatus(false);
+ },
+ });
+
+ const sendEmail = api.email.sendInvoice.useMutation({
+ onSuccess: (data) => {
+ toast.success(data.message);
+ void utils.invoices.getById.invalidate({ id: invoiceId });
+ void utils.invoices.getAll.invalidate();
+ onStatusChange?.();
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ });
+
+ const handleStatusUpdate = async (newStatus: StoredInvoiceStatus) => {
+ setIsChangingStatus(true);
+ updateStatus.mutate({
+ id: invoiceId,
+ status: newStatus,
+ });
+ };
+
+ const handleSendEmail = () => {
+ sendEmail.mutate({ invoiceId });
+ };
+
+ const effectiveStatus = getEffectiveInvoiceStatus(currentStatus, dueDate);
+ const isOverdue = isInvoiceOverdue(currentStatus, dueDate);
+ const daysPastDue = getDaysPastDue(currentStatus, dueDate);
+ const statusConfig = getStatusConfig(currentStatus, dueDate);
+
+ const StatusIcon = statusIconConfig[effectiveStatus];
+
+ const getAvailableActions = () => {
+ const actions = [];
+
+ switch (effectiveStatus) {
+ case "draft":
+ if (clientEmail) {
+ actions.push({
+ key: "send",
+ label: "Send Invoice",
+ action: handleSendEmail,
+ variant: "default" as const,
+ icon: Send,
+ disabled: sendEmail.isPending,
+ });
+ }
+ actions.push({
+ key: "markPaid",
+ label: "Mark as Paid",
+ action: () => handleStatusUpdate("paid"),
+ variant: "secondary" as const,
+ icon: DollarSign,
+ disabled: isChangingStatus,
+ });
+ break;
+
+ case "sent":
+ actions.push({
+ key: "markPaid",
+ label: "Mark as Paid",
+ action: () => handleStatusUpdate("paid"),
+ variant: "default" as const,
+ icon: DollarSign,
+ disabled: isChangingStatus,
+ });
+ if (clientEmail) {
+ actions.push({
+ key: "resend",
+ label: "Resend Invoice",
+ action: handleSendEmail,
+ variant: "outline" as const,
+ icon: Send,
+ disabled: sendEmail.isPending,
+ });
+ }
+ actions.push({
+ key: "backToDraft",
+ label: "Back to Draft",
+ action: () => handleStatusUpdate("draft"),
+ variant: "outline" as const,
+ icon: FileText,
+ disabled: isChangingStatus,
+ });
+ break;
+
+ case "overdue":
+ actions.push({
+ key: "markPaid",
+ label: "Mark as Paid",
+ action: () => handleStatusUpdate("paid"),
+ variant: "default" as const,
+ icon: DollarSign,
+ disabled: isChangingStatus,
+ });
+ if (clientEmail) {
+ actions.push({
+ key: "resend",
+ label: "Resend Invoice",
+ action: handleSendEmail,
+ variant: "outline" as const,
+ icon: Send,
+ disabled: sendEmail.isPending,
+ });
+ }
+ actions.push({
+ key: "backToSent",
+ label: "Mark as Sent",
+ action: () => handleStatusUpdate("sent"),
+ variant: "outline" as const,
+ icon: Clock,
+ disabled: isChangingStatus,
+ });
+ break;
+
+ case "paid":
+ // Paid invoices can be reverted if needed (rare cases)
+ actions.push({
+ key: "revert",
+ label: "Revert to Sent",
+ action: () => handleStatusUpdate("sent"),
+ variant: "outline" as const,
+ icon: RefreshCw,
+ disabled: isChangingStatus,
+ requireConfirmation: true,
+ });
+ break;
+ }
+
+ return actions;
+ };
+
+ const actions = getAvailableActions();
+
+ return (
+
+
+
+
+ Invoice Status
+
+
+
+ {/* Current Status Display */}
+
+
+ {statusConfig.label}
+
+
+ {statusConfig.description}
+
+
+
+ {/* Overdue Warning */}
+ {isOverdue && (
+
+
+
+ {daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue
+
+
+ )}
+
+ {/* Due Date Info */}
+
+
+
+ Due:{" "}
+ {new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(dueDate))}
+
+
+
+ {/* Action Buttons */}
+ {actions.length > 0 && (
+
+
+ Available Actions:
+
+
+ {actions.map((action) => {
+ const ActionIcon = action.icon;
+
+ if (action.requireConfirmation) {
+ return (
+
+
+
+
+
+
+
+ Confirm Status Change
+
+
+ Are you sure you want to change this invoice status?
+ This action may affect your records.
+
+
+
+ Cancel
+
+ Confirm
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {/* No Email Warning */}
+ {!clientEmail && effectiveStatus !== "paid" && (
+
+
+
+
+ No email address on file for this client
+
+
+
+ Add an email address to the client to enable sending invoices.
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/dashboard/businesses/_components/businesses-data-table.tsx b/src/app/dashboard/businesses/_components/businesses-data-table.tsx
index 59a1f56..da01c9f 100644
--- a/src/app/dashboard/businesses/_components/businesses-data-table.tsx
+++ b/src/app/dashboard/businesses/_components/businesses-data-table.tsx
@@ -226,8 +226,8 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
Are you sure?
This action cannot be undone. This will permanently delete the
- business "{businessToDelete?.name}" and remove all associated
- data.
+ business "{businessToDelete?.name}" and remove all
+ associated data.
diff --git a/src/app/dashboard/businesses/new/page.tsx b/src/app/dashboard/businesses/new/page.tsx
index ffdccd5..e2b5eb9 100644
--- a/src/app/dashboard/businesses/new/page.tsx
+++ b/src/app/dashboard/businesses/new/page.tsx
@@ -1,20 +1,10 @@
-import Link from "next/link";
import { BusinessForm } from "~/components/forms/business-form";
-import { PageHeader } from "~/components/layout/page-header";
import { HydrateClient } from "~/trpc/server";
export default function NewBusinessPage() {
return (
-
+
+
+
);
}
diff --git a/src/app/dashboard/businesses/page.tsx b/src/app/dashboard/businesses/page.tsx
index a3cdbc9..b065957 100644
--- a/src/app/dashboard/businesses/page.tsx
+++ b/src/app/dashboard/businesses/page.tsx
@@ -1,11 +1,11 @@
+import { Plus } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
-import { api, HydrateClient } from "~/trpc/server";
-import { Button } from "~/components/ui/button";
-import { Plus, Building } from "lucide-react";
-import { BusinessesDataTable } from "./_components/businesses-data-table";
-import { PageHeader } from "~/components/layout/page-header";
import { DataTableSkeleton } from "~/components/data/data-table";
+import { PageHeader } from "~/components/layout/page-header";
+import { Button } from "~/components/ui/button";
+import { api, HydrateClient } from "~/trpc/server";
+import { BusinessesDataTable } from "./_components/businesses-data-table";
// Businesses Table Component
async function BusinessesTable() {
diff --git a/src/app/dashboard/clients/[id]/page.tsx b/src/app/dashboard/clients/[id]/page.tsx
index 9f6a8a4..744be0a 100644
--- a/src/app/dashboard/clients/[id]/page.tsx
+++ b/src/app/dashboard/clients/[id]/page.tsx
@@ -15,6 +15,8 @@ import {
DollarSign,
ArrowLeft,
} from "lucide-react";
+import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
+import type { StoredInvoiceStatus } from "~/types/invoice";
interface ClientDetailPageProps {
params: Promise<{ id: string }>;
@@ -222,7 +224,7 @@ export default async function ClientDetailPage({
{client.invoices.slice(0, 3).map((invoice) => (
@@ -238,15 +240,29 @@ export default async function ClientDetailPage({
- {invoice.status}
+ {getEffectiveInvoiceStatus(
+ invoice.status as StoredInvoiceStatus,
+ invoice.dueDate,
+ )}
diff --git a/src/app/dashboard/clients/_components/clients-data-table.tsx b/src/app/dashboard/clients/_components/clients-data-table.tsx
index d10b7c8..2c0426b 100644
--- a/src/app/dashboard/clients/_components/clients-data-table.tsx
+++ b/src/app/dashboard/clients/_components/clients-data-table.tsx
@@ -96,7 +96,7 @@ export function ClientsDataTable({
{client.name}
- {client.email || "—"}
+ {client.email ?? "—"}
@@ -108,7 +108,7 @@ export function ClientsDataTable({
header: ({ column }) => (
),
- cell: ({ row }) => row.original.phone || "—",
+ cell: ({ row }) => row.original.phone ?? "—",
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
@@ -148,9 +148,9 @@ export function ClientsDataTable({
return (
-
diff --git a/src/app/dashboard/invoices/[id]/send/page.tsx b/src/app/dashboard/invoices/[id]/send/page.tsx
new file mode 100644
index 0000000..af496a9
--- /dev/null
+++ b/src/app/dashboard/invoices/[id]/send/page.tsx
@@ -0,0 +1,511 @@
+"use client";
+
+import { useState, useEffect, useMemo } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { Button } from "~/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import { Badge } from "~/components/ui/badge";
+import { Separator } from "~/components/ui/separator";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import { Label } from "~/components/ui/label";
+import { PageHeader } from "~/components/layout/page-header";
+import { FloatingActionBar } from "~/components/layout/floating-action-bar";
+import { EmailComposer } from "~/components/forms/email-composer";
+import { EmailPreview } from "~/components/forms/email-preview";
+import { api } from "~/trpc/react";
+import { toast } from "sonner";
+import {
+ Mail,
+ Send,
+ Eye,
+ Edit3,
+ AlertTriangle,
+ ArrowLeft,
+ Loader2,
+ FileText,
+} from "lucide-react";
+
+function SendEmailPageSkeleton() {
+ return (
+
+ );
+}
+
+export default function SendEmailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const invoiceId = params.id as string;
+
+ // State management
+ const [activeTab, setActiveTab] = useState("compose");
+ const [isSending, setIsSending] = useState(false);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ // Email content state
+ const [subject, setSubject] = useState("");
+ const [emailContent, setEmailContent] = useState("");
+ const [ccEmail, setCcEmail] = useState("");
+ const [bccEmail, setBccEmail] = useState("");
+ const [customMessage, setCustomMessage] = useState("");
+
+ // Fetch invoice data
+ const { data: invoiceData, isLoading: invoiceLoading } =
+ api.invoices.getById.useQuery({
+ id: invoiceId,
+ });
+
+ // Get utils for cache invalidation
+ const utils = api.useUtils();
+
+ // Email sending mutation
+ const sendEmailMutation = api.email.sendInvoice.useMutation({
+ onSuccess: (data) => {
+ toast.success("Email sent successfully!", {
+ description: data.message,
+ duration: 5000,
+ });
+
+ // Navigate back to invoice view
+ router.push(`/dashboard/invoices/${invoiceId}/view`);
+
+ // Refresh invoice data
+ void utils.invoices.getById.invalidate({ id: invoiceId });
+ },
+ onError: (error) => {
+ console.error("Email send error:", error);
+
+ let errorMessage = "Failed to send invoice email";
+ let errorDescription = error.message;
+
+ if (error.message.includes("Invalid recipient")) {
+ errorMessage = "Invalid Email Address";
+ errorDescription =
+ "Please check the client's email address and try again.";
+ } else if (error.message.includes("domain not verified")) {
+ errorMessage = "Email Configuration Issue";
+ errorDescription = "Please contact support to configure email sending.";
+ } else if (error.message.includes("rate limit")) {
+ errorMessage = "Too Many Emails";
+ errorDescription = "Please wait a moment before sending another email.";
+ } else if (error.message.includes("no email address")) {
+ errorMessage = "No Email Address";
+ errorDescription = "This client doesn't have an email address on file.";
+ }
+
+ toast.error(errorMessage, {
+ description: errorDescription,
+ duration: 6000,
+ });
+
+ setIsSending(false);
+ },
+ });
+
+ // Transform invoice data for components
+ const invoice = useMemo(() => {
+ return invoiceData
+ ? {
+ id: invoiceData.id,
+ invoiceNumber: invoiceData.invoiceNumber,
+ issueDate: invoiceData.issueDate,
+ dueDate: invoiceData.dueDate,
+ status: invoiceData.status,
+ taxRate: invoiceData.taxRate,
+ client: invoiceData.client
+ ? {
+ name: invoiceData.client.name,
+ email: invoiceData.client.email,
+ }
+ : undefined,
+ business: invoiceData.business
+ ? {
+ name: invoiceData.business.name,
+ email: invoiceData.business.email,
+ }
+ : undefined,
+ items: invoiceData.items?.map((item) => ({
+ id: item.id,
+ hours: item.hours,
+ rate: item.rate,
+ })),
+ }
+ : undefined;
+ }, [invoiceData]);
+
+ // Initialize email content when invoice loads
+ useEffect(() => {
+ if (!invoice || isInitialized) return;
+
+ // Set default subject
+ const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
+ setSubject(defaultSubject);
+
+ // Set default content (empty since template handles everything)
+ const defaultContent = ``;
+
+ setEmailContent(defaultContent);
+ setIsInitialized(true);
+ }, [invoice, isInitialized]);
+
+ const handleSendEmail = async () => {
+ if (!invoice?.client?.email || invoice.client.email.trim() === "") {
+ toast.error("No email address", {
+ description: "This client doesn't have an email address on file.",
+ });
+ return;
+ }
+
+ if (!subject.trim()) {
+ toast.error("Subject required", {
+ description: "Please enter an email subject before sending.",
+ });
+ return;
+ }
+
+ // Email content is now optional since template handles default messaging
+
+ setIsSending(true);
+
+ try {
+ await sendEmailMutation.mutateAsync({
+ invoiceId,
+ customSubject: subject,
+ customContent: emailContent,
+ customMessage: customMessage?.trim() || undefined,
+ useHtml: true,
+ ccEmails: ccEmail.trim() || undefined,
+ bccEmails: bccEmail.trim() || undefined,
+ });
+ } catch (error) {
+ // Error handling is done in the mutation's onError
+ console.error("Send email error:", error);
+ }
+ };
+
+ const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
+ const toEmail = invoice?.client?.email ?? "";
+
+ const canSend =
+ !isSending && subject.trim() && toEmail && toEmail.trim() !== "";
+
+ if (invoiceLoading) {
+ return
;
+ }
+
+ if (!invoice) {
+ return (
+
+
+
+ Invoice not found.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {/* Warning for missing email */}
+ {(!toEmail || toEmail.trim() === "") && (
+
+
+
+ This client doesn't have an email address. Please add an email
+ address to the client before sending the invoice.
+
+
+ )}
+
+ {/* Main Content */}
+
+
+
+
+
+
+ Compose
+
+
+
+ Preview
+
+
+
+
+
+
+
+
+
+ Compose Email
+
+
+
+ {isInitialized ? (
+
+ ) : (
+
+
+
+
+ Initializing email content...
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ Email Preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Sidebar */}
+
+ {/* Invoice Summary */}
+
+
+
+
+ Invoice #{invoice.invoiceNumber}
+
+
+
+
+
+
+ {invoice.client?.name ?? "Client"}
+
+
+
+
+
+ {new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(invoice.issueDate))}
+
+
+
+
+ {invoice.status}
+
+
+
+
+
+
+ Email Details
+
+
+
+
+
{fromEmail}
+
+
+
+
+ {toEmail || "No email address"}
+
+
+ {ccEmail && (
+
+ )}
+ {bccEmail && (
+
+ )}
+
+
+
{subject || "No subject"}
+
+
+
+
+
+
+ invoice-{invoice.invoiceNumber}.pdf
+
+
+
+
+
+
+
+ Actions
+
+
+ {activeTab === "compose" && (
+
+ )}
+
+ {activeTab === "preview" && (
+
+ )}
+
+
+
+
+
+ {/* Floating Action Bar */}
+
+
+
+
+
+
+ Send Invoice
+
+
+ Email invoice to {invoice.client?.name ?? "client"}
+
+
+
+ }
+ >
+
+
+
+
+
+ );
+}
diff --git a/src/app/dashboard/invoices/[id]/view/page.tsx b/src/app/dashboard/invoices/[id]/view/page.tsx
index 619b0e9..2028c2b 100644
--- a/src/app/dashboard/invoices/[id]/view/page.tsx
+++ b/src/app/dashboard/invoices/[id]/view/page.tsx
@@ -1,17 +1,14 @@
"use client";
-import { useState } from "react";
-import { notFound, useRouter, useParams } from "next/navigation";
+import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
-import { api } from "~/trpc/react";
-import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
-import { Button } from "~/components/ui/button";
+import { notFound, useParams, useRouter } from "next/navigation";
+import { useState } from "react";
+import { toast } from "sonner";
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 { Button } from "~/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Dialog,
DialogContent,
@@ -20,19 +17,26 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
-import { toast } from "sonner";
+import { Separator } from "~/components/ui/separator";
+import {
+ getEffectiveInvoiceStatus,
+ isInvoiceOverdue,
+} from "~/lib/invoice-status";
+import { api } from "~/trpc/react";
+import type { StoredInvoiceStatus } from "~/types/invoice";
+import { InvoiceDetailsSkeleton } from "../_components/invoice-details-skeleton";
+import { PDFDownloadButton } from "../_components/pdf-download-button";
+import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
import {
+ AlertTriangle,
Building,
- Edit,
+ Check,
FileText,
Mail,
MapPin,
Phone,
User,
- AlertTriangle,
- Check,
- Trash2,
} from "lucide-react";
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
@@ -42,8 +46,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
id: invoiceId,
});
+ const utils = api.useUtils();
- // Delete mutation
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
@@ -54,10 +58,27 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
},
});
+ const updateStatus = api.invoices.updateStatus.useMutation({
+ onSuccess: (data) => {
+ toast.success(data.message);
+ void utils.invoices.getById.invalidate({ id: invoiceId });
+ },
+ onError: (error) => {
+ toast.error(error.message ?? "Failed to update invoice status");
+ },
+ });
+
const handleDelete = () => {
setDeleteDialogOpen(true);
};
+ const handleMarkAsPaid = () => {
+ updateStatus.mutate({
+ id: invoiceId,
+ status: "paid" as StoredInvoiceStatus,
+ });
+ };
+
const confirmDelete = () => {
deleteInvoice.mutate({ id: invoiceId });
};
@@ -88,17 +109,17 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
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 effectiveStatus = getEffectiveInvoiceStatus(
+ invoice.status as StoredInvoiceStatus,
+ invoice.dueDate,
+ );
+ const isOverdue = isInvoiceOverdue(
+ invoice.status as StoredInvoiceStatus,
+ invoice.dueDate,
+ );
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 effectiveStatus as StatusType;
};
return (
@@ -401,8 +422,38 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
)}
- {invoice.status === "draft" && (
-
+ {/* Send Invoice Button - Show for draft, sent, and overdue */}
+ {effectiveStatus === "draft" && (
+
+ )}
+
+ {(effectiveStatus === "sent" ||
+ effectiveStatus === "overdue") && (
+
+ )}
+
+ {/* Manual Status Updates */}
+ {(effectiveStatus === "sent" ||
+ effectiveStatus === "overdue") && (
+
)}
diff --git a/src/app/demo/table-layout/page.tsx b/src/app/demo/table-layout/page.tsx
deleted file mode 100644
index 2da2836..0000000
--- a/src/app/demo/table-layout/page.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { DataTable } from "~/components/data/data-table";
-import { PageHeader } from "~/components/layout/page-header";
-import { Button } from "~/components/ui/button";
-import { Plus } from "lucide-react";
-import type { ColumnDef } from "@tanstack/react-table";
-import { DataTableColumnHeader } from "~/components/data/data-table";
-import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
-import Link from "next/link";
-
-// Sample data type
-interface DemoItem {
- id: string;
- name: string;
- email: string;
- phone: string;
- status: string;
- createdAt: Date;
-}
-
-// Generate sample data
-const sampleData: DemoItem[] = Array.from({ length: 50 }, (_, i) => ({
- id: `item-${i + 1}`,
- name: `Item ${i + 1}`,
- email: `item${i + 1}@example.com`,
- phone: `555-${String(Math.floor(Math.random() * 9000) + 1000)}`,
- status: ["active", "pending", "inactive"][
- Math.floor(Math.random() * 3)
- ] as string,
- createdAt: new Date(Date.now() - Math.random() * 10000000000),
-}));
-
-// Define columns with responsive behavior
-const columns: ColumnDef