feat: add recurring invoices, public links, time tracker, payments, reminders

- Recurring invoices: schedule-based auto-generation with CRUD UI at /dashboard/invoices/recurring and POST /api/cron/generate-recurring cron endpoint
- Public invoice link: generate/revoke shareable /i/[token] page for unauthenticated clients with PDF download
- Live time tracker: localStorage-persisted timer widget in invoice editor that appends a line item on stop (rounds to nearest 0.25h)
- Partial payment tracking: record payments per invoice, auto-mark paid when fully covered, balance due display
- Send reminder: email reminder via Resend with custom message dialog and last-sent indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:17:58 -04:00
co-authored by Claude Sonnet 4.6
parent 47a0ccc88c
commit f0c34160df
16 changed files with 2222 additions and 195 deletions
+471 -188
View File
@@ -1,13 +1,32 @@
"use client";
import { DollarSign, Edit, Loader2, Trash2 } from "lucide-react";
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 } from "next/navigation";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
import { StatusBadge } from "~/components/data/status-badge";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Dialog,
@@ -17,7 +36,22 @@ import {
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 {
getEffectiveInvoiceStatus,
isInvoiceOverdue,
@@ -28,96 +62,154 @@ import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
import { PDFDownloadButton } from "./_components/pdf-download-button";
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
import {
AlertTriangle,
Building,
Check,
FileText,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
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 [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();
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 successfully");
toast.success("Invoice deleted");
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message ?? "Failed to delete invoice");
},
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
});
const updateStatus = api.invoices.updateStatus.useMutation({
onSuccess: (data) => {
toast.success(data.message);
void utils.invoices.getById.invalidate({ id: invoiceId });
},
onError: (error) => {
toast.error(error.message ?? "Failed to update invoice status");
invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to update status"),
});
const handleDelete = () => {
setDeleteDialogOpen(true);
};
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 handleMarkAsPaid = () => {
updateStatus.mutate({
id: invoiceId,
status: "paid",
});
};
const deletePayment = api.payments.delete.useMutation({
onSuccess: () => {
toast.success("Payment removed");
invalidate();
},
onError: (e) => toast.error(e.message ?? "Failed to remove payment"),
});
const confirmDelete = () => {
deleteInvoice.mutate({ id: invoiceId });
};
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"),
});
if (isLoading) {
return <InvoiceDetailsSkeleton />;
}
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"),
});
if (!invoice) {
notFound();
}
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 formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
};
if (isLoading) return <InvoiceDetailsSkeleton />;
if (!invoice) notFound();
const formatCurrency = (amount: number, currency = invoice.currency) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
};
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(date),
);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
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 effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate);
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue";
const getStatusType = (): StatusType => {
return effectiveStatus;
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 (
@@ -127,20 +219,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
description="View and manage invoice information"
variant="gradient"
>
<PDFDownloadButton
invoiceId={invoice.id}
variant="outline"
className="hover-lift"
/>
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
<Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-5 w-5" />
<span>Edit</span>
Edit
</Link>
</Button>
</PageHeader>
{/* Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
@@ -154,24 +241,23 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<h2 className="text-foreground text-2xl font-bold break-words">
{invoice.invoiceNumber}
</h2>
<StatusBadge status={getStatusType()} />
<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">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>
<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>
@@ -188,8 +274,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<p className="font-medium">Invoice Overdue</p>
<p className="text-sm">
{Math.ceil(
(new Date().getTime() -
new Date(invoice.dueDate).getTime()) /
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
(1000 * 60 * 60 * 24),
)}{" "}
days past due date
@@ -200,9 +285,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</Card>
)}
{/* Client & Business Info */}
{/* Client & Business */}
<div className="grid gap-4 sm:grid-cols-2">
{/* Client Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
@@ -211,24 +295,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-foreground text-xl font-semibold">
{invoice.client.name}
</h3>
</div>
<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>
<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">
@@ -237,19 +313,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<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.addressLine1 && <div>{invoice.client.addressLine1}</div>}
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>}
{(invoice.client.city ??
invoice.client.state ??
invoice.client.postalCode) && (
@@ -263,9 +334,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
.join(", ")}
</div>
)}
{invoice.client.country && (
<div>{invoice.client.country}</div>
)}
{invoice.client.country && <div>{invoice.client.country}</div>}
</div>
</div>
)}
@@ -273,7 +342,6 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardContent>
</Card>
{/* Business Information */}
{invoice.business && (
<Card>
<CardHeader className="pb-3">
@@ -283,32 +351,24 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-foreground text-xl font-semibold">
{invoice.business.name}
</h3>
</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>
<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>
<span className="text-sm">{invoice.business.phone}</span>
</div>
)}
</div>
@@ -326,72 +386,126 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.items.map((item, _index) => (
{invoice.items.map((item) => (
<Card key={item.id} className="invoice-item bg-secondary">
<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">
<p className="text-foreground mb-2 text-base font-medium break-words">
{item.description}
</p>
<div className="text-muted-foreground text-sm">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span className="whitespace-nowrap">
{formatDate(item.date).replace(/ /g, "\u00A0")}
</span>
<span className="whitespace-nowrap">
{item.hours.toString().replace(/ /g, "\u00A0")}
&nbsp;hours
</span>
<span className="whitespace-nowrap">
@&nbsp;${item.rate}/hr
</span>
</div>
</div>
</div>
<div className="flex-shrink-0 self-start">
<p className="text-primary text-lg font-semibold">
{formatCurrency(item.amount)}
</p>
<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()}&nbsp;hours
</span>
<span className="whitespace-nowrap">@&nbsp;${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">
<div className="space-y-3">
<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">Subtotal:</span>
<span className="font-medium">
{formatCurrency(subtotal)}
</span>
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
<span className="font-medium">{formatCurrency(taxAmount)}</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)}
)}
<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 text-lg font-bold">
<span>Total:</span>
<span className="text-primary">
{formatCurrency(total)}
</span>
</div>
</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>
@@ -399,9 +513,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
</CardContent>
</Card>
)}
@@ -425,14 +537,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</Button>
{invoice.items && invoice.client && (
<PDFDownloadButton
invoiceId={invoice.id}
className="w-full"
variant="secondary"
/>
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
)}
{/* Send Invoice Button - Show for draft, sent, and overdue */}
{effectiveStatus === "draft" && (
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
@@ -441,8 +548,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
/>
)}
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
<EnhancedSendInvoiceButton
invoiceId={invoice.id}
className="w-full"
@@ -451,11 +557,92 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
/>
)}
{/* Manual Status Updates */}
{(effectiveStatus === "sent" ||
effectiveStatus === "overdue") && (
{/* 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={handleMarkAsPaid}
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })}
disabled={updateStatus.isPending}
variant="secondary"
className="w-full"
@@ -471,7 +658,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
<Button
variant="secondary"
onClick={handleDelete}
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteInvoice.isPending}
className="text-destructive hover:bg-destructive/10 w-full"
>
@@ -483,15 +670,115 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div>
</div>
{/* Delete Confirmation Dialog */}
{/* 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 and will permanently remove the invoice and all its data.
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -504,10 +791,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
onClick={() => deleteInvoice.mutate({ id: invoiceId })}
disabled={deleteInvoice.isPending}
>
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
{deleteInvoice.isPending ? "Deleting" : "Delete Invoice"}
</Button>
</DialogFooter>
</DialogContent>
@@ -521,14 +808,10 @@ export default function InvoiceViewPage() {
const router = useRouter();
const id = params.id as string;
// Handle /invoices/new route - redirect to dedicated new page
useEffect(() => {
if (id === "new") {
router.replace("/dashboard/invoices/new");
}
if (id === "new") router.replace("/dashboard/invoices/new");
}, [id, router]);
// Don't render anything if we're redirecting
if (id === "new") {
return (
<div className="flex h-96 items-center justify-center">