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:
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "beenvoice-dev",
|
||||||
|
"runtimeExecutable": "bun",
|
||||||
|
"runtimeArgs": ["dev"],
|
||||||
|
"port": 3000,
|
||||||
|
"autoPort": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { generateDueRecurringInvoices } from "~/server/api/routers/recurring-invoices";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
const secret = env.CRON_SECRET;
|
||||||
|
|
||||||
|
if (secret && authHeader !== `Bearer ${secret}`) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = await generateDueRecurringInvoices(db);
|
||||||
|
return NextResponse.json({ generated });
|
||||||
|
}
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { notFound, useParams, useRouter } from "next/navigation";
|
import { notFound, useParams, useRouter } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
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 { PageHeader } from "~/components/layout/page-header";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -17,7 +36,22 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} 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 { Separator } from "~/components/ui/separator";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
import {
|
import {
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
isInvoiceOverdue,
|
isInvoiceOverdue,
|
||||||
@@ -28,96 +62,154 @@ import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
|||||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||||
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||||
|
|
||||||
import {
|
const PAYMENT_METHODS = [
|
||||||
AlertTriangle,
|
{ value: "cash", label: "Cash" },
|
||||||
Building,
|
{ value: "check", label: "Check" },
|
||||||
Check,
|
{ value: "bank_transfer", label: "Bank Transfer" },
|
||||||
FileText,
|
{ value: "credit_card", label: "Credit Card" },
|
||||||
Mail,
|
{ value: "paypal", label: "PayPal" },
|
||||||
MapPin,
|
{ value: "other", label: "Other" },
|
||||||
Phone,
|
] as const;
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
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 }) {
|
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
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({
|
const { data: invoice, isLoading } = api.invoices.getById.useQuery({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
});
|
});
|
||||||
|
const { data: payments, isLoading: paymentsLoading } =
|
||||||
|
api.payments.getByInvoice.useQuery({ invoiceId });
|
||||||
const utils = api.useUtils();
|
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({
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Invoice deleted successfully");
|
toast.success("Invoice deleted");
|
||||||
router.push("/dashboard/invoices");
|
router.push("/dashboard/invoices");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||||
toast.error(error.message ?? "Failed to delete invoice");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
invalidate();
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message ?? "Failed to update invoice status");
|
|
||||||
},
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? "Failed to update status"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = () => {
|
const createPayment = api.payments.create.useMutation({
|
||||||
setDeleteDialogOpen(true);
|
onSuccess: () => {
|
||||||
};
|
toast.success("Payment recorded");
|
||||||
|
setRecordPaymentOpen(false);
|
||||||
|
setPaymentAmount("");
|
||||||
|
setPaymentMethod("other");
|
||||||
|
setPaymentNotes("");
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message ?? "Failed to record payment"),
|
||||||
|
});
|
||||||
|
|
||||||
const handleMarkAsPaid = () => {
|
const deletePayment = api.payments.delete.useMutation({
|
||||||
updateStatus.mutate({
|
onSuccess: () => {
|
||||||
id: invoiceId,
|
toast.success("Payment removed");
|
||||||
status: "paid",
|
invalidate();
|
||||||
});
|
},
|
||||||
};
|
onError: (e) => toast.error(e.message ?? "Failed to remove payment"),
|
||||||
|
});
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const generatePublicToken = api.invoices.generatePublicToken.useMutation({
|
||||||
deleteInvoice.mutate({ id: invoiceId });
|
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) {
|
const revokePublicToken = api.invoices.revokePublicToken.useMutation({
|
||||||
return <InvoiceDetailsSkeleton />;
|
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) {
|
const sendReminder = api.invoices.sendReminder.useMutation({
|
||||||
notFound();
|
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) => {
|
if (isLoading) return <InvoiceDetailsSkeleton />;
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
if (!invoice) notFound();
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(new Date(date));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number, currency = invoice.currency) => {
|
const formatDate = (date: Date) =>
|
||||||
return new Intl.NumberFormat("en-US", {
|
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
|
||||||
style: "currency",
|
new Date(date),
|
||||||
currency,
|
);
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
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 storedStatus = invoice.status as StoredInvoiceStatus;
|
||||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, invoice.dueDate);
|
||||||
storedStatus,
|
|
||||||
invoice.dueDate,
|
|
||||||
);
|
|
||||||
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate);
|
||||||
|
const canSendReminder = effectiveStatus === "sent" || effectiveStatus === "overdue";
|
||||||
|
|
||||||
const getStatusType = (): StatusType => {
|
const publicUrl = invoice.publicToken
|
||||||
return effectiveStatus;
|
? `${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 (
|
return (
|
||||||
@@ -127,20 +219,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
description="View and manage invoice information"
|
description="View and manage invoice information"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
>
|
>
|
||||||
<PDFDownloadButton
|
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
|
||||||
invoiceId={invoice.id}
|
|
||||||
variant="outline"
|
|
||||||
className="hover-lift"
|
|
||||||
/>
|
|
||||||
<Button asChild variant="default" className="hover-lift">
|
<Button asChild variant="default" className="hover-lift">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-5 w-5" />
|
<Edit className="mr-2 h-5 w-5" />
|
||||||
<span>Edit</span>
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<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">
|
<h2 className="text-foreground text-2xl font-bold break-words">
|
||||||
{invoice.invoiceNumber}
|
{invoice.invoiceNumber}
|
||||||
</h2>
|
</h2>
|
||||||
<StatusBadge status={getStatusType()} />
|
<StatusBadge status={effectiveStatus} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||||
<div className="sm:inline">
|
<div className="sm:inline">Issued {formatDate(invoice.issueDate)}</div>
|
||||||
Issued {formatDate(invoice.issueDate)}
|
|
||||||
</div>
|
|
||||||
<div className="sm:inline sm:before:content-['_•_']">
|
<div className="sm:inline sm:before:content-['_•_']">
|
||||||
Due {formatDate(invoice.dueDate)}
|
Due {formatDate(invoice.dueDate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-left sm:text-right">
|
<div className="flex-shrink-0 text-left sm:text-right">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">Total Amount</p>
|
||||||
Total Amount
|
<p className="text-primary text-3xl font-bold">{formatCurrency(total)}</p>
|
||||||
</p>
|
{totalPaid > 0 && balanceDue > 0 && (
|
||||||
<p className="text-primary text-3xl font-bold">
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
{formatCurrency(total)}
|
Balance due: {formatCurrency(balanceDue)}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,8 +274,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<p className="font-medium">Invoice Overdue</p>
|
<p className="font-medium">Invoice Overdue</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{Math.ceil(
|
{Math.ceil(
|
||||||
(new Date().getTime() -
|
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||||
new Date(invoice.dueDate).getTime()) /
|
|
||||||
(1000 * 60 * 60 * 24),
|
(1000 * 60 * 60 * 24),
|
||||||
)}{" "}
|
)}{" "}
|
||||||
days past due date
|
days past due date
|
||||||
@@ -200,9 +285,8 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Client & Business Info */}
|
{/* Client & Business */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* Client Information */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -211,24 +295,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<h3 className="text-foreground text-xl font-semibold">{invoice.client.name}</h3>
|
||||||
<h3 className="text-foreground text-xl font-semibold">
|
|
||||||
{invoice.client.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.client.email && (
|
{invoice.client.email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm break-all">
|
<span className="text-sm break-all">{invoice.client.email}</span>
|
||||||
{invoice.client.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invoice.client.phone && (
|
{invoice.client.phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
@@ -237,19 +313,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<span className="text-sm">{invoice.client.phone}</span>
|
<span className="text-sm">{invoice.client.phone}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<MapPin className="text-primary h-4 w-4" />
|
<MapPin className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{invoice.client.addressLine1 && (
|
{invoice.client.addressLine1 && <div>{invoice.client.addressLine1}</div>}
|
||||||
<div>{invoice.client.addressLine1}</div>
|
{invoice.client.addressLine2 && <div>{invoice.client.addressLine2}</div>}
|
||||||
)}
|
|
||||||
{invoice.client.addressLine2 && (
|
|
||||||
<div>{invoice.client.addressLine2}</div>
|
|
||||||
)}
|
|
||||||
{(invoice.client.city ??
|
{(invoice.client.city ??
|
||||||
invoice.client.state ??
|
invoice.client.state ??
|
||||||
invoice.client.postalCode) && (
|
invoice.client.postalCode) && (
|
||||||
@@ -263,9 +334,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
.join(", ")}
|
.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{invoice.client.country && (
|
{invoice.client.country && <div>{invoice.client.country}</div>}
|
||||||
<div>{invoice.client.country}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -273,7 +342,6 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Business Information */}
|
|
||||||
{invoice.business && (
|
{invoice.business && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -283,32 +351,24 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<h3 className="text-foreground text-xl font-semibold">
|
||||||
<h3 className="text-foreground text-xl font-semibold">
|
{invoice.business.name}
|
||||||
{invoice.business.name}
|
</h3>
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoice.business.email && (
|
{invoice.business.email && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Mail className="text-primary h-4 w-4" />
|
<Mail className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm break-all">
|
<span className="text-sm break-all">{invoice.business.email}</span>
|
||||||
{invoice.business.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invoice.business.phone && (
|
{invoice.business.phone && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 p-2">
|
<div className="bg-primary/10 p-2">
|
||||||
<Phone className="text-primary h-4 w-4" />
|
<Phone className="text-primary h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm">
|
<span className="text-sm">{invoice.business.phone}</span>
|
||||||
{invoice.business.phone}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -326,72 +386,126 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{invoice.items.map((item, _index) => (
|
{invoice.items.map((item) => (
|
||||||
<Card key={item.id} className="invoice-item bg-secondary">
|
<Card key={item.id} className="invoice-item bg-secondary">
|
||||||
<CardContent className="p-3">
|
<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="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1">
|
<p className="text-foreground mb-2 text-base font-medium break-words">
|
||||||
<p className="text-foreground mb-2 text-base font-medium break-words">
|
{item.description}
|
||||||
{item.description}
|
</p>
|
||||||
</p>
|
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||||
<div className="text-muted-foreground text-sm">
|
<span className="whitespace-nowrap">
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
{formatDate(item.date).replace(/ /g, " ")}
|
||||||
<span className="whitespace-nowrap">
|
</span>
|
||||||
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
<span className="whitespace-nowrap">
|
||||||
</span>
|
{item.hours.toString()} hours
|
||||||
<span className="whitespace-nowrap">
|
</span>
|
||||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
<span className="whitespace-nowrap">@ ${item.rate}/hr</span>
|
||||||
hours
|
|
||||||
</span>
|
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
@ ${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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-primary flex-shrink-0 self-start text-lg font-semibold">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="bg-secondary rounded-lg p-4">
|
<div className="bg-secondary rounded-lg p-4 space-y-3">
|
||||||
<div className="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">
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
||||||
<span className="text-muted-foreground">Subtotal:</span>
|
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||||
{formatCurrency(subtotal)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{invoice.taxRate > 0 && (
|
)}
|
||||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1">
|
<Separator />
|
||||||
<span className="text-muted-foreground">
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
|
||||||
Tax ({invoice.taxRate}%):
|
<span>Total:</span>
|
||||||
</span>
|
<span className="text-primary">{formatCurrency(total)}</span>
|
||||||
<span className="font-medium">
|
</div>
|
||||||
{formatCurrency(taxAmount)}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Separator />
|
||||||
<Separator />
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 font-bold">
|
||||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-lg font-bold">
|
<span>Balance Due:</span>
|
||||||
<span>Total:</span>
|
<span className={balanceDue <= 0 ? "text-green-600" : "text-primary"}>
|
||||||
<span className="text-primary">
|
{formatCurrency(Math.max(0, balanceDue))}
|
||||||
{formatCurrency(total)}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Notes */}
|
||||||
{invoice.notes && (
|
{invoice.notes && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -399,9 +513,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
<CardTitle>Notes</CardTitle>
|
<CardTitle>Notes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-foreground whitespace-pre-wrap">
|
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||||
{invoice.notes}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -425,14 +537,9 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
<PDFDownloadButton
|
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
|
||||||
invoiceId={invoice.id}
|
|
||||||
className="w-full"
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Send Invoice Button - Show for draft, sent, and overdue */}
|
|
||||||
{effectiveStatus === "draft" && (
|
{effectiveStatus === "draft" && (
|
||||||
<EnhancedSendInvoiceButton
|
<EnhancedSendInvoiceButton
|
||||||
invoiceId={invoice.id}
|
invoiceId={invoice.id}
|
||||||
@@ -441,8 +548,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(effectiveStatus === "sent" ||
|
{(effectiveStatus === "sent" || effectiveStatus === "overdue") && (
|
||||||
effectiveStatus === "overdue") && (
|
|
||||||
<EnhancedSendInvoiceButton
|
<EnhancedSendInvoiceButton
|
||||||
invoiceId={invoice.id}
|
invoiceId={invoice.id}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -451,11 +557,92 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Manual Status Updates */}
|
{/* Send Reminder */}
|
||||||
{(effectiveStatus === "sent" ||
|
{canSendReminder && (
|
||||||
effectiveStatus === "overdue") && (
|
<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
|
<Button
|
||||||
onClick={handleMarkAsPaid}
|
onClick={() => updateStatus.mutate({ id: invoiceId, status: "paid" })}
|
||||||
disabled={updateStatus.isPending}
|
disabled={updateStatus.isPending}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -471,7 +658,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleDelete}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
disabled={deleteInvoice.isPending}
|
disabled={deleteInvoice.isPending}
|
||||||
className="text-destructive hover:bg-destructive/10 w-full"
|
className="text-destructive hover:bg-destructive/10 w-full"
|
||||||
>
|
>
|
||||||
@@ -483,15 +670,115 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Invoice</DialogTitle>
|
<DialogTitle>Delete Invoice</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete invoice{" "}
|
Are you sure you want to delete invoice <strong>{invoice.invoiceNumber}</strong>?
|
||||||
<strong>{invoice.invoiceNumber}</strong>? This action cannot be
|
This action cannot be undone.
|
||||||
undone and will permanently remove the invoice and all its data.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -504,10 +791,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={confirmDelete}
|
onClick={() => deleteInvoice.mutate({ id: invoiceId })}
|
||||||
disabled={deleteInvoice.isPending}
|
disabled={deleteInvoice.isPending}
|
||||||
>
|
>
|
||||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
{deleteInvoice.isPending ? "Deleting…" : "Delete Invoice"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -521,14 +808,10 @@ export default function InvoiceViewPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
|
|
||||||
// Handle /invoices/new route - redirect to dedicated new page
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id === "new") {
|
if (id === "new") router.replace("/dashboard/invoices/new");
|
||||||
router.replace("/dashboard/invoices/new");
|
|
||||||
}
|
|
||||||
}, [id, router]);
|
}, [id, router]);
|
||||||
|
|
||||||
// Don't render anything if we're redirecting
|
|
||||||
if (id === "new") {
|
if (id === "new") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-96 items-center justify-center">
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
"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 { PageHeader } from "~/components/layout/page-header";
|
||||||
|
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 (
|
||||||
|
<div className="page-enter space-y-6 pb-24">
|
||||||
|
<PageHeader
|
||||||
|
title="Recurring Invoices"
|
||||||
|
description="Schedule automatic invoice generation"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New recurring
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{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="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
|
<RefreshCw className="text-muted-foreground h-10 w-10" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No recurring invoices yet. Create one to automatically generate draft invoices on a
|
||||||
|
schedule.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create first 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, currency = "USD") {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) {
|
||||||
|
const overdue = status === "sent" && new Date(dueDate) < new Date();
|
||||||
|
const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
const cls = overdue
|
||||||
|
? "bg-red-50 text-red-700 border-red-200"
|
||||||
|
: status === "paid"
|
||||||
|
? "bg-green-50 text-green-700 border-green-200"
|
||||||
|
: "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-semibold ${cls}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicInvoiceView({ token }: { token: string }) {
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token });
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!invoice || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
await generateInvoicePDF({
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
invoicePrefix: invoice.invoicePrefix,
|
||||||
|
issueDate: new Date(invoice.issueDate),
|
||||||
|
dueDate: new Date(invoice.dueDate),
|
||||||
|
status: invoice.status,
|
||||||
|
totalAmount: invoice.totalAmount,
|
||||||
|
taxRate: invoice.taxRate,
|
||||||
|
currency: invoice.currency ?? "USD",
|
||||||
|
notes: invoice.notes,
|
||||||
|
business: invoice.business,
|
||||||
|
client: invoice.client,
|
||||||
|
items: invoice.items,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to generate PDF");
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error ?? !invoice) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-800">Invoice not found</p>
|
||||||
|
<p className="text-sm text-gray-500">This link may have expired or been revoked.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0);
|
||||||
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
const senderName = invoice.business
|
||||||
|
? invoice.business.nickname
|
||||||
|
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||||
|
: invoice.business.name
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
{/* Card */}
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gray-900 px-8 py-6">
|
||||||
|
<p className="text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||||
|
{invoice.business?.email && (
|
||||||
|
<p className="mt-0.5 text-sm text-gray-400">{invoice.business.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-8 py-6 space-y-6">
|
||||||
|
{/* Invoice meta */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{invoice.invoiceNumber}</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={invoice.status} dueDate={invoice.dueDate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bill to */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Bill to</p>
|
||||||
|
<p className="font-semibold text-gray-900">{invoice.client.name}</p>
|
||||||
|
{invoice.client.email && (
|
||||||
|
<p className="text-sm text-gray-500">{invoice.client.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{invoice.items.map((item) => (
|
||||||
|
<div key={item.id} className="flex justify-between gap-4 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 break-words">{item.description}</p>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{item.hours} hrs @ {formatCurrency(item.rate, invoice.currency ?? "USD")}/hr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-gray-900 shrink-0">
|
||||||
|
{formatCurrency(item.amount, invoice.currency ?? "USD")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between text-gray-500">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>{formatCurrency(subtotal, invoice.currency ?? "USD")}</span>
|
||||||
|
</div>
|
||||||
|
{invoice.taxRate > 0 && (
|
||||||
|
<div className="flex justify-between text-gray-500">
|
||||||
|
<span>Tax ({invoice.taxRate}%)</span>
|
||||||
|
<span>{formatCurrency(taxAmount, invoice.currency ?? "USD")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-base font-bold text-gray-900 pt-1">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{formatCurrency(total, invoice.currency ?? "USD")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{invoice.notes && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Notes</p>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF download */}
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating PDF…</>
|
||||||
|
) : (
|
||||||
|
<><Download className="mr-2 h-4 w-4" /> Download PDF</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-100 bg-gray-50 px-8 py-4 text-center">
|
||||||
|
<p className="text-xs text-gray-400">Powered by beenvoice</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicInvoicePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const token = params.token as string;
|
||||||
|
return <PublicInvoiceView token={token} />;
|
||||||
|
}
|
||||||
@@ -805,6 +805,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onRemoveItem={removeItem}
|
onRemoveItem={removeItem}
|
||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
onAddItemWithValues={addItemWithValues}
|
onAddItemWithValues={addItemWithValues}
|
||||||
|
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||||
|
defaultRate={formData.items[0]?.rate}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus, Trash2, Zap } from "lucide-react";
|
import { Plus, Timer, Trash2, Zap } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useLineItemSuggestions,
|
useLineItemSuggestions,
|
||||||
type LineItemSuggestion,
|
type LineItemSuggestion,
|
||||||
} from "~/hooks/use-line-item-suggestions";
|
} from "~/hooks/use-line-item-suggestions";
|
||||||
|
import { TimeTracker } from "~/components/invoice/time-tracker";
|
||||||
|
|
||||||
interface InvoiceItem {
|
interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +36,8 @@ interface InvoiceLineItemsProps {
|
|||||||
value: string | number | Date,
|
value: string | number | Date,
|
||||||
) => void;
|
) => void;
|
||||||
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
||||||
|
invoiceId?: string;
|
||||||
|
defaultRate?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +347,8 @@ export function InvoiceLineItems({
|
|||||||
onRemoveItem,
|
onRemoveItem,
|
||||||
onUpdateItem,
|
onUpdateItem,
|
||||||
onAddItemWithValues,
|
onAddItemWithValues,
|
||||||
|
invoiceId,
|
||||||
|
defaultRate,
|
||||||
className,
|
className,
|
||||||
}: InvoiceLineItemsProps) {
|
}: InvoiceLineItemsProps) {
|
||||||
const canRemoveItems = items.length > 1;
|
const canRemoveItems = items.length > 1;
|
||||||
@@ -417,6 +422,20 @@ export function InvoiceLineItems({
|
|||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
{onAddItemWithValues && invoiceId && (
|
||||||
|
<div className="border-t p-3 space-y-2">
|
||||||
|
<p className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<Timer className="h-3.5 w-3.5" /> Time tracker
|
||||||
|
</p>
|
||||||
|
<TimeTracker
|
||||||
|
invoiceId={invoiceId}
|
||||||
|
defaultRate={defaultRate}
|
||||||
|
onStop={(hours, description) => {
|
||||||
|
onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{onAddItemWithValues && (
|
{onAddItemWithValues && (
|
||||||
<NLQuickAdd onAdd={onAddItemWithValues} />
|
<NLQuickAdd onAdd={onAddItemWithValues} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Play, Square } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
||||||
|
interface TimeTrackerProps {
|
||||||
|
invoiceId: string;
|
||||||
|
onStop: (hours: number, description: string) => void;
|
||||||
|
defaultRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedState {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: number | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(invoiceId: string) {
|
||||||
|
return `time-tracker-${invoiceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(seconds: number) {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedState(invoiceId: string): PersistedState {
|
||||||
|
if (typeof window === "undefined") return { running: false, startedAt: null, description: "" };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(invoiceId));
|
||||||
|
if (raw) return JSON.parse(raw) as PersistedState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { running: false, startedAt: null, description: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeTracker({ invoiceId, onStop }: TimeTrackerProps) {
|
||||||
|
const [running, setRunning] = useState(() => readPersistedState(invoiceId).running);
|
||||||
|
const [startedAt, setStartedAt] = useState<number | null>(
|
||||||
|
() => readPersistedState(invoiceId).startedAt,
|
||||||
|
);
|
||||||
|
const [elapsed, setElapsed] = useState(() => {
|
||||||
|
const s = readPersistedState(invoiceId);
|
||||||
|
if (s.running && s.startedAt) {
|
||||||
|
return Math.max(0, Math.floor((Date.now() - s.startedAt) / 1000));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
const [description, setDescription] = useState(
|
||||||
|
() => readPersistedState(invoiceId).description,
|
||||||
|
);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (running && startedAt !== null) {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [running, startedAt]);
|
||||||
|
|
||||||
|
function persist(state: PersistedState) {
|
||||||
|
localStorage.setItem(storageKey(invoiceId), JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStart() {
|
||||||
|
const now = Date.now();
|
||||||
|
setStartedAt(now);
|
||||||
|
setElapsed(0);
|
||||||
|
setRunning(true);
|
||||||
|
persist({ running: true, startedAt: now, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStop() {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
const hours = Math.max(0.25, Math.ceil(elapsed / 900) * 0.25);
|
||||||
|
setRunning(false);
|
||||||
|
setStartedAt(null);
|
||||||
|
setElapsed(0);
|
||||||
|
localStorage.removeItem(storageKey(invoiceId));
|
||||||
|
onStop(hours, description);
|
||||||
|
setDescription("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-secondary flex flex-col gap-3 rounded-lg p-3 sm:flex-row sm:items-center">
|
||||||
|
{running ? (
|
||||||
|
<>
|
||||||
|
<span className="text-primary font-mono text-xl font-bold tabular-nums">
|
||||||
|
{formatElapsed(elapsed)}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
persist({ running: true, startedAt, description: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="What are you working on?"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="default" size="sm" onClick={handleStop} className="shrink-0">
|
||||||
|
<Square className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Stop & add
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What will you work on?"
|
||||||
|
className="flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStart();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={handleStart} className="shrink-0">
|
||||||
|
<Play className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Start timer
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export const env = createEnv({
|
|||||||
.default("development"),
|
.default("development"),
|
||||||
DB_DISABLE_SSL: z.coerce.boolean().optional(),
|
DB_DISABLE_SSL: z.coerce.boolean().optional(),
|
||||||
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
|
DISABLE_SIGNUPS: z.coerce.boolean().optional(),
|
||||||
|
CRON_SECRET: z.string().optional(),
|
||||||
// SSO / Authentik (optional)
|
// SSO / Authentik (optional)
|
||||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||||
@@ -85,6 +86,7 @@ export const env = createEnv({
|
|||||||
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
|
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
|
||||||
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
|
||||||
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
|
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
|
||||||
|
CRON_SECRET: process.env.CRON_SECRET,
|
||||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
interface ReminderEmailTemplateProps {
|
||||||
|
invoice: {
|
||||||
|
invoiceNumber: string;
|
||||||
|
issueDate: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
totalAmount: number;
|
||||||
|
currency?: string | null;
|
||||||
|
client: { name: string; email: string | null };
|
||||||
|
business?: {
|
||||||
|
name: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
customMessage?: string;
|
||||||
|
userName?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateReminderEmailTemplate({
|
||||||
|
invoice,
|
||||||
|
customMessage,
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
|
||||||
|
const formatDate = (date: Date) =>
|
||||||
|
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||||
|
new Date(date),
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: invoice.currency ?? "USD",
|
||||||
|
}).format(amount);
|
||||||
|
|
||||||
|
const senderName =
|
||||||
|
invoice.business?.name
|
||||||
|
? invoice.business.nickname
|
||||||
|
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||||
|
: invoice.business.name
|
||||||
|
: userName ?? "Your service provider";
|
||||||
|
|
||||||
|
const isOverdue = new Date(invoice.dueDate) < new Date();
|
||||||
|
|
||||||
|
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber} — ${formatCurrency(invoice.totalAmount)}`;
|
||||||
|
|
||||||
|
const defaultMessage = isOverdue
|
||||||
|
? `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} was due on ${formatDate(invoice.dueDate)} and remains outstanding. Please arrange payment at your earliest convenience.`
|
||||||
|
: `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} is due on ${formatDate(invoice.dueDate)}. Please ensure payment is arranged before the due date.`;
|
||||||
|
|
||||||
|
const bodyMessage = customMessage ?? defaultMessage;
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Payment Reminder</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr><td style="background:#111827;padding:24px 32px;">
|
||||||
|
<p style="margin:0;color:#f9fafb;font-size:20px;font-weight:700;">${senderName}</p>
|
||||||
|
${userEmail ? `<p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">${userEmail}</p>` : ""}
|
||||||
|
</td></tr>
|
||||||
|
<!-- Badge -->
|
||||||
|
<tr><td style="padding:24px 32px 0;">
|
||||||
|
<span style="display:inline-block;background:${isOverdue ? "#fef2f2" : "#fffbeb"};color:${isOverdue ? "#dc2626" : "#d97706"};border:1px solid ${isOverdue ? "#fecaca" : "#fde68a"};border-radius:6px;padding:4px 12px;font-size:12px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;">
|
||||||
|
${isOverdue ? "OVERDUE" : "PAYMENT DUE"}
|
||||||
|
</span>
|
||||||
|
</td></tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr><td style="padding:24px 32px;">
|
||||||
|
<p style="margin:0 0 16px;color:#374151;font-size:15px;">Dear ${invoice.client.name},</p>
|
||||||
|
<p style="margin:0 0 24px;color:#374151;font-size:15px;line-height:1.6;">${bodyMessage}</p>
|
||||||
|
|
||||||
|
<!-- Invoice details box -->
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:24px;">
|
||||||
|
<tr><td style="padding:16px 20px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Invoice number</td>
|
||||||
|
<td style="color:#111827;font-size:13px;font-weight:600;text-align:right;padding:4px 0;">${invoice.invoiceNumber}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Issue date</td>
|
||||||
|
<td style="color:#111827;font-size:13px;text-align:right;padding:4px 0;">${formatDate(invoice.issueDate)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Due date</td>
|
||||||
|
<td style="color:${isOverdue ? "#dc2626" : "#111827"};font-size:13px;font-weight:${isOverdue ? "600" : "400"};text-align:right;padding:4px 0;">${formatDate(invoice.dueDate)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2" style="border-top:1px solid #e5e7eb;padding:8px 0 0;"></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#111827;font-size:15px;font-weight:700;padding:4px 0;">Amount due</td>
|
||||||
|
<td style="color:#111827;font-size:15px;font-weight:700;text-align:right;padding:4px 0;">${formatCurrency(invoice.totalAmount)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0;color:#6b7280;font-size:13px;">If you have already made payment, please disregard this notice. Thank you for your business.</p>
|
||||||
|
</td></tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="background:#f9fafb;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
|
||||||
|
<p style="margin:0;color:#9ca3af;font-size:12px;">Sent by ${senderName} · Powered by beenvoice</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const text = `Payment Reminder from ${senderName}
|
||||||
|
|
||||||
|
Dear ${invoice.client.name},
|
||||||
|
|
||||||
|
${bodyMessage}
|
||||||
|
|
||||||
|
Invoice: ${invoice.invoiceNumber}
|
||||||
|
Issue date: ${formatDate(invoice.issueDate)}
|
||||||
|
Due date: ${formatDate(invoice.dueDate)}
|
||||||
|
Amount due: ${formatCurrency(invoice.totalAmount)}
|
||||||
|
|
||||||
|
If you have already made payment, please disregard this notice.
|
||||||
|
Thank you for your business.
|
||||||
|
|
||||||
|
— ${senderName}`;
|
||||||
|
|
||||||
|
return { html, text, subject };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
Shield,
|
Shield,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
@@ -28,6 +29,7 @@ export const navigationConfig: NavSection[] = [
|
|||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
|
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
||||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||||
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import { emailRouter } from "~/server/api/routers/email";
|
|||||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||||
import { expensesRouter } from "~/server/api/routers/expenses";
|
import { expensesRouter } from "~/server/api/routers/expenses";
|
||||||
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
|
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
|
||||||
|
import { paymentsRouter } from "~/server/api/routers/payments";
|
||||||
|
import { recurringInvoicesRouter } from "~/server/api/routers/recurring-invoices";
|
||||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the primary router for your server.
|
|
||||||
*
|
|
||||||
* All routers added in /api/routers should be manually added here.
|
|
||||||
*/
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
clients: clientsRouter,
|
clients: clientsRouter,
|
||||||
businesses: businessesRouter,
|
businesses: businessesRouter,
|
||||||
@@ -22,6 +19,8 @@ export const appRouter = createTRPCRouter({
|
|||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
expenses: expensesRouter,
|
expenses: expensesRouter,
|
||||||
invoiceTemplates: invoiceTemplatesRouter,
|
invoiceTemplates: invoiceTemplatesRouter,
|
||||||
|
payments: paymentsRouter,
|
||||||
|
recurringInvoices: recurringInvoicesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import {
|
import {
|
||||||
invoices,
|
invoices,
|
||||||
invoiceItems,
|
invoiceItems,
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
|
|
||||||
type InvoiceRouterContext = {
|
type InvoiceRouterContext = {
|
||||||
@@ -626,4 +629,128 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── Public token (shareable link) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
generatePublicToken: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.id, input.id),
|
||||||
|
});
|
||||||
|
if (invoice?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
await ctx.db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ publicToken: token })
|
||||||
|
.where(eq(invoices.id, input.id));
|
||||||
|
return { token };
|
||||||
|
}),
|
||||||
|
|
||||||
|
revokePublicToken: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.id, input.id),
|
||||||
|
});
|
||||||
|
if (invoice?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
await ctx.db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ publicToken: null })
|
||||||
|
.where(eq(invoices.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getByPublicToken: publicProcedure
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.publicToken, input.token),
|
||||||
|
with: { client: true, business: true, items: { orderBy: (i, { asc }) => [asc(i.position)] } },
|
||||||
|
});
|
||||||
|
if (!invoice) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
return invoice;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── Send reminder ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sendReminder: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string(), customMessage: z.string().optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.id, input.id),
|
||||||
|
with: { client: true, business: true },
|
||||||
|
});
|
||||||
|
if (invoice?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
if (!invoice.client?.email) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Client has no email address" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName =
|
||||||
|
invoice.business?.emailFromName ?? invoice.business?.name ?? ctx.session.user.name ?? "";
|
||||||
|
const userEmail = invoice.business?.email ?? ctx.session.user.email ?? "";
|
||||||
|
|
||||||
|
const { html, text, subject } = generateReminderEmailTemplate({
|
||||||
|
invoice: {
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
issueDate: invoice.issueDate,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
totalAmount: invoice.totalAmount,
|
||||||
|
currency: invoice.currency,
|
||||||
|
client: { name: invoice.client.name, email: invoice.client.email },
|
||||||
|
business: invoice.business,
|
||||||
|
},
|
||||||
|
customMessage: input.customMessage,
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve Resend instance (same two-tier logic as email router)
|
||||||
|
let resendInstance: Resend;
|
||||||
|
let fromEmail: string;
|
||||||
|
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
|
||||||
|
resendInstance = new Resend(invoice.business.resendApiKey);
|
||||||
|
const fromName = invoice.business.emailFromName ?? invoice.business.name;
|
||||||
|
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
||||||
|
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
|
||||||
|
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||||
|
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||||
|
} else if (env.RESEND_API_KEY) {
|
||||||
|
resendInstance = new Resend(env.RESEND_API_KEY);
|
||||||
|
fromEmail = invoice.business?.email ?? "noreply@example.com";
|
||||||
|
} else {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Email delivery is not configured. Add a Resend API key.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resendInstance.emails.send({
|
||||||
|
from: fromEmail,
|
||||||
|
to: [invoice.client.email],
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: result.error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ lastReminderSentAt: new Date() })
|
||||||
|
.where(eq(invoices.id, input.id));
|
||||||
|
|
||||||
|
return { sent: true };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { eq, sum } from "drizzle-orm";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { invoicePayments, invoices } from "~/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
const PAYMENT_METHODS = ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"] as const;
|
||||||
|
|
||||||
|
export const paymentsRouter = createTRPCRouter({
|
||||||
|
getByInvoice: protectedProcedure
|
||||||
|
.input(z.object({ invoiceId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.id, input.invoiceId),
|
||||||
|
});
|
||||||
|
if (invoice?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
return ctx.db.query.invoicePayments.findMany({
|
||||||
|
where: eq(invoicePayments.invoiceId, input.invoiceId),
|
||||||
|
orderBy: (p, { desc }) => [desc(p.date)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
invoiceId: z.string(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
date: z.date(),
|
||||||
|
method: z.enum(PAYMENT_METHODS).default("other"),
|
||||||
|
notes: z.string().max(500).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.id, input.invoiceId),
|
||||||
|
});
|
||||||
|
if (invoice?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert(invoicePayments).values({
|
||||||
|
invoiceId: input.invoiceId,
|
||||||
|
amount: input.amount,
|
||||||
|
currency: invoice.currency,
|
||||||
|
date: input.date,
|
||||||
|
method: input.method,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
createdById: ctx.session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-mark paid if total payments >= invoice total
|
||||||
|
const [totals] = await ctx.db
|
||||||
|
.select({ paid: sum(invoicePayments.amount) })
|
||||||
|
.from(invoicePayments)
|
||||||
|
.where(eq(invoicePayments.invoiceId, input.invoiceId));
|
||||||
|
|
||||||
|
const totalPaid = Number(totals?.paid ?? 0);
|
||||||
|
if (totalPaid >= invoice.totalAmount && invoice.status !== "paid") {
|
||||||
|
await ctx.db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ status: "paid" })
|
||||||
|
.where(eq(invoices.id, input.invoiceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const payment = await ctx.db.query.invoicePayments.findFirst({
|
||||||
|
where: eq(invoicePayments.id, input.id),
|
||||||
|
});
|
||||||
|
if (!payment) throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: eq(invoices.id, payment.invoiceId),
|
||||||
|
});
|
||||||
|
if (invoice?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(invoicePayments).where(eq(invoicePayments.id, input.id));
|
||||||
|
|
||||||
|
// Re-check paid status after deletion
|
||||||
|
const [totals] = await ctx.db
|
||||||
|
.select({ paid: sum(invoicePayments.amount) })
|
||||||
|
.from(invoicePayments)
|
||||||
|
.where(eq(invoicePayments.invoiceId, payment.invoiceId));
|
||||||
|
|
||||||
|
const totalPaid = Number(totals?.paid ?? 0);
|
||||||
|
if (totalPaid < invoice.totalAmount && invoice.status === "paid") {
|
||||||
|
await ctx.db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ status: "sent" })
|
||||||
|
.where(eq(invoices.id, payment.invoiceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { and, eq, lte } from "drizzle-orm";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import {
|
||||||
|
recurringInvoices,
|
||||||
|
recurringInvoiceItems,
|
||||||
|
invoices,
|
||||||
|
invoiceItems,
|
||||||
|
clients,
|
||||||
|
businesses,
|
||||||
|
} from "~/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type { db as DbType } from "~/server/db";
|
||||||
|
|
||||||
|
export function nextDueDate(schedule: string, from = new Date()): Date {
|
||||||
|
const d = new Date(from);
|
||||||
|
switch (schedule) {
|
||||||
|
case "weekly": d.setDate(d.getDate() + 7); break;
|
||||||
|
case "biweekly": d.setDate(d.getDate() + 14); break;
|
||||||
|
case "monthly": d.setMonth(d.getMonth() + 1); break;
|
||||||
|
case "quarterly": d.setMonth(d.getMonth() + 3); break;
|
||||||
|
case "yearly": d.setFullYear(d.getFullYear() + 1); break;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecurringWithItems = typeof recurringInvoices.$inferSelect & {
|
||||||
|
items: (typeof recurringInvoiceItems.$inferSelect)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateInvoiceFromRecurring(
|
||||||
|
db: typeof DbType,
|
||||||
|
recurring: RecurringWithItems,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const now = new Date();
|
||||||
|
const invoiceNumber = `REC-${Date.now()}`;
|
||||||
|
|
||||||
|
const subtotal = recurring.items.reduce((s, i) => s + i.hours * i.rate, 0);
|
||||||
|
const taxAmount = (subtotal * recurring.taxRate) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
|
const [newInvoice] = await db
|
||||||
|
.insert(invoices)
|
||||||
|
.values({
|
||||||
|
invoiceNumber,
|
||||||
|
invoicePrefix: recurring.invoicePrefix ?? "#",
|
||||||
|
clientId: recurring.clientId,
|
||||||
|
businessId: recurring.businessId ?? null,
|
||||||
|
issueDate: now,
|
||||||
|
dueDate: nextDueDate("monthly", now),
|
||||||
|
status: "draft",
|
||||||
|
totalAmount: total,
|
||||||
|
taxRate: recurring.taxRate,
|
||||||
|
notes: recurring.notes ?? null,
|
||||||
|
emailMessage: recurring.emailMessage ?? null,
|
||||||
|
currency: recurring.currency,
|
||||||
|
createdById: recurring.createdById,
|
||||||
|
})
|
||||||
|
.returning({ id: invoices.id });
|
||||||
|
|
||||||
|
if (!newInvoice) throw new Error("Failed to create invoice");
|
||||||
|
|
||||||
|
if (recurring.items.length > 0) {
|
||||||
|
await db.insert(invoiceItems).values(
|
||||||
|
recurring.items.map((item, idx) => ({
|
||||||
|
invoiceId: newInvoice.id,
|
||||||
|
date: now,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.hours * item.rate,
|
||||||
|
position: item.position ?? idx,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newInvoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDueRecurringInvoices(db: typeof DbType): Promise<number> {
|
||||||
|
const now = new Date();
|
||||||
|
const due = await db.query.recurringInvoices.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(recurringInvoices.status, "active"),
|
||||||
|
lte(recurringInvoices.nextDueAt, now),
|
||||||
|
),
|
||||||
|
with: { items: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let generated = 0;
|
||||||
|
for (const rec of due) {
|
||||||
|
try {
|
||||||
|
await generateInvoiceFromRecurring(db, rec);
|
||||||
|
await db
|
||||||
|
.update(recurringInvoices)
|
||||||
|
.set({ lastGeneratedAt: now, nextDueAt: nextDueDate(rec.schedule, now) })
|
||||||
|
.where(eq(recurringInvoices.id, rec.id));
|
||||||
|
generated++;
|
||||||
|
} catch {
|
||||||
|
// continue on individual failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleEnum = z.enum(["weekly", "biweekly", "monthly", "quarterly", "yearly"]);
|
||||||
|
|
||||||
|
const recurringItemSchema = z.object({
|
||||||
|
description: z.string().min(1),
|
||||||
|
hours: z.number().min(0),
|
||||||
|
rate: z.number().min(0),
|
||||||
|
position: z.number().int().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const recurringInvoiceSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
clientId: z.string().min(1),
|
||||||
|
businessId: z.string().optional().or(z.literal("")),
|
||||||
|
schedule: scheduleEnum,
|
||||||
|
invoicePrefix: z.string().optional().default("#"),
|
||||||
|
taxRate: z.number().min(0).max(100).default(0),
|
||||||
|
currency: z.string().length(3).default("USD"),
|
||||||
|
notes: z.string().optional().or(z.literal("")),
|
||||||
|
emailMessage: z.string().optional().or(z.literal("")),
|
||||||
|
items: z.array(recurringItemSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const recurringInvoicesRouter = createTRPCRouter({
|
||||||
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return ctx.db.query.recurringInvoices.findMany({
|
||||||
|
where: eq(recurringInvoices.createdById, ctx.session.user.id),
|
||||||
|
with: { client: true, business: true, items: true },
|
||||||
|
orderBy: (r, { asc }) => [asc(r.nextDueAt)],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(recurringInvoiceSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
|
where: eq(clients.id, input.clientId),
|
||||||
|
});
|
||||||
|
if (client?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Client not found" });
|
||||||
|
}
|
||||||
|
if (input.businessId) {
|
||||||
|
const biz = await ctx.db.query.businesses.findFirst({
|
||||||
|
where: eq(businesses.id, input.businessId),
|
||||||
|
});
|
||||||
|
if (biz?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Business not found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rec] = await ctx.db
|
||||||
|
.insert(recurringInvoices)
|
||||||
|
.values({
|
||||||
|
name: input.name,
|
||||||
|
clientId: input.clientId,
|
||||||
|
businessId: input.businessId ?? null,
|
||||||
|
schedule: input.schedule,
|
||||||
|
status: "active",
|
||||||
|
invoicePrefix: input.invoicePrefix,
|
||||||
|
taxRate: input.taxRate,
|
||||||
|
currency: input.currency,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
emailMessage: input.emailMessage ?? null,
|
||||||
|
nextDueAt: nextDueDate(input.schedule),
|
||||||
|
createdById: ctx.session.user.id,
|
||||||
|
})
|
||||||
|
.returning({ id: recurringInvoices.id });
|
||||||
|
|
||||||
|
if (!rec) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||||
|
|
||||||
|
await ctx.db.insert(recurringInvoiceItems).values(
|
||||||
|
input.items.map((item, idx) => ({
|
||||||
|
recurringInvoiceId: rec.id,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
position: item.position ?? idx,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rec;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(recurringInvoiceSchema.extend({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.db.query.recurringInvoices.findFirst({
|
||||||
|
where: eq(recurringInvoices.id, input.id),
|
||||||
|
});
|
||||||
|
if (existing?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(recurringInvoices)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
clientId: input.clientId,
|
||||||
|
businessId: input.businessId ?? null,
|
||||||
|
schedule: input.schedule,
|
||||||
|
invoicePrefix: input.invoicePrefix,
|
||||||
|
taxRate: input.taxRate,
|
||||||
|
currency: input.currency,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
emailMessage: input.emailMessage ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.delete(recurringInvoiceItems)
|
||||||
|
.where(eq(recurringInvoiceItems.recurringInvoiceId, input.id));
|
||||||
|
|
||||||
|
await ctx.db.insert(recurringInvoiceItems).values(
|
||||||
|
input.items.map((item, idx) => ({
|
||||||
|
recurringInvoiceId: input.id,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
position: item.position ?? idx,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
pause: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rec = await ctx.db.query.recurringInvoices.findFirst({
|
||||||
|
where: eq(recurringInvoices.id, input.id),
|
||||||
|
});
|
||||||
|
if (rec?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
await ctx.db
|
||||||
|
.update(recurringInvoices)
|
||||||
|
.set({ status: "paused" })
|
||||||
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
resume: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rec = await ctx.db.query.recurringInvoices.findFirst({
|
||||||
|
where: eq(recurringInvoices.id, input.id),
|
||||||
|
});
|
||||||
|
if (rec?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
await ctx.db
|
||||||
|
.update(recurringInvoices)
|
||||||
|
.set({ status: "active" })
|
||||||
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rec = await ctx.db.query.recurringInvoices.findFirst({
|
||||||
|
where: eq(recurringInvoices.id, input.id),
|
||||||
|
});
|
||||||
|
if (rec?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
await ctx.db
|
||||||
|
.delete(recurringInvoices)
|
||||||
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
generateNow: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rec = await ctx.db.query.recurringInvoices.findFirst({
|
||||||
|
where: eq(recurringInvoices.id, input.id),
|
||||||
|
with: { items: true },
|
||||||
|
});
|
||||||
|
if (rec?.createdById !== ctx.session.user.id) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newInvoice = await generateInvoiceFromRecurring(ctx.db, rec);
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(recurringInvoices)
|
||||||
|
.set({ lastGeneratedAt: new Date(), nextDueAt: nextDueDate(rec.schedule) })
|
||||||
|
.where(eq(recurringInvoices.id, input.id));
|
||||||
|
|
||||||
|
return { invoiceId: newInvoice.id };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -87,6 +87,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
sessions: many(sessions),
|
sessions: many(sessions),
|
||||||
expenses: many(expenses),
|
expenses: many(expenses),
|
||||||
invoiceTemplates: many(invoiceTemplates),
|
invoiceTemplates: many(invoiceTemplates),
|
||||||
|
recurringInvoices: many(recurringInvoices),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
@@ -326,6 +327,8 @@ export const invoices = createTable(
|
|||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
|
publicToken: d.varchar({ length: 255 }).unique(),
|
||||||
|
lastReminderSentAt: d.timestamp(),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp()
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
@@ -338,6 +341,7 @@ export const invoices = createTable(
|
|||||||
index("invoice_created_by_idx").on(t.createdById),
|
index("invoice_created_by_idx").on(t.createdById),
|
||||||
index("invoice_number_idx").on(t.invoiceNumber),
|
index("invoice_number_idx").on(t.invoiceNumber),
|
||||||
index("invoice_status_idx").on(t.status),
|
index("invoice_status_idx").on(t.status),
|
||||||
|
index("invoice_public_token_idx").on(t.publicToken),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -355,6 +359,7 @@ export const invoicesRelations = relations(invoices, ({ one, many }) => ({
|
|||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
items: many(invoiceItems),
|
items: many(invoiceItems),
|
||||||
|
payments: many(invoicePayments),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const invoiceItems = createTable(
|
export const invoiceItems = createTable(
|
||||||
@@ -491,3 +496,149 @@ export const invoiceTemplatesRelations = relations(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Invoice Payments ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const invoicePayments = createTable(
|
||||||
|
"invoice_payment",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
invoiceId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||||
|
amount: d.real().notNull(),
|
||||||
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
|
date: d.timestamp().notNull(),
|
||||||
|
method: d
|
||||||
|
.varchar({ length: 50 })
|
||||||
|
.notNull()
|
||||||
|
.default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||||
|
notes: d.varchar({ length: 500 }),
|
||||||
|
createdById: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
index("invoice_payment_invoice_id_idx").on(t.invoiceId),
|
||||||
|
index("invoice_payment_created_by_idx").on(t.createdById),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) => ({
|
||||||
|
invoice: one(invoices, {
|
||||||
|
fields: [invoicePayments.invoiceId],
|
||||||
|
references: [invoices.id],
|
||||||
|
}),
|
||||||
|
createdBy: one(users, {
|
||||||
|
fields: [invoicePayments.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Recurring Invoices ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const recurringInvoices = createTable(
|
||||||
|
"recurring_invoice",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
name: d.varchar({ length: 255 }).notNull(),
|
||||||
|
clientId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id),
|
||||||
|
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||||
|
schedule: d.varchar({ length: 20 }).notNull().default("monthly"), // weekly | biweekly | monthly | quarterly | yearly
|
||||||
|
status: d.varchar({ length: 20 }).notNull().default("active"), // active | paused
|
||||||
|
invoicePrefix: d.varchar({ length: 20 }).default("#"),
|
||||||
|
taxRate: d.real().notNull().default(0),
|
||||||
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
|
notes: d.varchar({ length: 1000 }),
|
||||||
|
emailMessage: d.varchar({ length: 2000 }),
|
||||||
|
nextDueAt: d.timestamp().notNull(),
|
||||||
|
lastGeneratedAt: d.timestamp(),
|
||||||
|
createdById: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
index("recurring_invoice_created_by_idx").on(t.createdById),
|
||||||
|
index("recurring_invoice_client_id_idx").on(t.clientId),
|
||||||
|
index("recurring_invoice_status_idx").on(t.status),
|
||||||
|
index("recurring_invoice_next_due_idx").on(t.nextDueAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recurringInvoicesRelations = relations(
|
||||||
|
recurringInvoices,
|
||||||
|
({ one, many }) => ({
|
||||||
|
client: one(clients, {
|
||||||
|
fields: [recurringInvoices.clientId],
|
||||||
|
references: [clients.id],
|
||||||
|
}),
|
||||||
|
business: one(businesses, {
|
||||||
|
fields: [recurringInvoices.businessId],
|
||||||
|
references: [businesses.id],
|
||||||
|
}),
|
||||||
|
createdBy: one(users, {
|
||||||
|
fields: [recurringInvoices.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
items: many(recurringInvoiceItems),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recurringInvoiceItems = createTable(
|
||||||
|
"recurring_invoice_item",
|
||||||
|
(d) => ({
|
||||||
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
recurringInvoiceId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => recurringInvoices.id, { onDelete: "cascade" }),
|
||||||
|
description: d.varchar({ length: 500 }).notNull(),
|
||||||
|
hours: d.real().notNull(),
|
||||||
|
rate: d.real().notNull(),
|
||||||
|
position: d.integer().notNull().default(0),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
index("recurring_invoice_item_recurring_id_idx").on(t.recurringInvoiceId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recurringInvoiceItemsRelations = relations(
|
||||||
|
recurringInvoiceItems,
|
||||||
|
({ one }) => ({
|
||||||
|
recurringInvoice: one(recurringInvoices, {
|
||||||
|
fields: [recurringInvoiceItems.recurringInvoiceId],
|
||||||
|
references: [recurringInvoices.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user