mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
ddc2b42672
- Cleaned up imports and formatted code for better readability in invoices-data-table.tsx. - Enhanced invoice interface definitions for clarity. - Improved toast messages for bulk delete and update actions. - Refactored date formatting and status type retrieval for better readability. - Simplified template management in templates page, extracting TemplateList component. - Added registration toggle based on environment variable DISABLE_SIGNUPS. - Updated navbar to conditionally render registration link based on allowRegistration prop. - Enhanced error handling and validation in expenses and settings routers. - Improved PDF export footer handling. - Updated TRPC react integration for cleaner type imports.
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import type { ColumnDef, Row } from "@tanstack/react-table";
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
|
import { Button } from "~/components/ui/button";
|
|
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/data/data-table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "~/components/ui/dropdown-menu";
|
|
import {
|
|
Eye,
|
|
Edit,
|
|
Trash2,
|
|
FileText,
|
|
CheckCircle,
|
|
Send,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
import { api } from "~/trpc/react";
|
|
import { toast } from "sonner";
|
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
|
import { formatCurrency } from "~/lib/currency";
|
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
|
|
|
interface Invoice {
|
|
id: string;
|
|
invoiceNumber: string;
|
|
clientId: string;
|
|
businessId: string | null;
|
|
issueDate: Date;
|
|
dueDate: Date;
|
|
status: string;
|
|
totalAmount: number;
|
|
taxRate: number;
|
|
currency: string;
|
|
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 =>
|
|
getEffectiveInvoiceStatus(
|
|
invoice.status as StoredInvoiceStatus,
|
|
invoice.dueDate,
|
|
) as StatusType;
|
|
|
|
const formatDate = (date: Date) =>
|
|
new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "2-digit",
|
|
year: "numeric",
|
|
}).format(new Date(date));
|
|
|
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|
const router = useRouter();
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
|
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
|
|
|
const utils = api.useUtils();
|
|
|
|
const deleteInvoice = api.invoices.delete.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Invoice deleted");
|
|
void utils.invoices.getAll.invalidate();
|
|
setDeleteDialogOpen(false);
|
|
setInvoiceToDelete(null);
|
|
},
|
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
|
});
|
|
|
|
const bulkDelete = api.invoices.bulkDelete.useMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(
|
|
`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`,
|
|
);
|
|
void utils.invoices.getAll.invalidate();
|
|
setBulkDeleteDialogOpen(false);
|
|
setPendingBulkDelete([]);
|
|
},
|
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
|
|
});
|
|
|
|
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(
|
|
`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`,
|
|
);
|
|
void utils.invoices.getAll.invalidate();
|
|
},
|
|
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
|
|
});
|
|
|
|
const columns: ColumnDef<Invoice>[] = [
|
|
{
|
|
id: "select",
|
|
header: ({ table }) => (
|
|
<Checkbox
|
|
checked={
|
|
table.getIsAllPageRowsSelected() ||
|
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
}
|
|
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
|
aria-label="Select all"
|
|
data-action-button="true"
|
|
/>
|
|
),
|
|
cell: ({ row }: { row: Row<Invoice> }) => (
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
aria-label="Select row"
|
|
data-action-button="true"
|
|
/>
|
|
),
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
},
|
|
{
|
|
accessorKey: "client.name",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Client" />
|
|
),
|
|
cell: ({ row }) => {
|
|
const invoice = row.original;
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="bg-primary/10 hidden p-2 sm:flex">
|
|
<FileText className="text-primary h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-medium">
|
|
{invoice.client?.name ?? "—"}
|
|
</p>
|
|
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
|
{invoice.invoiceNumber}
|
|
</p>
|
|
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
|
<StatusBadge
|
|
status={getStatusType(invoice)}
|
|
className="text-xs"
|
|
/>
|
|
<span className="text-foreground text-xs font-semibold">
|
|
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "issueDate",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Date" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm">
|
|
{formatDate(row.getValue("issueDate"))}
|
|
</p>
|
|
<p className="text-muted-foreground truncate text-xs">
|
|
Due {formatDate(new Date(row.original.dueDate))}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "status",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Status" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<StatusBadge
|
|
status={getStatusType(row.original)}
|
|
className={
|
|
getStatusType(row.original) === "sent" ? "status-pending" : ""
|
|
}
|
|
/>
|
|
),
|
|
filterFn: (row, _id, value: string[]) =>
|
|
value.includes(getStatusType(row.original)),
|
|
meta: {
|
|
headerClassName: "hidden sm:table-cell",
|
|
cellClassName: "hidden sm:table-cell",
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "totalAmount",
|
|
header: ({ column }) => (
|
|
<DataTableColumnHeader column={column} title="Amount" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="text-right">
|
|
<p className="text-sm font-semibold">
|
|
{formatCurrency(row.getValue("totalAmount"), row.original.currency)}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{row.original.items?.length ?? 0} items
|
|
</p>
|
|
</div>
|
|
),
|
|
meta: {
|
|
headerClassName: "hidden sm:table-cell",
|
|
cellClassName: "hidden sm:table-cell",
|
|
},
|
|
},
|
|
{
|
|
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="hover-scale h-8 w-8 p-0"
|
|
data-action-button="true"
|
|
>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</Link>
|
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="hover-scale h-8 w-8 p-0"
|
|
data-action-button="true"
|
|
>
|
|
<Edit className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</Link>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setInvoiceToDelete(invoice);
|
|
setDeleteDialogOpen(true);
|
|
}}
|
|
data-action-button="true"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{invoice.items && invoice.client && (
|
|
<div data-action-button="true">
|
|
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
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}
|
|
onRowClick={(invoice) =>
|
|
router.push(`/dashboard/invoices/${invoice.id}`)
|
|
}
|
|
selectionActions={(selected, clear) => (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={bulkUpdateStatus.isPending}
|
|
>
|
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
|
Mark as
|
|
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
bulkUpdateStatus.mutate(
|
|
{ ids: selected.map((i) => i.id), status: "sent" },
|
|
{ onSuccess: clear },
|
|
)
|
|
}
|
|
>
|
|
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
bulkUpdateStatus.mutate(
|
|
{ ids: selected.map((i) => i.id), status: "paid" },
|
|
{ onSuccess: clear },
|
|
)
|
|
}
|
|
>
|
|
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
bulkUpdateStatus.mutate(
|
|
{ ids: selected.map((i) => i.id), status: "draft" },
|
|
{ onSuccess: clear },
|
|
)
|
|
}
|
|
>
|
|
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={bulkDelete.isPending}
|
|
onClick={() => {
|
|
setPendingBulkDelete(selected);
|
|
setBulkDeleteDialogOpen(true);
|
|
}}
|
|
>
|
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
|
Delete ({selected.length})
|
|
</Button>
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
{/* Single delete dialog */}
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Invoice</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete invoice{" "}
|
|
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
|
<strong>{invoiceToDelete?.client?.name}</strong>? This action
|
|
cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteDialogOpen(false)}
|
|
disabled={deleteInvoice.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() =>
|
|
invoiceToDelete &&
|
|
deleteInvoice.mutate({ id: invoiceToDelete.id })
|
|
}
|
|
disabled={deleteInvoice.isPending}
|
|
>
|
|
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Bulk delete dialog */}
|
|
<Dialog
|
|
open={bulkDeleteDialogOpen}
|
|
onOpenChange={setBulkDeleteDialogOpen}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
Delete {pendingBulkDelete.length} Invoice
|
|
{pendingBulkDelete.length !== 1 ? "s" : ""}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
This will permanently delete {pendingBulkDelete.length} invoice
|
|
{pendingBulkDelete.length !== 1 ? "s" : ""}. This action cannot be
|
|
undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setBulkDeleteDialogOpen(false)}
|
|
disabled={bulkDelete.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() =>
|
|
bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })
|
|
}
|
|
disabled={bulkDelete.isPending}
|
|
>
|
|
{bulkDelete.isPending
|
|
? "Deleting..."
|
|
: `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|