Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function InvoiceDetailsSkeleton() {
|
||||
return (
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="View and manage invoice information"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 sm:w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||
{/* Invoice Header Skeleton */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm sm:space-y-0">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="hidden h-4 w-32 sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-left sm:text-right">
|
||||
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
|
||||
<Skeleton className="h-9 w-32 sm:ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Skeleton */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Skeleton */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Invoice Items Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Item Rows */}
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="bg-secondary/50 border-0">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Skeleton className="mb-2 h-5 w-3/4" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-secondary rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "~/components/data/data-table";
|
||||
import {
|
||||
formatLineItemDetail,
|
||||
isFixedLineItem,
|
||||
} from "~/lib/invoice-line-item";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Type for invoice item data
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface InvoiceItemsTableProps {
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<InvoiceItem>[] = [
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date",
|
||||
cell: ({ row }) => formatDate(row.getValue("date")),
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: plain description */}
|
||||
<div className="hidden font-medium sm:block">{item.description}</div>
|
||||
{/* Mobile: description + date + hours @ rate stacked */}
|
||||
<div className="sm:hidden">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{formatDate(item.date)} ·{" "}
|
||||
{formatLineItemDetail(item.hours, item.rate, formatCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "hours",
|
||||
header: "Hours",
|
||||
cell: ({ row }) => {
|
||||
const hours = row.getValue<number>("hours");
|
||||
return (
|
||||
<div className="text-right">{isFixedLineItem(hours) ? "—" : hours}</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rate",
|
||||
header: "Rate",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="text-right">
|
||||
{isFixedLineItem(item.hours)
|
||||
? "—"
|
||||
: `${formatCurrency(item.rate)}/hr`}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-primary text-right font-medium">
|
||||
{formatCurrency(row.getValue("amount"))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function InvoiceItemsTable({ items }: InvoiceItemsTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
showSearch={false}
|
||||
showColumnVisibility={false}
|
||||
showPagination={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||
|
||||
interface InvoiceTimerCardProps {
|
||||
invoiceId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||
return (
|
||||
<TimeClockPanel
|
||||
compact
|
||||
defaultClientId={clientId}
|
||||
defaultInvoiceId={invoiceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface PDFDownloadButtonProps {
|
||||
invoiceId: string;
|
||||
variant?: "default" | "outline" | "ghost" | "icon" | "secondary";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PDFDownloadButton({
|
||||
invoiceId,
|
||||
variant = "outline",
|
||||
className,
|
||||
}: PDFDownloadButtonProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Fetch invoice data when PDF generation is triggered
|
||||
const { refetch: fetchInvoice } = api.invoices.getById.useQuery(
|
||||
{ id: invoiceId },
|
||||
{ enabled: false },
|
||||
);
|
||||
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (isGenerating) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
// Fetch fresh invoice data
|
||||
const { data: invoiceData } = await fetchInvoice();
|
||||
|
||||
if (!invoiceData) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
|
||||
// Map invoice to PDF format with currency support
|
||||
const pdfData = {
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
invoicePrefix: invoiceData.invoicePrefix,
|
||||
issueDate: new Date(invoiceData.issueDate),
|
||||
dueDate: new Date(invoiceData.dueDate),
|
||||
status: invoiceData.status,
|
||||
totalAmount: invoiceData.totalAmount,
|
||||
taxRate: invoiceData.taxRate,
|
||||
currency: invoiceData.currency ?? "USD",
|
||||
notes: invoiceData.notes,
|
||||
business: invoiceData.business,
|
||||
client: invoiceData.client,
|
||||
items: invoiceData.items,
|
||||
};
|
||||
|
||||
await generateInvoicePDF(pdfData, {
|
||||
pdfTemplate: pdfSettings?.pdfTemplate,
|
||||
pdfAccentColor: pdfSettings?.pdfAccentColor,
|
||||
pdfFontFamily: pdfSettings?.pdfFontFamily,
|
||||
pdfNumericFontFamily: pdfSettings?.pdfNumericFontFamily,
|
||||
pdfFooterText: pdfSettings?.pdfFooterText,
|
||||
pdfShowLogo: pdfSettings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
|
||||
});
|
||||
toast.success("PDF downloaded successfully");
|
||||
} catch (error) {
|
||||
console.error("PDF generation error:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to generate PDF",
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
variant="ghost"
|
||||
className={className}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isGenerating}
|
||||
variant={variant}
|
||||
className={`shadow-sm ${className ?? ""}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span>Generating PDF...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
<span>Download PDF</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface EditInvoicePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const invoice = await api.invoices.getById({ id });
|
||||
if (invoice.status !== "draft") {
|
||||
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
|
||||
}
|
||||
} catch {
|
||||
redirect("/dashboard/invoices");
|
||||
}
|
||||
|
||||
return <InvoiceForm invoiceId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Building,
|
||||
Check,
|
||||
Copy,
|
||||
DollarSign,
|
||||
Edit,
|
||||
FileText,
|
||||
Link2,
|
||||
Link2Off,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Plus,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import {
|
||||
getEffectiveInvoiceStatus,
|
||||
isInvoiceOverdue,
|
||||
} from "~/lib/invoice-status";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||
import { InvoiceTimerCard } from "./_components/invoice-timer-card";
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ value: "cash", label: "Cash" },
|
||||
{ value: "check", label: "Check" },
|
||||
{ value: "bank_transfer", label: "Bank Transfer" },
|
||||
{ value: "credit_card", label: "Credit Card" },
|
||||
{ value: "paypal", label: "PayPal" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
function methodLabel(method: string) {
|
||||
return PAYMENT_METHODS.find((m) => m.value === method)?.label ?? method;
|
||||
}
|
||||
|
||||
function daysSince(date: Date) {
|
||||
return Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [paymentAmount, setPaymentAmount] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState("other");
|
||||
const [paymentNotes, setPaymentNotes] = useState("");
|
||||
const [reminderMessage, setReminderMessage] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
const { data: payments, isLoading: paymentsLoading } =
|
||||
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||
const utils = api.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("editBlocked") === "1") {
|
||||
toast.error("Only draft invoices can be edited");
|
||||
router.replace(`/dashboard/invoices/${invoiceId}`);
|
||||
}
|
||||
}, [searchParams, invoiceId, router]);
|
||||
|
||||
const invalidate = () => {
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.payments.getByInvoice.invalidate({ invoiceId });
|
||||
};
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted");
|
||||
router.push("/dashboard/invoices");
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||
});
|
||||
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to update status"),
|
||||
});
|
||||
|
||||
const createPayment = api.payments.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Payment recorded");
|
||||
setRecordPaymentOpen(false);
|
||||
setPaymentAmount("");
|
||||
setPaymentMethod("other");
|
||||
setPaymentNotes("");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to record payment"),
|
||||
});
|
||||
|
||||
const deletePayment = api.payments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Payment removed");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to remove payment"),
|
||||
});
|
||||
|
||||
const generatePublicToken = api.invoices.generatePublicToken.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Share link generated");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to generate link"),
|
||||
});
|
||||
|
||||
const revokePublicToken = api.invoices.revokePublicToken.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Share link revoked");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to revoke link"),
|
||||
});
|
||||
|
||||
const sendReminder = api.invoices.sendReminder.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Reminder sent");
|
||||
setReminderOpen(false);
|
||||
setReminderMessage("");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to send reminder"),
|
||||
});
|
||||
|
||||
const updateInvoice = api.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Reminder saved");
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to save reminder"),
|
||||
});
|
||||
|
||||
if (isLoading) return <InvoiceDetailsSkeleton />;
|
||||
if (!invoice) notFound();
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
|
||||
new Date(date),
|
||||
);
|
||||
|
||||
const formatCurrency = (amount: number, currency = invoice.currency) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||
|
||||
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const totalPaid = (payments ?? []).reduce((s, p) => s + p.amount, 0);
|
||||
const balanceDue = total - totalPaid;
|
||||
const storedStatus = invoice.status as StoredInvoiceStatus;
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate);
|
||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
||||
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue";
|
||||
|
||||
const publicUrl = invoice.publicToken
|
||||
? `${window.location.origin}/i/${invoice.publicToken}`
|
||||
: null;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!publicUrl) return;
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleRecordPayment = () => {
|
||||
const amount = parseFloat(paymentAmount);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast.error("Enter a valid payment amount");
|
||||
return;
|
||||
}
|
||||
createPayment.mutate({
|
||||
invoiceId,
|
||||
amount,
|
||||
date: new Date(),
|
||||
method: paymentMethod as Parameters<typeof createPayment.mutate>[0]["method"],
|
||||
notes: paymentNotes || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
>
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="default" className="hover-lift">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
{/* Left Column */}
|
||||
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
|
||||
{/* Invoice Header */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h2 className="text-foreground text-2xl font-bold break-words">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||
<div className="sm:inline">Issued {formatDate(invoice.issueDate)}</div>
|
||||
<div className="sm:inline sm:before:content-['_•_']">
|
||||
Due {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-left sm:text-right">
|
||||
<p className="text-muted-foreground text-sm">Total Amount</p>
|
||||
<p className="text-primary text-3xl font-bold">{formatCurrency(total)}</p>
|
||||
{totalPaid > 0 && balanceDue > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
Balance due: {formatCurrency(balanceDue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Invoice Overdue</p>
|
||||
<p className="text-sm">
|
||||
{Math.ceil(
|
||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days past due date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client & Business */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<h3 className="text-foreground text-xl font-semibold">{invoice.client.name}</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">{invoice.client.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.client.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<MapPin className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{invoice.client.addressLine1 && <div>{invoice.client.addressLine1}</div>}
|
||||
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>}
|
||||
{(invoice.client.city ??
|
||||
invoice.client.state ??
|
||||
invoice.client.postalCode) && (
|
||||
<div>
|
||||
{[
|
||||
invoice.client.city,
|
||||
invoice.client.state,
|
||||
invoice.client.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client.country && <div>{invoice.client.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{invoice.business && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.business.logoStorageKey && (
|
||||
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||
<img
|
||||
src={`/api/business-logo/${invoice.business.id}`}
|
||||
alt={`${invoice.business.name} logo`}
|
||||
className="h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-foreground text-xl font-semibold">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Mail className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm break-all">{invoice.business.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Phone className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm">{invoice.business.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item) => (
|
||||
<Card key={item.id} className="invoice-item bg-secondary">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground mb-2 text-base font-medium break-words">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDate(item.date).replace(/ /g, " ")}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">
|
||||
{item.hours.toString()} hours
|
||||
</span>
|
||||
<span className="whitespace-nowrap">@ ${item.rate}/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-primary flex-shrink-0 self-start text-lg font-semibold">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-secondary rounded-lg p-4 space-y-3">
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
|
||||
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
{totalPaid > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-sm">
|
||||
<span className="text-muted-foreground">Paid:</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
− {formatCurrency(totalPaid)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold">
|
||||
<span>Balance Due:</span>
|
||||
<span className={balanceDue <= 0 ? "text-green-600" : "text-primary"}>
|
||||
{formatCurrency(Math.max(0, balanceDue))}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
Payments
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRecordPaymentOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Record
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{paymentsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : (payments ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No payments recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(payments ?? []).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-secondary flex items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{formatCurrency(p.amount)}</span>
|
||||
<Badge variant="secondary">{methodLabel(p.method)}</Badge>
|
||||
<span className="text-muted-foreground">{formatDate(p.date)}</span>
|
||||
{p.notes && (
|
||||
<span className="text-muted-foreground truncate max-w-[200px]">
|
||||
{p.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => deletePayment.mutate({ id: p.id })}
|
||||
disabled={deletePayment.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className={cn("flex flex-col", dashboardGapClass)}>
|
||||
{storedStatus === "draft" && (
|
||||
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||
)}
|
||||
|
||||
<Card className="lg:sticky lg:top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{storedStatus === "draft" ? (
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||
)}
|
||||
|
||||
{effectiveStatus === "draft" && (
|
||||
<EnhancedSendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveStatus === "draft" && (
|
||||
<SendReminderEditor
|
||||
key={`${invoiceId}-${invoice.sendReminderAt?.toISOString() ?? "none"}`}
|
||||
invoiceId={invoiceId}
|
||||
savedReminderAt={invoice.sendReminderAt}
|
||||
formatDate={formatDate}
|
||||
isSaving={updateInvoice.isPending}
|
||||
onSave={(sendReminderAt) =>
|
||||
updateInvoice.mutate({
|
||||
id: invoiceId,
|
||||
sendReminderAt,
|
||||
})
|
||||
}
|
||||
onClear={() =>
|
||||
updateInvoice.mutate({ id: invoiceId, sendReminderAt: null })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
<EnhancedSendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
showResend={true}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Send Reminder */}
|
||||
{canSendReminder && (
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setReminderOpen(true)}
|
||||
>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Send Reminder
|
||||
</Button>
|
||||
{invoice.lastReminderSentAt && (
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||
Last sent {daysSince(invoice.lastReminderSentAt)} day
|
||||
{daysSince(invoice.lastReminderSentAt) === 1 ? "" : "s"} ago
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Link */}
|
||||
<Popover open={shareOpen} onOpenChange={setShareOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary" className="w-full">
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Share Link
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 space-y-3" align="end">
|
||||
<p className="text-sm font-semibold">Client share link</p>
|
||||
{publicUrl ? (
|
||||
<>
|
||||
<div className="bg-secondary flex items-center gap-2 rounded-md px-3 py-2">
|
||||
<p className="flex-1 truncate text-xs">{publicUrl}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 w-full"
|
||||
onClick={() => revokePublicToken.mutate({ id: invoiceId })}
|
||||
disabled={revokePublicToken.isPending}
|
||||
>
|
||||
<Link2Off className="mr-1.5 h-3.5 w-3.5" />
|
||||
Revoke link
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Generate a shareable link your client can use to view this invoice without
|
||||
logging in.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => generatePublicToken.mutate({ id: invoiceId })}
|
||||
disabled={generatePublicToken.isPending}
|
||||
>
|
||||
{generatePublicToken.isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
Generate link
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Mark as Paid */}
|
||||
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||
<Button
|
||||
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })}
|
||||
disabled={updateStatus.isPending}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
{updateStatus.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={deleteInvoice.isPending}
|
||||
className="text-destructive hover:bg-destructive/10 w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Record Payment Dialog */}
|
||||
<Dialog open={recordPaymentOpen} onOpenChange={setRecordPaymentOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Payment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Record a payment received for invoice {invoice.invoiceNumber}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pay-amount">Amount</Label>
|
||||
<Input
|
||||
id="pay-amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={paymentAmount}
|
||||
onChange={(e) => setPaymentAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pay-method">Method</Label>
|
||||
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<SelectTrigger id="pay-method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHODS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pay-notes">Notes (optional)</Label>
|
||||
<Input
|
||||
id="pay-notes"
|
||||
placeholder="e.g. cheque #1234"
|
||||
value={paymentNotes}
|
||||
onChange={(e) => setPaymentNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRecordPaymentOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRecordPayment} disabled={createPayment.isPending}>
|
||||
{createPayment.isPending ? "Saving…" : "Record Payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Send Reminder Dialog */}
|
||||
<Dialog open={reminderOpen} onOpenChange={setReminderOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Reminder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a payment reminder to {invoice.client.name} for invoice{" "}
|
||||
{invoice.invoiceNumber}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder-msg">Custom message (optional)</Label>
|
||||
<Textarea
|
||||
id="reminder-msg"
|
||||
placeholder="Leave blank to use the default reminder message."
|
||||
rows={4}
|
||||
value={reminderMessage}
|
||||
onChange={(e) => setReminderMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReminderOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
sendReminder.mutate({
|
||||
id: invoiceId,
|
||||
customMessage: reminderMessage || undefined,
|
||||
})
|
||||
}
|
||||
disabled={sendReminder.isPending}
|
||||
>
|
||||
{sendReminder.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…</>
|
||||
) : (
|
||||
<><Bell className="mr-2 h-4 w-4" /> Send Reminder</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteInvoice.mutate({ id: invoiceId })}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
{deleteInvoice.isPending ? "Deleting…" : "Delete Invoice"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
function SendReminderEditor({
|
||||
invoiceId,
|
||||
savedReminderAt,
|
||||
formatDate,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClear,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
savedReminderAt: Date | null | undefined;
|
||||
formatDate: (date: Date) => string;
|
||||
isSaving: boolean;
|
||||
onSave: (sendReminderAt: Date | null) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [sendReminderAt, setSendReminderAt] = useState<Date | undefined>(() =>
|
||||
savedReminderAt ? new Date(savedReminderAt) : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<Label htmlFor={`send-reminder-at-${invoiceId}`}>Remind me to send</Label>
|
||||
<DatePicker
|
||||
date={sendReminderAt}
|
||||
onDateChange={setSendReminderAt}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onSave(sendReminderAt ?? null)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save reminder
|
||||
</Button>
|
||||
{sendReminderAt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSendReminderAt(undefined);
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{savedReminderAt ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(savedReminderAt) <= new Date()
|
||||
? "Reminder is due — time to send this invoice."
|
||||
: `Scheduled for ${formatDate(savedReminderAt)}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceViewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (id === "new") router.replace("/dashboard/invoices/new");
|
||||
}, [id, router]);
|
||||
|
||||
if (id === "new") {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InvoiceViewContent invoiceId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGapClass,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { EmailComposer } from "~/components/forms/email-composer";
|
||||
import { EmailPreview } from "~/components/forms/email-preview";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
Eye,
|
||||
Edit3,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
function SendEmailPageSkeleton() {
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice email"
|
||||
/>
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
|
||||
<div className="bg-muted h-96 animate-pulse" />
|
||||
</div>
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
<div className="bg-muted h-64 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
function plainTextToHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function normalizeEmailNoteHtml(value: string) {
|
||||
const visibleText = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ |\u00a0/g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
return visibleText ? value.trim() : "";
|
||||
}
|
||||
|
||||
export default function SendEmailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const invoiceId = params.id as string;
|
||||
|
||||
// State management
|
||||
const [activeTab, setActiveTab] = useState("compose");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
// Email content state
|
||||
const [subject, setSubject] = useState("");
|
||||
const [emailContent, setEmailContent] = useState("");
|
||||
const [ccEmail, setCcEmail] = useState("");
|
||||
const [bccEmail, setBccEmail] = useState("");
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
|
||||
// Fetch invoice data
|
||||
const { data: invoiceData, isLoading: invoiceLoading } =
|
||||
api.invoices.getById.useQuery({
|
||||
id: invoiceId,
|
||||
});
|
||||
|
||||
// Get utils for cache invalidation
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Email sending mutation
|
||||
const sendEmailMutation = api.email.sendInvoice.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Email sent successfully!", {
|
||||
description: data.message,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Navigate back to invoice view
|
||||
router.push(`/dashboard/invoices/${invoiceId}`);
|
||||
|
||||
// Refresh invoice data
|
||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||
},
|
||||
onError: (error) => {
|
||||
let errorMessage = "Failed to send invoice email";
|
||||
let errorDescription = error.message;
|
||||
let canRetry = false;
|
||||
|
||||
if (error.message.includes("Invalid recipient")) {
|
||||
errorMessage = "Invalid Email Address";
|
||||
errorDescription =
|
||||
"Please check the client's email address and try again.";
|
||||
} else if (error.message.includes("domain not verified")) {
|
||||
errorMessage = "Email Configuration Issue";
|
||||
errorDescription = "Please contact support to configure email sending.";
|
||||
} else if (error.message.includes("rate limit")) {
|
||||
errorMessage = "Too Many Emails";
|
||||
errorDescription = "Please wait a moment before sending another email.";
|
||||
canRetry = true;
|
||||
} else if (error.message.includes("no email address")) {
|
||||
errorMessage = "No Email Address";
|
||||
errorDescription = "This client doesn't have an email address on file.";
|
||||
} else if (
|
||||
error.message.includes("unavailable") ||
|
||||
error.message.includes("timeout")
|
||||
) {
|
||||
errorMessage = "Service Temporarily Unavailable";
|
||||
errorDescription =
|
||||
"The email service is temporarily unavailable. Please try again.";
|
||||
canRetry = true;
|
||||
} else {
|
||||
canRetry = true; // Allow retry for unknown errors
|
||||
}
|
||||
|
||||
toast.error(errorMessage, {
|
||||
description:
|
||||
canRetry && retryCount < 2
|
||||
? `${errorDescription} You can retry this operation.`
|
||||
: errorDescription,
|
||||
duration: 6000,
|
||||
action:
|
||||
canRetry && retryCount < 2
|
||||
? {
|
||||
label: "Retry",
|
||||
onClick: () => handleRetry(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
setIsSending(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Transform invoice data for components
|
||||
const invoice = useMemo(() => {
|
||||
return invoiceData
|
||||
? {
|
||||
id: invoiceData.id,
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
issueDate: invoiceData.issueDate,
|
||||
dueDate: invoiceData.dueDate,
|
||||
status: invoiceData.status,
|
||||
totalAmount: invoiceData.totalAmount,
|
||||
taxRate: invoiceData.taxRate,
|
||||
currency: invoiceData.currency,
|
||||
emailMessage: invoiceData.emailMessage,
|
||||
client: invoiceData.client
|
||||
? {
|
||||
name: invoiceData.client.name,
|
||||
email: invoiceData.client.email,
|
||||
}
|
||||
: undefined,
|
||||
business: invoiceData.business
|
||||
? {
|
||||
id: invoiceData.business.id,
|
||||
name: invoiceData.business.name,
|
||||
nickname: invoiceData.business.nickname,
|
||||
email: invoiceData.business.email,
|
||||
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||
logoMimeType: invoiceData.business.logoMimeType,
|
||||
}
|
||||
: undefined,
|
||||
items: invoiceData.items?.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
}, [invoiceData]);
|
||||
|
||||
const normalizedCustomMessage = useMemo(
|
||||
() => normalizeEmailNoteHtml(customMessage),
|
||||
[customMessage],
|
||||
);
|
||||
|
||||
// Initialize email content when invoice loads
|
||||
useEffect(() => {
|
||||
if (!invoice || isInitialized) return;
|
||||
|
||||
// Set default subject
|
||||
const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSubject(defaultSubject);
|
||||
|
||||
// Set default content (empty since template handles everything)
|
||||
const defaultContent = ``;
|
||||
|
||||
setEmailContent(defaultContent);
|
||||
setCustomMessage(
|
||||
invoice.emailMessage ? plainTextToHtml(invoice.emailMessage) : "",
|
||||
);
|
||||
setIsInitialized(true);
|
||||
}, [invoice, isInitialized]);
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
|
||||
toast.error("No email address", {
|
||||
description: "This client doesn't have an email address on file.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
toast.error("Subject required", {
|
||||
description: "Please enter an email subject before sending.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmSendEmail = async () => {
|
||||
setShowConfirmDialog(false);
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
await sendEmailMutation.mutateAsync({
|
||||
invoiceId,
|
||||
customSubject: subject,
|
||||
customContent: emailContent,
|
||||
customMessage: normalizedCustomMessage,
|
||||
useHtml: true,
|
||||
ccEmails: ccEmail.trim() || undefined,
|
||||
bccEmails: bccEmail.trim() || undefined,
|
||||
});
|
||||
setRetryCount(0); // Reset retry count on success
|
||||
} catch {
|
||||
// Error handling is done in the mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
if (retryCount < 2) {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
void confirmSendEmail();
|
||||
}
|
||||
};
|
||||
|
||||
const fromEmail = invoice?.business?.email ?? NOREPLY_EMAIL;
|
||||
const toEmail = invoice?.client?.email ?? "";
|
||||
|
||||
const canSend =
|
||||
!isSending && subject.trim() && toEmail && toEmail.trim() !== "";
|
||||
|
||||
if (invoiceLoading) {
|
||||
return <SendEmailPageSkeleton />;
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>Invoice not found.</AlertDescription>
|
||||
</Alert>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={`Send Invoice ${invoice.invoiceNumber}`}
|
||||
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"} • ${new Intl.DateTimeFormat(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
).format(new Date())}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Invoice
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{/* Warning for missing email */}
|
||||
{(!toEmail || toEmail.trim() === "") && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This client doesn't have an email address. Please add an email
|
||||
address to the client before sending the invoice.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
|
||||
<div className="lg:col-span-2">
|
||||
<PageTabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="compose" className="gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Compose
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview" className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent value="compose">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Compose Email
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isInitialized ? (
|
||||
<EmailComposer
|
||||
subject={subject}
|
||||
onSubjectChange={setSubject}
|
||||
content={emailContent}
|
||||
onContentChange={setEmailContent}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
ccEmail={ccEmail}
|
||||
onCcEmailChange={setCcEmail}
|
||||
bccEmail={bccEmail}
|
||||
onBccEmailChange={setBccEmail}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex h-[400px] items-center justify-center border">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin border-2 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Initializing email content...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
|
||||
<PageTabsContent value="preview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Email Preview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<EmailPreview
|
||||
subject={subject}
|
||||
fromEmail={fromEmail}
|
||||
toEmail={toEmail}
|
||||
ccEmail={ccEmail}
|
||||
bccEmail={bccEmail}
|
||||
content={emailContent}
|
||||
customMessage={normalizedCustomMessage}
|
||||
invoice={invoice}
|
||||
className="min-w-0 border-0"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={cn(dashboardGapClass, "flex flex-col")}>
|
||||
{/* Invoice Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Client
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{invoice.client?.name ?? "Client"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Issue Date
|
||||
</Label>
|
||||
<p className="text-sm">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(invoice.issueDate))}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Status
|
||||
</Label>
|
||||
<Badge variant="outline">{invoice.status}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Email Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
From
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">{fromEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
To
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">
|
||||
{toEmail || "No email address"}
|
||||
</p>
|
||||
</div>
|
||||
{ccEmail && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
CC
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">{ccEmail}</p>
|
||||
</div>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
BCC
|
||||
</Label>
|
||||
<p className="font-mono text-sm break-all">{bccEmail}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Subject
|
||||
</Label>
|
||||
<p className="text-sm break-words">{subject || "No subject"}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-sm font-medium">
|
||||
Attachment
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{activeTab === "compose" && (
|
||||
<Button
|
||||
onClick={() => setActiveTab("preview")}
|
||||
disabled={!subject.trim()}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview Email
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeTab === "preview" && (
|
||||
<Button
|
||||
onClick={() => setActiveTab("compose")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit Email
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Bar */}
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 p-2">
|
||||
<Send className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground font-medium">Send Invoice</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Email invoice to {invoice.client?.name ?? "client"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/invoices/${invoiceId}`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
disabled={!canSend || isSending}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<span className="hidden sm:inline">Sending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Send Email</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send this invoice email to <strong>{toEmail}</strong>
|
||||
{ccEmail && (
|
||||
<>
|
||||
{" "}
|
||||
with CC to <strong>{ccEmail}</strong>
|
||||
</>
|
||||
)}
|
||||
{bccEmail && (
|
||||
<>
|
||||
{" "}
|
||||
and BCC to <strong>{bccEmail}</strong>
|
||||
</>
|
||||
)}
|
||||
?
|
||||
</DialogDescription>
|
||||
{retryCount > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Retry attempt {retryCount} of 2
|
||||
</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/30 space-y-2 border p-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Subject: </span>
|
||||
<span className="font-medium">{subject}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Attachment: </span>
|
||||
<span>invoice-{invoice.invoiceNumber}.pdf</span>
|
||||
</div>
|
||||
{normalizedCustomMessage && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Email note: </span>
|
||||
<span>Included</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmSendEmail} variant="default">
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
"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,
|
||||
Plus,
|
||||
} 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,
|
||||
);
|
||||
|
||||
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>
|
||||
{invoice.status === "draft" ? (
|
||||
<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"
|
||||
title="Edit invoice"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
disabled
|
||||
title="Only draft invoices can be edited"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<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..."
|
||||
initialSorting={[{ id: "issueDate", desc: true }]}
|
||||
filterableColumns={filterableColumns}
|
||||
emptyTitle="Create your first invoice"
|
||||
emptyDescription="Send professional invoices and track payments from one place."
|
||||
emptyIcon={<FileText className="h-6 w-6" />}
|
||||
emptyAction={
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ImportPage() {
|
||||
redirect("/dashboard/settings?tab=data");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
|
||||
import InvoiceForm from "~/components/forms/invoice-form";
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<InvoiceForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { Plus } from "lucide-react";
|
||||
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||
import { DataTableSkeleton } from "~/components/data/data-table";
|
||||
|
||||
// Invoices Table Component
|
||||
async function InvoicesTable() {
|
||||
const invoices = await api.invoices.getAll();
|
||||
|
||||
return <InvoicesDataTable invoices={invoices} />;
|
||||
}
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
return (
|
||||
<DashboardPage>
|
||||
<DashboardPageHeader
|
||||
title="Invoices"
|
||||
description="Manage your invoices and track payments"
|
||||
>
|
||||
<Button asChild variant="default" className="hover-lift shadow-md">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Create Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||
<InvoicesTable />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const SCHEDULES = [
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "biweekly", label: "Every 2 weeks" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "quarterly", label: "Quarterly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
] as const;
|
||||
|
||||
type Schedule = (typeof SCHEDULES)[number]["value"];
|
||||
|
||||
interface RecurringItemInput {
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
interface RecurringFormState {
|
||||
name: string;
|
||||
clientId: string;
|
||||
businessId: string;
|
||||
schedule: Schedule;
|
||||
invoicePrefix: string;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
notes: string;
|
||||
emailMessage: string;
|
||||
items: RecurringItemInput[];
|
||||
}
|
||||
|
||||
const defaultForm = (): RecurringFormState => ({
|
||||
name: "",
|
||||
clientId: "",
|
||||
businessId: "",
|
||||
schedule: "monthly",
|
||||
invoicePrefix: "#",
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
notes: "",
|
||||
emailMessage: "",
|
||||
items: [{ description: "", hours: 0, rate: 0 }],
|
||||
});
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
function scheduleLabel(s: string) {
|
||||
return SCHEDULES.find((x) => x.value === s)?.label ?? s;
|
||||
}
|
||||
|
||||
function RecurringForm({
|
||||
form,
|
||||
setForm,
|
||||
clients,
|
||||
businesses,
|
||||
}: {
|
||||
form: RecurringFormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<RecurringFormState>>;
|
||||
clients: { id: string; name: string }[];
|
||||
businesses: { id: string; name: string }[];
|
||||
}) {
|
||||
const addItem = () =>
|
||||
setForm((f) => ({ ...f, items: [...f.items, { description: "", hours: 0, rate: 0 }] }));
|
||||
|
||||
const removeItem = (idx: number) =>
|
||||
setForm((f) => ({ ...f, items: f.items.filter((_, i) => i !== idx) }));
|
||||
|
||||
const updateItem = (idx: number, field: keyof RecurringItemInput, value: string | number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
items: f.items.map((item, i) => (i === idx ? { ...item, [field]: value } : item)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Template name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Monthly retainer"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
value={form.clientId}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, clientId: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Business (optional)</Label>
|
||||
<Select
|
||||
value={form.businessId}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, businessId: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{businesses.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Schedule</Label>
|
||||
<Select
|
||||
value={form.schedule}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, schedule: v as Schedule }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Currency</Label>
|
||||
<Input
|
||||
maxLength={3}
|
||||
placeholder="USD"
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value.toUpperCase() }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tax rate (%)</Label>
|
||||
<NumberInput
|
||||
value={form.taxRate}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taxRate: v ?? 0 }))}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="space-y-2">
|
||||
<Label>Line items</Label>
|
||||
{form.items.map((item, idx) => (
|
||||
<div key={idx} className="bg-secondary space-y-2 rounded-lg p-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Description"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(idx, "description", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{form.items.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive h-8 w-8 p-0 shrink-0"
|
||||
onClick={() => removeItem(idx)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(v) => updateItem(idx, "hours", v ?? 0)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Rate ($/hr)</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(v) => updateItem(idx, "rate", v ?? 0)}
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="outline" onClick={addItem}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Notes shown on generated invoices"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecurringInvoicesPage() {
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<RecurringFormState>(defaultForm());
|
||||
|
||||
const { data: recurring, isLoading } = api.recurringInvoices.getAll.useQuery();
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
const { data: businesses = [] } = api.businesses.getAll.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const invalidate = () => void utils.recurringInvoices.getAll.invalidate();
|
||||
|
||||
const create = api.recurringInvoices.create.useMutation({
|
||||
onSuccess: () => { toast.success("Recurring invoice created"); setCreateOpen(false); setForm(defaultForm()); invalidate(); },
|
||||
onError: (e) => toast.error(e.message ?? "Failed to create"),
|
||||
});
|
||||
|
||||
const update = api.recurringInvoices.update.useMutation({
|
||||
onSuccess: () => { toast.success("Updated"); setEditId(null); setForm(defaultForm()); invalidate(); },
|
||||
onError: (e) => toast.error(e.message ?? "Failed to update"),
|
||||
});
|
||||
|
||||
const pause = api.recurringInvoices.pause.useMutation({
|
||||
onSuccess: () => { toast.success("Paused"); invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const resume = api.recurringInvoices.resume.useMutation({
|
||||
onSuccess: () => { toast.success("Resumed"); invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const del = api.recurringInvoices.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Deleted"); setDeleteId(null); invalidate(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const generateNow = api.recurringInvoices.generateNow.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Invoice generated");
|
||||
invalidate();
|
||||
router.push(`/dashboard/invoices/${data.invoiceId}`);
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to generate"),
|
||||
});
|
||||
|
||||
function handleOpenEdit(rec: NonNullable<typeof recurring>[number]) {
|
||||
setForm({
|
||||
name: rec.name,
|
||||
clientId: rec.clientId,
|
||||
businessId: rec.businessId ?? "",
|
||||
schedule: rec.schedule as Schedule,
|
||||
invoicePrefix: rec.invoicePrefix ?? "#",
|
||||
taxRate: rec.taxRate,
|
||||
currency: rec.currency,
|
||||
notes: rec.notes ?? "",
|
||||
emailMessage: rec.emailMessage ?? "",
|
||||
items: rec.items.map((i) => ({
|
||||
description: i.description,
|
||||
hours: i.hours,
|
||||
rate: i.rate,
|
||||
})),
|
||||
});
|
||||
setEditId(rec.id);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const payload = {
|
||||
...form,
|
||||
businessId: form.businessId || undefined,
|
||||
notes: form.notes || undefined,
|
||||
emailMessage: form.emailMessage || undefined,
|
||||
items: form.items.map((item, idx) => ({ ...item, position: idx })),
|
||||
};
|
||||
if (editId) {
|
||||
update.mutate({ id: editId, ...payload });
|
||||
} else {
|
||||
create.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitting = create.isPending || update.isPending;
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-24">
|
||||
<DashboardPageHeader
|
||||
title="Recurring Invoices"
|
||||
description="Schedule automatic invoice generation"
|
||||
>
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New recurring
|
||||
</Button>
|
||||
</DashboardPageHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (recurring ?? []).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={<RefreshCw className="h-6 w-6" />}
|
||||
title="Create your first recurring invoice"
|
||||
description="Automatically generate draft invoices on a schedule you choose."
|
||||
action={
|
||||
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create recurring invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(recurring ?? []).map((rec) => (
|
||||
<Card key={rec.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-semibold">{rec.name}</p>
|
||||
<Badge variant={rec.status === "active" ? "default" : "secondary"}>
|
||||
{rec.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{rec.client.name} · {scheduleLabel(rec.schedule)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Next: {formatDate(rec.nextDueAt)}
|
||||
{rec.lastGeneratedAt && (
|
||||
<> · Last generated: {formatDate(rec.lastGeneratedAt)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => generateNow.mutate({ id: rec.id })}
|
||||
disabled={generateNow.isPending}
|
||||
>
|
||||
<Zap className="mr-1.5 h-3.5 w-3.5" />
|
||||
Generate now
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenEdit(rec)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{rec.status === "active" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => pause.mutate({ id: rec.id })}
|
||||
disabled={pause.isPending}
|
||||
>
|
||||
<Pause className="mr-1.5 h-3.5 w-3.5" />
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resume.mutate({ id: rec.id })}
|
||||
disabled={resume.isPending}
|
||||
>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setDeleteId(rec.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Dialog */}
|
||||
<Dialog
|
||||
open={createOpen || editId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit recurring invoice" : "New recurring invoice"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure the template. Invoices will be generated as drafts on the selected schedule.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RecurringForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
clients={clients}
|
||||
businesses={businesses}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setCreateOpen(false); setEditId(null); setForm(defaultForm()); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !form.name || !form.clientId}>
|
||||
{isSubmitting ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…</>
|
||||
) : editId ? (
|
||||
<><Check className="mr-2 h-4 w-4" /> Save changes</>
|
||||
) : (
|
||||
<><Plus className="mr-2 h-4 w-4" /> Create</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete recurring invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will stop automatic generation. Already-generated invoices are not affected.
|
||||
</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>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, type RouterOutputs } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage } from "~/components/layout/dashboard-page";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
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 } 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,
|
||||
};
|
||||
|
||||
type InvoiceTemplate = RouterOutputs["invoiceTemplates"]["getAll"][number];
|
||||
|
||||
interface TemplateListProps {
|
||||
items: InvoiceTemplate[];
|
||||
type: "notes" | "terms";
|
||||
isLoading: boolean;
|
||||
onCreate: (type: "notes" | "terms") => void;
|
||||
onEdit: (template: InvoiceTemplate) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function TemplateList({
|
||||
items,
|
||||
type,
|
||||
isLoading,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TemplateListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => onCreate(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((template) => (
|
||||
<Card key={template.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">{template.name}</p>
|
||||
{template.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">
|
||||
{template.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onEdit(template)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-8 w-8 p-0"
|
||||
onClick={() => onDelete(template.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: InvoiceTemplate) => {
|
||||
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");
|
||||
|
||||
return (
|
||||
<DashboardPage className="pb-6">
|
||||
<DashboardPageHeader
|
||||
title="Invoice Templates"
|
||||
description="Reusable notes and payment terms for your invoices"
|
||||
/>
|
||||
|
||||
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="notes">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Notes (
|
||||
{notesTemplates.length})
|
||||
</PageTabsTrigger>
|
||||
<PageTabsTrigger value="terms">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Terms (
|
||||
{termsTemplates.length})
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
<PageTabsContent value="notes">
|
||||
<TemplateList
|
||||
items={notesTemplates}
|
||||
type="notes"
|
||||
isLoading={isLoading}
|
||||
onCreate={handleOpen}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteId}
|
||||
/>
|
||||
</PageTabsContent>
|
||||
<PageTabsContent value="terms">
|
||||
<TemplateList
|
||||
items={termsTemplates}
|
||||
type="terms"
|
||||
isLoading={isLoading}
|
||||
onCreate={handleOpen}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setDeleteId}
|
||||
/>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
|
||||
{/* 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>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user