mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 09:34:44 -05:00
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
248 lines
6.9 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|