Add bulk actions, multi-currency, expenses, templates, and reports

Schema (migration 0001):
- clients: add currency column (default USD)
- invoices: add currency column (default USD)
- New expenses table: amount, currency, category, billable, reimbursable,
  client/invoice/business relations, notes
- New invoice_templates table: name, type (notes|terms), content, isDefault

API:
- invoices: add bulkUpdateStatus and bulkDelete procedures (ownership-safe)
- invoices: currency field threaded through create/update schemas
- clients: currency field added to create/update schemas
- New expenses router: full CRUD with authorization
- New invoiceTemplates router: full CRUD, isDefault management per type
- Root router: wire in expenses and invoiceTemplates

Currency (src/lib/currency.ts):
- Shared formatCurrency(amount, currency) utility replacing hardcoded USD
- SUPPORTED_CURRENCIES list (17 currencies)
- Invoice form: currency selector in Config card, auto-fills from client
- Client form: currency selector in Billing Information card

Bulk actions (invoices list):
- Checkbox column with select-all support
- Selection toolbar: Mark as Sent/Paid/Draft dropdown, Delete (N) button
- DataTable: new selectionActions prop renders toolbar when rows selected

Notes templates:
- Invoice form: Notes card with textarea in Details tab
- Template dropdown button appears when templates exist
- /dashboard/invoices/templates: full CRUD page for notes and terms templates

New pages:
- /dashboard/expenses: expense list with summary cards, add/edit dialog
- /dashboard/reports: KPI cards, 12-month revenue area chart, top clients
  bar chart, status breakdown, recent activity
- Navigation: Expenses and Reports added to Main section

https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
This commit is contained in:
Claude
2026-04-05 02:34:06 +00:00
parent ba14526fc5
commit e6b79ce2c2
19 changed files with 3233 additions and 214 deletions
@@ -3,7 +3,8 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table";
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";
@@ -16,13 +17,19 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Eye, Edit, Trash2, FileText } from "lucide-react";
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";
// Type for invoice data
interface Invoice {
id: string;
invoiceNumber: string;
@@ -33,32 +40,16 @@ interface Invoice {
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;
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;
id: string; invoiceId: string; date: Date; description: string;
hours: number; rate: number; amount: number; position: number; createdAt: Date;
}> | null;
}
@@ -66,67 +57,74 @@ interface InvoicesDataTableProps {
invoices: Invoice[];
}
const getStatusType = (invoice: Invoice): StatusType => {
return getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) as StatusType;
};
const getStatusType = (invoice: Invoice): StatusType =>
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType;
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 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 successfully");
toast.success("Invoice deleted");
void utils.invoices.getAll.invalidate();
setDeleteDialogOpen(false);
setInvoiceToDelete(null);
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
});
const handleRowClick = (invoice: Invoice) => {
router.push(`/dashboard/invoices/${invoice.id}`);
};
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 handleDelete = (invoice: Invoice) => {
setInvoiceToDelete(invoice);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (invoiceToDelete) {
deleteInvoice.mutate({ id: invoiceToDelete.id });
}
};
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>[] = [
{
accessorKey: "client.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
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 (
@@ -135,20 +133,12 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<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>
{/* Show status + amount inline on mobile only */}
<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"
/>
<StatusBadge status={getStatusType(invoice)} className="text-xs" />
<span className="text-foreground text-xs font-semibold">
{formatCurrency(invoice.totalAmount)}
{formatCurrency(invoice.totalAmount, invoice.currency)}
</span>
</div>
</div>
@@ -158,69 +148,38 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
},
{
accessorKey: "issueDate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Date" />
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => (
<div className="min-w-0">
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p>
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p>
</div>
),
cell: ({ row }) => {
const date = row.getValue("issueDate");
return (
<div className="min-w-0">
<p className="truncate text-sm">{formatDate(date as Date)}</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" />
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge
status={getStatusType(row.original)}
className={getStatusType(row.original) === "sent" ? "status-pending" : ""}
/>
),
cell: ({ row }) => {
const invoice = row.original;
return (
<StatusBadge
status={getStatusType(invoice)}
className={
getStatusType(invoice) === "sent" ? "status-pending" : ""
}
/>
);
},
filterFn: (row, id, value: string[]) => {
const invoice = row.original;
const status = getStatusType(invoice);
return value.includes(status);
},
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
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" />
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
cell: ({ row }) => (
<div className="text-right">
<p className="text-sm font-semibold">
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)}
</p>
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
</div>
),
cell: ({ row }) => {
const amount = row.getValue("totalAmount");
return (
<div className="text-right">
<p className="text-sm font-semibold">
{formatCurrency(amount as number)}
</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",
},
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
},
{
id: "actions",
@@ -229,33 +188,19 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
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"
>
<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"
>
<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"
variant="ghost" size="sm"
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
handleDelete(invoice);
}}
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }}
data-action-button="true"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -292,10 +237,68 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber"
searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns}
onRowClick={handleRowClick}
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>
</>
)}
/>
{/* Delete Confirmation Dialog */}
{/* Single delete dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -303,21 +306,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<DialogDescription>
Are you sure you want to delete invoice{" "}
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
<strong>{invoiceToDelete?.client?.name}</strong>? This action
cannot be undone.
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteInvoice.isPending}
>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })}
disabled={deleteInvoice.isPending}
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
@@ -325,6 +323,31 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
</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>
</>
);
}