Files
beenvoice/src/app/dashboard/invoices/_components/invoices-data-table.tsx
Sean O'Connor f331136090 feat: polish invoice editor and viewer UI with custom NumberInput
component

- Create custom NumberInput component with increment/decrement buttons
- Add 0.25 step increments for hours and rates in invoice forms
- Implement emerald-themed styling with hover states and accessibility
- Add keyboard navigation (arrow keys) and proper ARIA support
- Condense invoice editor tax/totals section into efficient grid layout
- Update client dropdown to single-line format (name + email)
- Add fixed footer with floating action bar pattern matching business
  forms
- Redesign invoice viewer with better space utilization and visual
  hierarchy
- Maintain professional appearance and consistent design system
- Fix Next.js 15 params Promise handling across all invoice pages
- Resolve TypeScript compilation errors and type-only imports
2025-07-15 00:29:02 -04:00

248 lines
6.9 KiB
TypeScript

"use client";
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 { 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 { Plus, FileText, Eye, Edit } from "lucide-react";
// Type for invoice data
interface Invoice {
id: string;
invoiceNumber: string;
clientId: string;
businessId: string | null;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
notes: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
client?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
items?: Array<{
id: string;
invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}> | null;
}
interface InvoicesDataTableProps {
invoices: Invoice[];
}
const getStatusType = (invoice: Invoice): StatusType => {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (invoice.status === "sent") {
const dueDate = new Date(invoice.dueDate);
return dueDate < new Date() ? "overdue" : "sent";
}
return "draft";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const columns: ColumnDef<Invoice>[] = [
{
accessorKey: "invoiceNumber",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Invoice" />
),
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-status-success-muted hidden rounded-lg p-2 sm:flex">
<FileText className="text-status-success h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{invoice.invoiceNumber}</p>
<p className="text-muted-foreground truncate text-sm">
{invoice.items?.length || 0} items
</p>
</div>
</div>
);
},
},
{
accessorKey: "client.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="min-w-0">
<p className="truncate font-medium">{invoice.client?.name || "—"}</p>
<p className="text-muted-foreground truncate text-sm">
{invoice.client?.email || "—"}
</p>
</div>
);
},
},
{
accessorKey: "issueDate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Issue Date" />
),
cell: ({ row }) => formatDate(row.getValue("issueDate")),
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
},
{
accessorKey: "dueDate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Due Date" />
),
cell: ({ row }) => formatDate(row.getValue("dueDate")),
meta: {
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
{
accessorKey: "totalAmount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => {
const amount = row.getValue("totalAmount") as number;
return <p className="font-semibold">{formatCurrency(amount)}</p>;
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const invoice = row.original;
return <StatusBadge status={getStatusType(invoice)} />;
},
filterFn: (row, id, value) => {
const invoice = row.original;
const status = getStatusType(invoice);
return value.includes(status);
},
},
{
id: "actions",
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Edit className="h-3.5 w-3.5" />
</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"
/>
)}
</div>
);
},
},
];
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const filterableColumns = [
{
id: "status",
title: "Status",
options: [
{ label: "Draft", value: "draft" },
{ label: "Sent", value: "sent" },
{ label: "Paid", value: "paid" },
{ label: "Overdue", value: "overdue" },
],
},
];
return (
<DataTable
columns={columns}
data={invoices}
searchKey="invoiceNumber"
searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns}
/>
);
}