mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user