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
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Checkbox } from "~/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses";
interface ExpenseFormData {
date: Date;
description: string;
amount: number;
currency: string;
category: string;
billable: boolean;
reimbursable: boolean;
notes: string;
clientId: string;
}
const defaultForm: ExpenseFormData = {
date: new Date(),
description: "",
amount: 0,
currency: "USD",
category: "",
billable: false,
reimbursable: false,
notes: "",
clientId: "",
};
export default function ExpensesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const utils = api.useUtils();
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
const { data: clients = [] } = api.clients.getAll.useQuery();
const create = api.expenses.create.useMutation({
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const update = api.expenses.update.useMutation({
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const del = api.expenses.delete.useMutation({
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); };
const handleEdit = (expense: typeof expenses[0]) => {
setEditId(expense.id);
setForm({
date: new Date(expense.date),
description: expense.description,
amount: expense.amount,
currency: expense.currency,
category: expense.category ?? "",
billable: expense.billable,
reimbursable: expense.reimbursable,
notes: expense.notes ?? "",
clientId: expense.clientId ?? "",
});
setOpen(true);
};
const handleSubmit = () => {
if (!form.description.trim()) { toast.error("Description is required"); return; }
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined };
if (editId) update.mutate({ id: editId, ...payload });
else create.mutate(payload);
};
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
<Plus className="mr-2 h-5 w-5" /> Add Expense
</Button>
</PageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
</CardContent>
</Card>
<Card className="col-span-2 sm:col-span-1">
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
</CardContent>
</Card>
</div>
{/* Expenses list */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" /> All Expenses
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading</div>
) : expenses.length === 0 ? (
<div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
</div>
) : (
<div className="divide-y">
{expenses.map((expense) => (
<div key={expense.id} className="flex items-start justify-between gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{expense.description}</p>
{expense.billable && <Badge variant="secondary" className="text-xs">Billable</Badge>}
{expense.reimbursable && <Badge variant="outline" className="text-xs">Reimbursable</Badge>}
{expense.category && <Badge variant="outline" className="text-xs">{expense.category}</Badge>}
</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))}
{expense.client ? ` · ${expense.client.name}` : ""}
</p>
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<p className="font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(expense)}><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(expense.id)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Add/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Description *</Label>
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Amount *</Label>
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
</div>
<div className="space-y-2">
<Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Date</Label>
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Client (optional)</Label>
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">No client</SelectItem>
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex gap-6">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
<span className="text-sm">Billable</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
<span className="text-sm">Reimbursable</span>
</label>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Expense</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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>
</>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { Checkbox } from "~/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "~/components/ui/tabs";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
interface TemplateForm {
name: string;
type: "notes" | "terms";
content: string;
isDefault: boolean;
}
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false };
export default function TemplatesPage() {
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [form, setForm] = useState<TemplateForm>(defaultForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [tab, setTab] = useState<"notes" | "terms">("notes");
const utils = api.useUtils();
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery();
const create = api.invoiceTemplates.create.useMutation({
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const update = api.invoiceTemplates.update.useMutation({
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
onError: (e) => toast.error(e.message),
});
const del = api.invoiceTemplates.delete.useMutation({
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
onError: (e) => toast.error(e.message),
});
const handleOpen = (type: "notes" | "terms") => {
setEditId(null);
setForm({ ...defaultForm, type });
setOpen(true);
};
const handleEdit = (t: typeof templates[0]) => {
setEditId(t.id);
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
setOpen(true);
};
const handleSubmit = () => {
if (!form.name.trim()) { toast.error("Name is required"); return; }
if (!form.content.trim()) { toast.error("Content is required"); return; }
if (editId) update.mutate({ id: editId, ...form });
else create.mutate(form);
};
const notesTemplates = templates.filter((t) => t.type === "notes");
const termsTemplates = templates.filter((t) => t.type === "terms");
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => handleOpen(type)}>
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template
</Button>
</div>
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">Loading</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
No {type} templates yet.
</div>
) : (
items.map((t) => (
<Card key={t.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{t.name}</p>
{t.isDefault && (
<Badge variant="secondary" className="text-xs">
<Star className="mr-1 h-3 w-3" /> Default
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
{t.content}
</p>
</div>
<div className="flex flex-shrink-0 gap-1">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="text-destructive h-8 w-8 p-0" onClick={() => setDeleteId(t.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
title="Invoice Templates"
description="Reusable notes and payment terms for your invoices"
variant="gradient"
/>
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length})
</TabsTrigger>
<TabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length})
</TabsTrigger>
</TabsList>
<TabsContent value="notes" className="mt-4">
<TemplateList items={notesTemplates} type="notes" />
</TabsContent>
<TabsContent value="terms" className="mt-4">
<TemplateList items={termsTemplates} type="terms" />
</TabsContent>
</Tabs>
{/* Create/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name *</Label>
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="terms">Terms</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="space-y-2">
<Label>Content *</Label>
<Textarea
value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))}
placeholder="Template content…"
className="min-h-[120px]"
/>
</div>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} />
<span className="text-sm">Set as default for {form.type}</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete dialog */}
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
{del.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
"use client";
import { useMemo } from "react";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge";
import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import {
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { TrendingUp, DollarSign, Clock, Users } from "lucide-react";
export default function ReportsPage() {
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
const { data: stats } = api.dashboard.getStats.useQuery();
const now = new Date();
const reportData = useMemo(() => {
if (!invoices.length) return null;
// Revenue by month (last 12 months)
const monthMap: Record<string, number> = {};
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
monthMap[key] = 0;
}
let totalRevenue = 0;
let totalPending = 0;
let totalHours = 0;
let overdueCount = 0;
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
if (status === "paid") {
totalRevenue += inv.totalAmount;
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
} else if (status === "sent" || status === "overdue") {
totalPending += inv.totalAmount;
}
if (status === "overdue") overdueCount++;
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
}
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
revenue,
}));
// Top clients by revenue (paid only)
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
for (const inv of invoices) {
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
if (status === "paid" && inv.client) {
const id = inv.client.id;
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 };
clientMap[id]!.revenue += inv.totalAmount;
clientMap[id]!.count += 1;
}
}
const topClients = Object.values(clientMap)
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 6);
// Status breakdown
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
for (const inv of invoices) {
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
statusCount[s] = (statusCount[s] ?? 0) + 1;
}
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount };
}, [invoices]);
if (isLoading) {
return (
<div className="page-enter space-y-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
</div>
</div>
);
}
const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0;
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
{/* KPI cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-primary/10 rounded p-1.5">
<DollarSign className="text-primary h-4 w-4" />
</div>
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-yellow-500/10 rounded p-1.5">
<Clock className="h-4 w-4 text-yellow-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Pending</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-blue-500/10 rounded p-1.5">
<TrendingUp className="h-4 w-4 text-blue-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
</div>
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<div className="bg-green-500/10 rounded p-1.5">
<Users className="h-4 w-4 text-green-500" />
</div>
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
</div>
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
</CardContent>
</Card>
</div>
{/* Revenue trend chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={reportData?.revenueByMonth ?? []}>
<defs>
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Top clients */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" /> Top Clients by Revenue
</CardTitle>
</CardHeader>
<CardContent>
{!reportData?.topClients.length ? (
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
) : (
<div className="h-48 md:h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={reportData.topClients} layout="vertical">
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* Invoice status breakdown */}
<Card>
<CardHeader>
<CardTitle>Invoice Status Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => (
<div key={status} className="flex items-center justify-between">
<StatusBadge status={status as never} />
<div className="flex items-center gap-3">
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
/>
</div>
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
</div>
</div>
))}
{invoices.length === 0 && (
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
)}
</CardContent>
</Card>
</div>
{/* Monthly stats table */}
{stats && (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y">
{stats.recentInvoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between py-3">
<div>
<p className="font-medium">{inv.client?.name ?? "—"}</p>
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
</div>
<div className="flex items-center gap-3">
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}