Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { FileDown } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
interface ReceiptProps {
|
|
payment: {
|
|
id: string
|
|
amount: number
|
|
due_date: string
|
|
paid_date: string | null
|
|
payment_method: string | null
|
|
status: string
|
|
}
|
|
tenant: {
|
|
first_name: string
|
|
last_name: string
|
|
email?: string | null
|
|
}
|
|
property: { name: string }
|
|
unit?: { unit_number: string } | null
|
|
}
|
|
|
|
export function RentReceiptButton({ payment, tenant, property, unit }: ReceiptProps) {
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
async function downloadReceipt() {
|
|
if (payment.status !== "paid") {
|
|
toast.error("Receipt only available for paid payments")
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
try {
|
|
const { jsPDF } = await import("jspdf")
|
|
const doc = new jsPDF({ unit: "pt", format: "a4" })
|
|
|
|
const pageW = doc.internal.pageSize.getWidth()
|
|
const margin = 48
|
|
|
|
// Header background
|
|
doc.setFillColor(22, 22, 31)
|
|
doc.rect(0, 0, pageW, 100, "F")
|
|
|
|
// Title
|
|
doc.setTextColor(255, 255, 255)
|
|
doc.setFontSize(22)
|
|
doc.setFont("helvetica", "bold")
|
|
doc.text("Property Management Network", margin, 44)
|
|
|
|
doc.setFontSize(11)
|
|
doc.setFont("helvetica", "normal")
|
|
doc.setTextColor(160, 160, 180)
|
|
doc.text("RENT RECEIPT", margin, 64)
|
|
|
|
// Receipt number
|
|
doc.setTextColor(160, 160, 180)
|
|
doc.setFontSize(9)
|
|
doc.text(`Receipt #${payment.id.slice(0, 8).toUpperCase()}`, pageW - margin, 44, { align: "right" })
|
|
doc.text(new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }), pageW - margin, 60, { align: "right" })
|
|
|
|
// Divider
|
|
doc.setDrawColor(60, 60, 80)
|
|
doc.line(margin, 112, pageW - margin, 112)
|
|
|
|
// Tenant & Property info
|
|
doc.setTextColor(120, 120, 140)
|
|
doc.setFontSize(9)
|
|
doc.setFont("helvetica", "bold")
|
|
doc.text("TENANT", margin, 136)
|
|
doc.text("PROPERTY", pageW / 2, 136)
|
|
|
|
doc.setFont("helvetica", "normal")
|
|
doc.setTextColor(30, 30, 30)
|
|
doc.setFontSize(11)
|
|
doc.text(`${tenant.first_name} ${tenant.last_name}`, margin, 154)
|
|
doc.text(property.name, pageW / 2, 154)
|
|
|
|
if (tenant.email) {
|
|
doc.setFontSize(9)
|
|
doc.setTextColor(120, 120, 140)
|
|
doc.text(tenant.email, margin, 170)
|
|
}
|
|
if (unit) {
|
|
doc.setFontSize(9)
|
|
doc.setTextColor(120, 120, 140)
|
|
doc.text(`Unit ${unit.unit_number}`, pageW / 2, 170)
|
|
}
|
|
|
|
// Payment box
|
|
doc.setFillColor(245, 245, 250)
|
|
doc.roundedRect(margin, 200, pageW - margin * 2, 140, 8, 8, "F")
|
|
|
|
doc.setTextColor(30, 30, 30)
|
|
doc.setFontSize(13)
|
|
doc.setFont("helvetica", "bold")
|
|
doc.text("Payment Details", margin + 20, 228)
|
|
|
|
const rows = [
|
|
["Amount Paid", `$${Number(payment.amount).toFixed(2)}`],
|
|
["Due Date", payment.due_date],
|
|
["Paid Date", payment.paid_date ?? "—"],
|
|
["Payment Method", payment.payment_method ?? "—"],
|
|
["Status", "PAID"],
|
|
]
|
|
|
|
doc.setFont("helvetica", "normal")
|
|
doc.setFontSize(10)
|
|
rows.forEach(([label, value], i) => {
|
|
const y = 252 + i * 18
|
|
doc.setTextColor(100, 100, 120)
|
|
doc.text(label, margin + 20, y)
|
|
doc.setTextColor(30, 30, 30)
|
|
doc.text(value, pageW - margin - 20, y, { align: "right" })
|
|
})
|
|
|
|
// Paid stamp
|
|
doc.setTextColor(34, 197, 94)
|
|
doc.setFontSize(32)
|
|
doc.setFont("helvetica", "bold")
|
|
doc.text("PAID", pageW - margin - 20, 290, { align: "right" })
|
|
|
|
// Footer
|
|
doc.setFont("helvetica", "normal")
|
|
doc.setFontSize(8)
|
|
doc.setTextColor(160, 160, 180)
|
|
doc.text("This receipt was generated by Property Management Network. Please keep for your records.", margin, 780)
|
|
doc.text("propertymanagement.network", pageW - margin, 780, { align: "right" })
|
|
|
|
const filename = `receipt-${tenant.last_name.toLowerCase()}-${payment.due_date}.pdf`
|
|
doc.save(filename)
|
|
toast.success("Receipt downloaded")
|
|
} catch {
|
|
toast.error("Failed to generate receipt")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (payment.status !== "paid") return null
|
|
|
|
return (
|
|
<button
|
|
onClick={downloadReceipt}
|
|
disabled={loading}
|
|
title="Download Receipt"
|
|
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1.5 text-xs text-white/60 hover:border-indigo-500/30 hover:text-indigo-400 transition disabled:opacity-40"
|
|
>
|
|
<FileDown className="h-3.5 w-3.5" />
|
|
{loading ? "…" : "Receipt"}
|
|
</button>
|
|
)
|
|
}
|