mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
feat: improve invoice view responsiveness and settings UX
- Replace custom invoice items table with responsive DataTable component - Fix server/client component error by creating InvoiceItemsTable client component - Merge danger zone with actions sidebar and use destructive button variant - Standardize button text sizing across all action buttons - Remove false claims from homepage (testimonials, ratings, fake user counts) - Focus homepage messaging on freelancers with honest feature descriptions - Fix dark mode support throughout app by replacing hard-coded colors with semantic classes - Remove aggressive red styling from settings, add subtle red accents only - Align import/export buttons and improve delete confirmation UX - Update dark mode background to have subtle green tint instead of pure black - Fix HTML nesting error in AlertDialog by using div instead of nested p tags This update makes the invoice view properly responsive, removes misleading marketing claims, and ensures consistent dark mode support across the entire application.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Copy,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoiceActionsDropdownProps {
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
export function InvoiceActionsDropdown({ invoiceId }: InvoiceActionsDropdownProps) {
|
||||
const handleSendClick = () => {
|
||||
const sendButton = document.querySelector(
|
||||
"[data-testid='send-invoice-button']",
|
||||
) as HTMLButtonElement;
|
||||
sendButton?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSendClick}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send to Client
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
export function InvoiceDetailsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 xl:col-span-2">
|
||||
{/* Invoice Header Skeleton */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Skeleton className="h-6 w-48 sm:h-8" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
<Skeleton className="mt-1 h-4 w-64" />
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="mt-1 h-6 w-24 sm:h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client & Business Information Skeleton */}
|
||||
<div className="grid gap-4 sm:gap-6 lg:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<Skeleton className="h-5 w-32 sm:h-6" />
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div key={j} className="flex items-center gap-2 sm:gap-3">
|
||||
<Skeleton className="h-6 w-6 rounded-lg sm:h-8 sm:w-8" />
|
||||
<Skeleton className="h-3 w-28 sm:h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items Skeleton */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Skeleton className="h-5 w-28 sm:h-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{["Date", "Description", "Hours", "Rate", "Amount"].map(
|
||||
(header) => (
|
||||
<th key={header} className="p-2 text-left sm:p-4">
|
||||
<Skeleton className="h-3 w-16 sm:h-4" />
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="p-2 sm:p-4">
|
||||
<Skeleton className="h-3 w-20 sm:h-4" />
|
||||
</td>
|
||||
<td className="p-2 sm:p-4">
|
||||
<Skeleton className="h-3 w-48 sm:h-4" />
|
||||
</td>
|
||||
<td className="p-2 sm:p-4">
|
||||
<Skeleton className="h-3 w-12 sm:h-4" />
|
||||
</td>
|
||||
<td className="p-2 sm:p-4">
|
||||
<Skeleton className="h-3 w-16 sm:h-4" />
|
||||
</td>
|
||||
<td className="p-2 sm:p-4">
|
||||
<Skeleton className="h-3 w-20 sm:h-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals Section Skeleton */}
|
||||
<div className="bg-muted/20 border-t p-3 sm:p-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full max-w-64 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-16 sm:h-4" />
|
||||
<Skeleton className="h-3 w-20 sm:h-4" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-20 sm:h-4" />
|
||||
<Skeleton className="h-3 w-20 sm:h-4" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-12 sm:h-6" />
|
||||
<Skeleton className="h-4 w-24 sm:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes Skeleton */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-5 w-16 sm:h-6" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full sm:h-4" />
|
||||
<Skeleton className="h-3 w-3/4 sm:h-4" />
|
||||
<Skeleton className="h-3 w-1/2 sm:h-4" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Skeleton */}
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Actions Skeleton */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-5 w-16 sm:h-6" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 sm:space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full sm:h-10" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Details Skeleton */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Skeleton className="h-5 w-16 sm:h-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 sm:space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<Skeleton className="h-3 w-16 sm:h-4" />
|
||||
<Skeleton className="h-3 w-20 sm:h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone Skeleton */}
|
||||
<Card className="border-red-200 shadow-sm dark:border-red-800">
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-5 w-24 sm:h-6" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-full sm:h-10" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// Type for invoice item data
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface InvoiceItemsTableProps {
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date",
|
||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("description")}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "hours",
|
||||
header: "Hours",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{row.getValue("hours")}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "rate",
|
||||
header: "Rate",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">{formatCurrency(row.getValue("rate"))}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-medium text-emerald-600">
|
||||
{formatCurrency(row.getValue("amount"))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function InvoiceItemsTable({ items }: InvoiceItemsTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
showSearch={false}
|
||||
showColumnVisibility={false}
|
||||
showPagination={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,84 +3,43 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
notes?: string | null;
|
||||
business?: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
website?: string | null;
|
||||
taxId?: string | null;
|
||||
} | null;
|
||||
client: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
};
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PDFDownloadButtonProps {
|
||||
invoice: Invoice;
|
||||
variant?: "button" | "menu" | "icon";
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PDFDownloadButton({
|
||||
invoice,
|
||||
variant = "button",
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
}: PDFDownloadButtonProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Fetch invoice data when PDF generation is triggered
|
||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
||||
{ id: invoiceId },
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (isGenerating) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
// Transform the invoice data to match the PDF interface
|
||||
const pdfData = {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
notes: invoice.notes,
|
||||
business: invoice.business,
|
||||
client: invoice.client,
|
||||
items: invoice.items,
|
||||
};
|
||||
// Fetch fresh invoice data
|
||||
const { data: invoiceData } = await fetchInvoice();
|
||||
|
||||
await generateInvoicePDF(pdfData);
|
||||
if (!invoiceData) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
|
||||
await generateInvoicePDF(invoiceData);
|
||||
toast.success("PDF downloaded successfully");
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
@@ -92,23 +51,6 @@ export function PDFDownloadButton({
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === "menu") {
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
className="hover:bg-accent flex w-full items-center gap-2 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isGenerating ? "Generating..." : "Download PDF"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<Button
|
||||
@@ -116,12 +58,12 @@ export function PDFDownloadButton({
|
||||
disabled={isGenerating}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
className={className}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
<Download className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
@@ -131,15 +73,21 @@ export function PDFDownloadButton({
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
variant={variant}
|
||||
size="default"
|
||||
className={`w-full shadow-sm ${className}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Generating PDF...</span>
|
||||
</>
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<span>Download PDF</span>
|
||||
</>
|
||||
)}
|
||||
{isGenerating ? "Generating..." : "Download PDF"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { Send, Loader2 } from "lucide-react";
|
||||
|
||||
interface SendInvoiceButtonProps {
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SendInvoiceButton({
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
}: SendInvoiceButtonProps) {
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// Fetch invoice data when sending is triggered
|
||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
||||
{ id: invoiceId },
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const handleSendInvoice = async () => {
|
||||
if (isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
// Fetch fresh invoice data
|
||||
const { data: invoice } = await fetchInvoice();
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
|
||||
// Generate PDF blob for potential attachment
|
||||
const pdfBlob = await generateInvoicePDFBlob(invoice);
|
||||
|
||||
// Create a temporary download URL for the PDF
|
||||
const pdfUrl = URL.createObjectURL(pdfBlob);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
// Calculate days until due
|
||||
const today = new Date();
|
||||
const dueDate = new Date(invoice.dueDate);
|
||||
const daysUntilDue = Math.ceil(
|
||||
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
// Create professional email template
|
||||
const subject = `Invoice ${invoice.invoiceNumber} - ${formatCurrency(invoice.totalAmount)}`;
|
||||
|
||||
const body = `Dear ${invoice.client.name},
|
||||
|
||||
I hope this email finds you well. Please find attached invoice ${invoice.invoiceNumber} for the services provided.
|
||||
|
||||
Invoice Details:
|
||||
• Invoice Number: ${invoice.invoiceNumber}
|
||||
• Issue Date: ${formatDate(invoice.issueDate)}
|
||||
• Due Date: ${formatDate(invoice.dueDate)}
|
||||
• Amount Due: ${formatCurrency(invoice.totalAmount)}
|
||||
${daysUntilDue > 0 ? `• Payment Due: In ${daysUntilDue} days` : daysUntilDue === 0 ? `• Payment Due: Today` : `• Status: ${Math.abs(daysUntilDue)} days overdue`}
|
||||
|
||||
${invoice.notes ? `\nAdditional Notes:\n${invoice.notes}\n` : ""}
|
||||
Please review the attached invoice and remit payment by the due date. If you have any questions or concerns regarding this invoice, please don't hesitate to contact me.
|
||||
|
||||
Thank you for your business!
|
||||
|
||||
Best regards,
|
||||
${invoice.business?.name ?? "Your Business Name"}
|
||||
${invoice.business?.email ? `\n${invoice.business.email}` : ""}
|
||||
${invoice.business?.phone ? `\n${invoice.business.phone}` : ""}`;
|
||||
|
||||
// Create mailto link
|
||||
const mailtoLink = `mailto:${invoice.client.email ?? ""}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
|
||||
// Create a temporary link element to trigger mailto
|
||||
const link = document.createElement("a");
|
||||
link.href = mailtoLink;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the PDF URL object
|
||||
URL.revokeObjectURL(pdfUrl);
|
||||
|
||||
toast.success("Email client opened with invoice details");
|
||||
} catch (error) {
|
||||
console.error("Send invoice error:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to prepare invoice email",
|
||||
);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<Button
|
||||
onClick={handleSendInvoice}
|
||||
disabled={isSending}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={className}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleSendInvoice}
|
||||
disabled={isSending}
|
||||
variant={variant}
|
||||
size="default"
|
||||
className={`w-full shadow-sm ${className}`}
|
||||
data-testid="send-invoice-button"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Preparing Email...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
<span>Send Invoice</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { InvoiceView } from "~/components/invoice-view";
|
||||
import { InvoiceForm } from "~/components/invoice-form";
|
||||
import { InvoiceView } from "~/components/data/invoice-view";
|
||||
import { InvoiceForm } from "~/components/forms/invoice-form";
|
||||
|
||||
interface UnifiedInvoicePageProps {
|
||||
invoiceId: string;
|
||||
|
||||
@@ -1,56 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building,
|
||||
DollarSign,
|
||||
Edit3,
|
||||
Eye,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Send,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
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/page-header";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
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,
|
||||
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 { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
Loader2,
|
||||
Send,
|
||||
DollarSign,
|
||||
Hash,
|
||||
Edit3,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
|
||||
interface EditInvoicePageProps {}
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface InvoiceItem {
|
||||
id?: string;
|
||||
|
||||
@@ -4,78 +4,34 @@ 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 { Badge } from "~/components/ui/badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { SendInvoiceButton } from "./_components/send-invoice-button";
|
||||
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||
import { InvoiceActionsDropdown } from "./_components/invoice-actions-dropdown";
|
||||
import { InvoiceItemsTable } from "./_components/invoice-items-table";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Send,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Calendar,
|
||||
FileText,
|
||||
Building,
|
||||
User,
|
||||
DollarSign,
|
||||
Hash,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Copy,
|
||||
Edit,
|
||||
FileText,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
User,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
function InvoiceStatusBadge({
|
||||
status,
|
||||
dueDate,
|
||||
}: {
|
||||
status: string;
|
||||
dueDate: Date;
|
||||
}) {
|
||||
const getStatus = (): "draft" | "sent" | "paid" | "overdue" => {
|
||||
if (status === "paid") return "paid";
|
||||
if (status === "draft") return "draft";
|
||||
if (status === "sent") {
|
||||
const due = new Date(dueDate);
|
||||
return due < new Date() ? "overdue" : "sent";
|
||||
}
|
||||
return "draft";
|
||||
};
|
||||
|
||||
const actualStatus = getStatus();
|
||||
|
||||
const icons = {
|
||||
draft: FileText,
|
||||
sent: Clock,
|
||||
paid: CheckCircle,
|
||||
overdue: Clock,
|
||||
};
|
||||
|
||||
const Icon = icons[actualStatus];
|
||||
|
||||
return (
|
||||
<StatusBadge status={actualStatus} className="flex items-center gap-1">
|
||||
<Icon className="h-3 w-3" />
|
||||
{actualStatus.charAt(0).toUpperCase() + actualStatus.slice(1)}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
|
||||
async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
const invoice = await api.invoices.getById({ id: invoiceId });
|
||||
|
||||
if (!invoice) {
|
||||
@@ -97,379 +53,337 @@ async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const subtotal =
|
||||
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) || 0;
|
||||
const taxAmount = (subtotal * (invoice.taxRate || 0)) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
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 === "sent") {
|
||||
return isOverdue ? "overdue" : "sent";
|
||||
}
|
||||
return "draft";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
{/* Invoice Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-lg bg-emerald-100 p-3 dark:bg-emerald-900/30">
|
||||
<Hash className="h-6 w-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||
{invoice.invoiceNumber}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">Invoice</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Issued
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Due
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Amount
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-emerald-600">
|
||||
{formatCurrency(total)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Status
|
||||
</p>
|
||||
<InvoiceStatusBadge
|
||||
status={invoice.status}
|
||||
dueDate={invoice.dueDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row lg:flex-col">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button className="w-full">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
<PDFDownloadButton invoice={invoice} variant="button" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business & Client Info */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* From Business */}
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Building className="h-4 w-4 text-emerald-600" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{invoice.business ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-semibold">{invoice.business.name}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">
|
||||
{invoice.business.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">
|
||||
{invoice.business.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.business.addressLine1 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
|
||||
<div className="text-muted-foreground">
|
||||
<p>{invoice.business.addressLine1}</p>
|
||||
{invoice.business.addressLine2 && (
|
||||
<p>{invoice.business.addressLine2}</p>
|
||||
)}
|
||||
<p>
|
||||
{[
|
||||
invoice.business.city,
|
||||
invoice.business.state,
|
||||
invoice.business.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
{invoice.business.country && (
|
||||
<p>{invoice.business.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
No business information
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* To Client */}
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<User className="h-4 w-4 text-emerald-600" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold">{invoice.client.name}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">
|
||||
{invoice.client.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">
|
||||
{invoice.client.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.addressLine1 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
|
||||
<div className="text-muted-foreground">
|
||||
<p>{invoice.client.addressLine1}</p>
|
||||
{invoice.client.addressLine2 && (
|
||||
<p>{invoice.client.addressLine2}</p>
|
||||
)}
|
||||
<p>
|
||||
{[
|
||||
invoice.client.city,
|
||||
invoice.client.state,
|
||||
invoice.client.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
{invoice.client.country && <p>{invoice.client.country}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-medium">
|
||||
This invoice is{" "}
|
||||
{Math.ceil(
|
||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days overdue
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line Items */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
Line Items ({invoice.items?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
{invoice.items && invoice.items.length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{/* Header - Hidden on mobile */}
|
||||
<div className="border-muted/30 bg-muted/20 hidden grid-cols-12 gap-4 border-b px-6 py-3 text-sm font-medium md:grid">
|
||||
<div className="col-span-2">Date</div>
|
||||
<div className="col-span-5">Description</div>
|
||||
<div className="col-span-2 text-right">Hours</div>
|
||||
<div className="col-span-2 text-right">Rate</div>
|
||||
<div className="col-span-1 text-right">Amount</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{invoice.items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-muted/30 grid grid-cols-1 gap-2 border-b px-6 py-4 last:border-b-0 md:grid-cols-12 md:items-center md:gap-4"
|
||||
>
|
||||
{/* Mobile Layout */}
|
||||
<div className="md:hidden">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<span className="font-mono text-sm font-semibold text-emerald-600">
|
||||
{formatCurrency(item.hours * item.rate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Date
|
||||
</span>
|
||||
<p>{formatDate(item.date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Hours
|
||||
</span>
|
||||
<p className="font-mono">{item.hours}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Rate
|
||||
</span>
|
||||
<p className="font-mono">
|
||||
{formatCurrency(item.rate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
<div className="text-muted-foreground col-span-2 hidden text-sm md:block">
|
||||
{formatDate(item.date)}
|
||||
</div>
|
||||
<div className="col-span-5 hidden font-medium md:block">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
|
||||
{item.hours}
|
||||
</div>
|
||||
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
|
||||
{formatCurrency(item.rate)}
|
||||
</div>
|
||||
<div className="col-span-1 hidden text-right font-mono font-semibold text-emerald-600 md:block">
|
||||
{formatCurrency(item.hours * item.rate)}
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-4 xl:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-3 xl:col-span-2">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-lg">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold sm:text-2xl">
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</h1>
|
||||
<StatusBadge status={getStatusType()} />
|
||||
</div>
|
||||
))}
|
||||
<p className="text-muted-foreground text-sm sm:text-base">
|
||||
Issued {formatDate(invoice.issueDate)} • Due{" "}
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<p className="text-muted-foreground text-sm sm:text-base">
|
||||
Total Amount
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 sm:text-3xl">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-12 text-center">
|
||||
<FileText className="mx-auto mb-2 h-8 w-8" />
|
||||
<p>No line items found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Totals & Notes */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="border-0 shadow-md lg:col-span-2">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<Card
|
||||
className={`border-0 shadow-md ${!invoice.notes ? "lg:col-start-3" : ""}`}
|
||||
>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<DollarSign className="h-4 w-4 text-emerald-600" />
|
||||
Total
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-mono">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({invoice.taxRate}%):
|
||||
</span>
|
||||
<span className="font-mono">{formatCurrency(taxAmount)}</span>
|
||||
{/* Client & Business Information */}
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-2">
|
||||
{/* Client Information */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-600">
|
||||
<User className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-lg font-semibold sm:text-xl">
|
||||
{invoice.client.name}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-emerald-600">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
<div className="pt-2">
|
||||
{invoice.status === "draft" && (
|
||||
<Button className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Invoice
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all sm:text-base">
|
||||
{invoice.client.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.status === "sent" && (
|
||||
<Button className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700">
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<span className="text-sm sm:text-base">
|
||||
{invoice.client.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(invoice.status === "paid" || invoice.status === "overdue") && (
|
||||
<div className="text-center">
|
||||
<InvoiceStatusBadge
|
||||
status={invoice.status}
|
||||
dueDate={invoice.dueDate}
|
||||
/>
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<div className="flex items-start gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<MapPin className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div className="text-sm sm:text-base">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Information */}
|
||||
{invoice.business && (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-600">
|
||||
<Building className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-lg font-semibold sm:text-xl">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all sm:text-base">
|
||||
{invoice.business.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<span className="text-sm sm:text-base">
|
||||
{invoice.business.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<InvoiceItemsTable items={invoice.items} />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full space-y-2 sm:max-w-64">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tax ({invoice.taxRate}%):
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-base font-bold sm:text-lg">
|
||||
<span>Total:</span>
|
||||
<span className="text-emerald-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4 sm:space-y-6 lg:col-span-1">
|
||||
{/* Actions */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base sm:text-lg">Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full border-0 shadow-sm"
|
||||
size="default"
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<PDFDownloadButton invoiceId={invoice.id} />
|
||||
|
||||
<SendInvoiceButton invoiceId={invoice.id} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-0 shadow-sm"
|
||||
size="default"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<span>Duplicate</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="destructive" size="default" className="w-full">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Invoice</span>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Details */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Calendar className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Invoice #</p>
|
||||
<p className="font-medium break-all">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Status</p>
|
||||
<div className="mt-1">
|
||||
<StatusBadge status={getStatusType()} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Issue Date</p>
|
||||
<p className="font-medium">{formatDate(invoice.issueDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Due Date</p>
|
||||
<p className="font-medium">{formatDate(invoice.dueDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Tax Rate</p>
|
||||
<p className="font-medium">{invoice.taxRate}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Total</p>
|
||||
<p className="font-medium text-emerald-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -479,56 +393,35 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
<PageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="sm">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-0 shadow-sm"
|
||||
size="default"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<span className="hidden sm:inline">Back to Invoices</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/dashboard/invoices/${id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Invoice
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<InvoiceActionsDropdown invoiceId={id} />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<div>Loading invoice details...</div>}>
|
||||
<InvoiceDetails invoiceId={id} />
|
||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||
<InvoiceContent invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import Link from "next/link";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
|
||||
import { EmptyState } from "~/components/ui/page-layout";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Plus, FileText, Eye, Edit } from "lucide-react";
|
||||
|
||||
// Type for invoice data
|
||||
@@ -182,38 +182,7 @@ const columns: ColumnDef<Invoice>[] = [
|
||||
</Button>
|
||||
</Link>
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton
|
||||
invoice={{
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
notes: invoice.notes,
|
||||
business: invoice.business
|
||||
? {
|
||||
name: invoice.business.name,
|
||||
email: invoice.business.email,
|
||||
phone: invoice.business.phone,
|
||||
}
|
||||
: null,
|
||||
client: {
|
||||
name: invoice.client.name,
|
||||
email: invoice.client.email,
|
||||
phone: invoice.client.phone,
|
||||
},
|
||||
items: invoice.items.map((item) => ({
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
})),
|
||||
}}
|
||||
variant="icon"
|
||||
/>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Upload,
|
||||
|
||||
@@ -10,7 +10,7 @@ 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/page-header";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
||||
@@ -2,10 +2,10 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
|
||||
// Invoices Table Component
|
||||
async function InvoicesTable() {
|
||||
|
||||
Reference in New Issue
Block a user