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>
139 lines
4.7 KiB
TypeScript
139 lines
4.7 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { FileWarning } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
interface Props {
|
|
payment: {
|
|
id: string
|
|
amount: number
|
|
due_date: string
|
|
status: string
|
|
}
|
|
tenant: {
|
|
first_name: string
|
|
last_name: string
|
|
email?: string | null
|
|
}
|
|
property: { name: string; address_line1?: string; city?: string; state?: string }
|
|
unit?: { unit_number: string } | null
|
|
}
|
|
|
|
export function LateNoticeButton({ payment, tenant, property, unit }: Props) {
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
async function generateNotice() {
|
|
setLoading(true)
|
|
try {
|
|
const { jsPDF } = await import("jspdf")
|
|
const doc = new jsPDF({ unit: "pt", format: "a4" })
|
|
const pageW = doc.internal.pageSize.getWidth()
|
|
const margin = 60
|
|
const today = new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
|
|
const daysLate = Math.floor((Date.now() - new Date(payment.due_date).getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
// Header
|
|
doc.setFillColor(239, 68, 68)
|
|
doc.rect(0, 0, pageW, 8, "F")
|
|
|
|
doc.setFontSize(11)
|
|
doc.setFont("helvetica", "normal")
|
|
doc.setTextColor(120, 120, 140)
|
|
doc.text("Property Management Network", margin, 40)
|
|
doc.text(today, pageW - margin, 40, { align: "right" })
|
|
|
|
// Title
|
|
doc.setFont("helvetica", "bold")
|
|
doc.setFontSize(20)
|
|
doc.setTextColor(239, 68, 68)
|
|
doc.text("LATE RENT NOTICE", margin, 90)
|
|
|
|
doc.setDrawColor(239, 68, 68, 0.3)
|
|
doc.line(margin, 100, pageW - margin, 100)
|
|
|
|
// Tenant info
|
|
doc.setFontSize(11)
|
|
doc.setFont("helvetica", "normal")
|
|
doc.setTextColor(40, 40, 60)
|
|
doc.text(`To: ${tenant.first_name} ${tenant.last_name}`, margin, 130)
|
|
if (tenant.email) doc.text(tenant.email, margin, 148)
|
|
doc.text(`${property.name}${unit ? ` — Unit ${unit.unit_number}` : ""}`, margin, 166)
|
|
if (property.address_line1) {
|
|
doc.text(`${property.address_line1}${property.city ? `, ${property.city}` : ""}${property.state ? `, ${property.state}` : ""}`, margin, 184)
|
|
}
|
|
|
|
// Body
|
|
doc.setFontSize(11)
|
|
doc.setTextColor(60, 60, 80)
|
|
const body = [
|
|
`Dear ${tenant.first_name} ${tenant.last_name},`,
|
|
"",
|
|
`This notice is to inform you that your rent payment of $${Number(payment.amount).toFixed(2)} was due`,
|
|
`on ${new Date(payment.due_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
|
`and is now ${daysLate} day${daysLate !== 1 ? "s" : ""} past due.`,
|
|
"",
|
|
"Please arrange payment immediately to avoid further action. If you have already",
|
|
"made this payment, please disregard this notice and contact your landlord.",
|
|
"",
|
|
"If you are experiencing financial difficulties, please contact us as soon as",
|
|
"possible to discuss payment arrangements.",
|
|
]
|
|
|
|
let y = 230
|
|
body.forEach((line) => {
|
|
doc.text(line, margin, y)
|
|
y += 18
|
|
})
|
|
|
|
// Payment box
|
|
doc.setFillColor(254, 242, 242)
|
|
doc.roundedRect(margin, y + 10, pageW - margin * 2, 70, 8, 8, "F")
|
|
doc.setFontSize(10)
|
|
doc.setTextColor(153, 27, 27)
|
|
doc.text("Amount Due", margin + 20, y + 35)
|
|
doc.setFontSize(20)
|
|
doc.setFont("helvetica", "bold")
|
|
doc.text(`$${Number(payment.amount).toFixed(2)}`, margin + 20, y + 62)
|
|
doc.setFontSize(10)
|
|
doc.setFont("helvetica", "normal")
|
|
doc.text(`Due since: ${new Date(payment.due_date).toLocaleDateString()}`, pageW - margin - 20, y + 48, { align: "right" })
|
|
|
|
// Signature
|
|
y += 120
|
|
doc.setFontSize(10)
|
|
doc.setTextColor(100, 100, 120)
|
|
doc.text("Sincerely,", margin, y)
|
|
doc.text("Property Management Network", margin, y + 20)
|
|
doc.text("propertymanagement.network", margin, y + 36)
|
|
|
|
// Footer
|
|
doc.setFontSize(8)
|
|
doc.setTextColor(180, 180, 200)
|
|
doc.text("This is an official notice. Please retain for your records.", margin, 780)
|
|
|
|
const filename = `late-notice-${tenant.last_name.toLowerCase()}-${payment.due_date}.pdf`
|
|
doc.save(filename)
|
|
toast.success("Late notice downloaded")
|
|
} catch {
|
|
toast.error("Failed to generate notice")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (payment.status !== "overdue") return null
|
|
|
|
return (
|
|
<button
|
|
onClick={generateNotice}
|
|
disabled={loading}
|
|
title="Download Late Notice"
|
|
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-2.5 py-1.5 text-xs text-red-400 hover:border-red-500/40 hover:bg-red-500/5 transition disabled:opacity-40"
|
|
>
|
|
<FileWarning className="h-3.5 w-3.5" />
|
|
{loading ? "…" : "Notice"}
|
|
</button>
|
|
)
|
|
}
|