Initial import: property management SaaS + security hardening + admin dashboard
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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Sparkles, Loader2, X, AlertTriangle, TrendingUp, Wrench } from "lucide-react"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
interface Summary {
|
||||
reportTitle: string
|
||||
reportDate: string
|
||||
propertyName: string
|
||||
totalRequests: number
|
||||
openRequests: number
|
||||
resolvedRequests: number
|
||||
urgentItems: string[]
|
||||
summary: string
|
||||
recommendations: string[]
|
||||
estimatedTotalCost: number
|
||||
}
|
||||
|
||||
export function AiMaintenanceSummary({ propertyId }: { propertyId: string }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
async function generate() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/maintenance-summary", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ property_id: propertyId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error ?? "Failed to generate summary"); return }
|
||||
setSummary(data)
|
||||
} catch {
|
||||
setError("Network error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-indigo-500/30 bg-indigo-500/10 px-3 py-2 text-xs font-medium text-indigo-300 hover:bg-indigo-500/20 transition-all disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI Summary
|
||||
</button>
|
||||
|
||||
{/* Slide-over panel */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-[#111118] border-l border-white/[0.06] overflow-y-auto shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||
<h2 className="text-sm font-semibold text-white">AI Maintenance Report</h2>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="rounded-lg p-1.5 text-white/40 hover:text-white transition">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<Loader2 className="h-8 w-8 text-indigo-400 animate-spin" />
|
||||
<p className="text-sm text-white/50">Analysing maintenance data…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && !loading && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">{summary.reportTitle}</h3>
|
||||
<p className="text-xs text-white/40 mt-0.5">{summary.reportDate} · {summary.propertyName}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Total", value: summary.totalRequests, color: "text-white" },
|
||||
{ label: "Open", value: summary.openRequests, color: "text-amber-400" },
|
||||
{ label: "Resolved", value: summary.resolvedRequests, color: "text-emerald-400" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 text-center">
|
||||
<p className={`text-xl font-bold ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
{summary.estimatedTotalCost > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3">
|
||||
<span className="text-xs text-amber-400/80">Estimated total cost</span>
|
||||
<span className="text-sm font-bold text-amber-400">{formatCurrency(summary.estimatedTotalCost)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-white/30 mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp className="h-3 w-3" /> Overview
|
||||
</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed">{summary.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Urgent items */}
|
||||
{summary.urgentItems?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-red-400/70 mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3 w-3" /> Urgent Items
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{summary.urgentItems.map((item, i) => (
|
||||
<li key={i} className="text-sm text-white/70 flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-400" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{summary.recommendations?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-indigo-400/70 mb-2 flex items-center gap-1.5">
|
||||
<Wrench className="h-3 w-3" /> Recommendations
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{summary.recommendations.map((rec, i) => (
|
||||
<li key={i} className="text-sm text-white/70 flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-indigo-500/20 text-[10px] font-bold text-indigo-400">{i + 1}</span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generate}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl border border-white/[0.06] px-4 py-2.5 text-xs font-medium text-white/50 hover:text-white hover:border-white/20 transition"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Regenerate report
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function CheckoutButton({ plan, label, highlight }: { plan: string; label: string; highlight?: boolean }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
|
||||
highlight
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-500"
|
||||
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{loading ? "Loading..." : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Download } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Props {
|
||||
endpoint: string // e.g. "/api/export/rent" or "/api/export/tenants"
|
||||
filename: string // e.g. "rent-payments.csv"
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function CsvExportButton({ endpoint, filename, label = "Export CSV" }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleExport() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(endpoint)
|
||||
if (!res.ok) throw new Error("Export failed")
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(`${filename} downloaded`)
|
||||
} catch {
|
||||
toast.error("Failed to export CSV")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-40"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{loading ? "Exporting…" : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Trash2 } from "lucide-react"
|
||||
|
||||
export function DeletePropertyButton({ propertyId }: { propertyId: string }) {
|
||||
const router = useRouter()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
await fetch(`/api/properties/${propertyId}`, { method: "DELETE" })
|
||||
router.push("/properties")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white/50">Are you sure?</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Deleting..." : "Yes, delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-white/60 hover:text-white transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-2 text-sm text-red-400 hover:border-red-500/40 hover:bg-red-500/5 transition"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "repairs", label: "Repairs" },
|
||||
{ value: "utilities", label: "Utilities" },
|
||||
{ value: "insurance", label: "Insurance" },
|
||||
{ value: "mortgage", label: "Mortgage" },
|
||||
{ value: "taxes", label: "Taxes" },
|
||||
{ value: "management", label: "Management" },
|
||||
{ value: "supplies", label: "Supplies" },
|
||||
{ value: "other", label: "Other" },
|
||||
]
|
||||
|
||||
const RECURRENCE = [
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "quarterly", label: "Quarterly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
]
|
||||
|
||||
export function ExpenseForm({ properties, expense }: { properties: any[]; expense?: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(expense?.property_id ?? "")
|
||||
const [isRecurring, setIsRecurring] = useState(expense?.is_recurring ?? false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
const propertyOptions = properties.map((p: any) => ({ value: p.id, label: p.name }))
|
||||
const units = properties.find((p: any) => p.id === selectedPropertyId)?.units ?? []
|
||||
const unitOptions = [
|
||||
{ value: "", label: "Whole property" },
|
||||
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
property_id: fd.get("property_id"),
|
||||
unit_id: fd.get("unit_id") || undefined,
|
||||
category: fd.get("category"),
|
||||
description: fd.get("description"),
|
||||
amount: Number(fd.get("amount")),
|
||||
expense_date: fd.get("expense_date"),
|
||||
vendor: fd.get("vendor") || undefined,
|
||||
is_recurring: fd.get("is_recurring") === "on",
|
||||
recurrence: fd.get("recurrence") || undefined,
|
||||
notes: fd.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(expense ? `/api/expenses/${expense.id}` : "/api/expenses", {
|
||||
method: expense ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(expense ? "Expense updated" : "Expense added")
|
||||
router.push("/expenses")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Description <span className="text-red-400">*</span></label>
|
||||
<input name="description" required placeholder="e.g. Plumbing repair" defaultValue={expense?.description} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Amount ($) <span className="text-red-400">*</span></label>
|
||||
<input name="amount" type="number" step="0.01" min="0" required placeholder="0.00" defaultValue={expense?.amount} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Date <span className="text-red-400">*</span></label>
|
||||
<input name="expense_date" type="date" required defaultValue={expense?.expense_date ?? today} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Category <span className="text-red-400">*</span></label>
|
||||
<Select name="category" defaultValue={expense?.category ?? "repairs"} options={CATEGORIES} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Vendor</label>
|
||||
<input name="vendor" placeholder="Vendor / contractor" defaultValue={expense?.vendor ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={[{ value: "", label: "Select property…" }, ...propertyOptions]}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPropertyId && units.length > 0 && (
|
||||
<div>
|
||||
<label className={lbl}>Unit (optional)</label>
|
||||
<Select name="unit_id" defaultValue={expense?.unit_id ?? ""} options={unitOptions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
name="is_recurring"
|
||||
type="checkbox"
|
||||
id="is_recurring"
|
||||
checked={isRecurring}
|
||||
onChange={(e) => setIsRecurring(e.target.checked)}
|
||||
className="h-4 w-4 rounded accent-indigo-600"
|
||||
/>
|
||||
<label htmlFor="is_recurring" className="text-sm text-white/70">Recurring expense</label>
|
||||
</div>
|
||||
|
||||
{isRecurring && (
|
||||
<div>
|
||||
<label className={lbl}>Recurrence</label>
|
||||
<Select name="recurrence" defaultValue={expense?.recurrence ?? "monthly"} options={RECURRENCE} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">Cancel</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : expense ? "Save Changes" : "Add Expense"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const LEASE_TYPES = [
|
||||
{ value: "fixed", label: "Fixed Term" },
|
||||
{ value: "month_to_month", label: "Month-to-Month" },
|
||||
]
|
||||
|
||||
export function LeaseForm({ tenants, properties, lease, prefill }: {
|
||||
tenants: any[]; properties: any[]; lease?: any
|
||||
prefill?: { tenant_id?: string; property_id?: string; unit_id?: string; rent_amount?: string }
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedTenantId, setSelectedTenantId] = useState(lease?.tenant_id ?? prefill?.tenant_id ?? "")
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(lease?.property_id ?? prefill?.property_id ?? "")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
const tenantOptions = [
|
||||
{ value: "", label: "Select tenant…" },
|
||||
...tenants.map((t: any) => ({ value: t.id, label: `${t.first_name} ${t.last_name}` })),
|
||||
]
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "Select property…" },
|
||||
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
const units = properties.find((p: any) => p.id === selectedPropertyId)?.units ?? []
|
||||
const unitOptions = [
|
||||
{ value: "", label: "No unit" },
|
||||
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
const selectedTenant = tenants.find((t: any) => t.id === selectedTenantId)
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
tenant_id: fd.get("tenant_id"),
|
||||
property_id: fd.get("property_id"),
|
||||
unit_id: fd.get("unit_id") || undefined,
|
||||
lease_start: fd.get("lease_start"),
|
||||
lease_end: fd.get("lease_end"),
|
||||
rent_amount: Number(fd.get("rent_amount")),
|
||||
security_deposit: fd.get("security_deposit") ? Number(fd.get("security_deposit")) : undefined,
|
||||
lease_type: fd.get("lease_type"),
|
||||
auto_renew: fd.get("auto_renew") === "on",
|
||||
notes: fd.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(lease ? `/api/leases/${lease.id}` : "/api/leases", {
|
||||
method: lease ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(lease ? "Lease updated" : "Lease created")
|
||||
router.push("/leases")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Tenant <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="tenant_id"
|
||||
value={selectedTenantId}
|
||||
onChange={(val) => {
|
||||
setSelectedTenantId(val)
|
||||
const t = tenants.find((t: any) => t.id === val)
|
||||
if (t) setSelectedPropertyId(t.property_id ?? "")
|
||||
}}
|
||||
options={tenantOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={propertyOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Unit</label>
|
||||
<Select
|
||||
name="unit_id"
|
||||
defaultValue={lease?.unit_id ?? prefill?.unit_id ?? selectedTenant?.unit_id ?? ""}
|
||||
options={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Lease Start <span className="text-red-400">*</span></label>
|
||||
<input name="lease_start" type="date" required defaultValue={lease?.lease_start} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Lease End <span className="text-red-400">*</span></label>
|
||||
<input name="lease_end" type="date" required defaultValue={lease?.lease_end} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Monthly Rent ($) <span className="text-red-400">*</span></label>
|
||||
<input name="rent_amount" type="number" step="0.01" min="0" required placeholder="0.00" defaultValue={lease?.rent_amount ?? prefill?.rent_amount ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Security Deposit ($)</label>
|
||||
<input name="security_deposit" type="number" step="0.01" min="0" placeholder="0.00" defaultValue={lease?.security_deposit ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Lease Type</label>
|
||||
<Select name="lease_type" defaultValue={lease?.lease_type ?? "fixed"} options={LEASE_TYPES} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<input name="auto_renew" type="checkbox" id="auto_renew" defaultChecked={lease?.auto_renew} className="h-4 w-4 rounded border-white/20 bg-white/5 accent-indigo-600" />
|
||||
<label htmlFor="auto_renew" className="text-sm text-white/70">Auto-renew</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" rows={2} placeholder="Optional..." defaultValue={lease?.notes ?? ""} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">Cancel</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : lease ? "Save Changes" : "Add Lease"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Camera, X, Loader2 } from "lucide-react"
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "plumbing", label: "Plumbing" },
|
||||
{ value: "electrical", label: "Electrical" },
|
||||
{ value: "hvac", label: "HVAC" },
|
||||
{ value: "appliance", label: "Appliance" },
|
||||
{ value: "structural", label: "Structural" },
|
||||
{ value: "pest", label: "Pest" },
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "emergency", label: "Emergency" },
|
||||
]
|
||||
|
||||
export function MaintenanceForm({ properties, tenants, request }: {
|
||||
properties: any[]; tenants: any[]; request?: any
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(request?.property_id ?? "")
|
||||
const [photos, setPhotos] = useState<string[]>(request?.images ?? [])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handlePhotoUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (!files.length) return
|
||||
if (photos.length + files.length > 5) { toast.error("Max 5 photos"); return }
|
||||
|
||||
setUploading(true)
|
||||
|
||||
const uploaded: string[] = []
|
||||
for (const file of files) {
|
||||
if (file.size > 5 * 1024 * 1024) { toast.error(`${file.name} is too large (max 5MB)`); continue }
|
||||
const uploadData = new FormData()
|
||||
uploadData.append("file", file)
|
||||
uploadData.append("scope", "maintenance")
|
||||
const res = await fetch("/api/upload", { method: "POST", body: uploadData })
|
||||
if (!res.ok) { toast.error(`Failed to upload ${file.name}`); continue }
|
||||
const { url } = await res.json()
|
||||
uploaded.push(url)
|
||||
}
|
||||
setPhotos((prev) => [...prev, ...uploaded])
|
||||
setUploading(false)
|
||||
if (fileRef.current) fileRef.current.value = ""
|
||||
}
|
||||
|
||||
function removePhoto(url: string) {
|
||||
setPhotos((prev) => prev.filter((p) => p !== url))
|
||||
}
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "Select property…" },
|
||||
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
const units = properties.find((p: any) => p.id === selectedPropertyId)?.units ?? []
|
||||
const unitOptions = [
|
||||
{ value: "", label: "No specific unit" },
|
||||
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
const tenantOptions = [
|
||||
{ value: "", label: "No tenant" },
|
||||
...tenants.map((t: any) => ({ value: t.id, label: `${t.first_name} ${t.last_name}` })),
|
||||
]
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
property_id: formData.get("property_id"),
|
||||
unit_id: formData.get("unit_id") || undefined,
|
||||
tenant_id: formData.get("tenant_id") || undefined,
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description"),
|
||||
category: formData.get("category"),
|
||||
priority: formData.get("priority"),
|
||||
assigned_to: formData.get("assigned_to") || undefined,
|
||||
estimated_cost: formData.get("estimated_cost") ? Number(formData.get("estimated_cost")) : undefined,
|
||||
images: photos,
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
request ? `/api/maintenance/${request.id}` : "/api/maintenance",
|
||||
{ method: request ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(request ? "Request updated" : "Maintenance request created")
|
||||
router.push(`/maintenance/${data.id}`)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Title <span className="text-red-400">*</span></label>
|
||||
<input name="title" required placeholder="e.g. Leaking faucet in bathroom" defaultValue={request?.title} className={cls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Description <span className="text-red-400">*</span></label>
|
||||
<textarea name="description" required rows={3} placeholder="Describe the issue in detail..." defaultValue={request?.description} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Category</label>
|
||||
<Select name="category" defaultValue={request?.category ?? "general"} options={CATEGORIES} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Priority</label>
|
||||
<Select name="priority" defaultValue={request?.priority ?? "medium"} options={PRIORITIES} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={propertyOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Unit</label>
|
||||
<Select name="unit_id" defaultValue={request?.unit_id ?? ""} options={unitOptions} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Reported by Tenant</label>
|
||||
<Select name="tenant_id" defaultValue={request?.tenant_id ?? ""} options={tenantOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Assigned To</label>
|
||||
<input name="assigned_to" placeholder="Contractor / vendor name" defaultValue={request?.assigned_to ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Estimated Cost ($)</label>
|
||||
<input name="estimated_cost" type="number" step="0.01" min="0" placeholder="0.00" defaultValue={request?.estimated_cost ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Upload */}
|
||||
<div>
|
||||
<label className={lbl}>Photos <span className="text-white/30 font-normal">(up to 5)</span></label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{photos.map((url) => (
|
||||
<div key={url} className="relative h-20 w-20 overflow-hidden rounded-lg border border-white/10">
|
||||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(url)}
|
||||
className="absolute right-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-black/70 text-white hover:bg-red-500/80 transition"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{photos.length < 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-white/10 text-white/30 hover:border-indigo-500/40 hover:text-white/50 transition disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
|
||||
<span className="text-[10px]">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : request ? "Save Changes" : "Create Request"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "open", label: "Open", color: "border-amber-500/30 text-amber-400 hover:bg-amber-500/10" },
|
||||
{ value: "in_progress", label: "In Progress", color: "border-blue-500/30 text-blue-400 hover:bg-blue-500/10" },
|
||||
{ value: "resolved", label: "Resolved", color: "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10" },
|
||||
{ value: "closed", label: "Closed", color: "border-white/10 text-white/40 hover:bg-white/5" },
|
||||
]
|
||||
|
||||
export function MaintenanceStatusUpdater({ request }: { request: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [resolutionNotes, setResolutionNotes] = useState(request.resolution_notes ?? "")
|
||||
const [actualCost, setActualCost] = useState(request.actual_cost ?? "")
|
||||
|
||||
async function updateStatus(status: string) {
|
||||
setLoading(true)
|
||||
await fetch(`/api/maintenance/${request.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
resolution_notes: resolutionNotes || undefined,
|
||||
actual_cost: actualCost ? Number(actualCost) : undefined,
|
||||
}),
|
||||
})
|
||||
setLoading(false)
|
||||
toast.success("Status updated")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Update Status</h3>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => updateStatus(s.value)}
|
||||
disabled={loading || request.status === s.value}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition disabled:opacity-40 ${
|
||||
request.status === s.value
|
||||
? "opacity-40 cursor-default"
|
||||
: s.color
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/50">Resolution Notes</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={resolutionNotes}
|
||||
onChange={(e) => setResolutionNotes(e.target.value)}
|
||||
placeholder="Describe what was done to resolve the issue..."
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/50">Actual Cost ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={actualCost}
|
||||
onChange={(e) => setActualCost(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => updateStatus(request.status)}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-indigo-600 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Notes"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export function PortalButton() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/stripe/portal", { method: "POST" })
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Manage Subscription"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function ProfileForm({ profile }: { profile: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const labelClass = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
const res = await fetch("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
full_name: formData.get("full_name") as string,
|
||||
phone: (formData.get("phone") as string) || null,
|
||||
company_name: (formData.get("company_name") as string) || null,
|
||||
}),
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(typeof data.error === "string" ? data.error : "Failed to save profile")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Profile saved")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input value={profile?.email ?? ""} disabled className={inputClass + " opacity-50 cursor-not-allowed"} readOnly />
|
||||
<p className="mt-1 text-xs text-white/30">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Full Name</label>
|
||||
<input name="full_name" defaultValue={profile?.full_name ?? ""} placeholder="John Smith" className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input name="phone" defaultValue={profile?.phone ?? ""} placeholder="+1 555 000 0000" className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Company / Business Name</label>
|
||||
<input name="company_name" defaultValue={profile?.company_name ?? ""} placeholder="Smith Property Management" className={inputClass} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import type { Property } from "@/types"
|
||||
|
||||
const PROPERTY_TYPES = [
|
||||
{ value: "residential", label: "Residential" },
|
||||
{ value: "commercial", label: "Commercial" },
|
||||
{ value: "mixed", label: "Mixed Use" },
|
||||
]
|
||||
|
||||
interface PropertyFormProps {
|
||||
property?: Property
|
||||
}
|
||||
|
||||
export function PropertyForm({ property }: PropertyFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
name: formData.get("name"),
|
||||
address_line1: formData.get("address_line1"),
|
||||
address_line2: formData.get("address_line2") || undefined,
|
||||
city: formData.get("city"),
|
||||
state: formData.get("state") || undefined,
|
||||
postal_code: formData.get("postal_code") || undefined,
|
||||
country: formData.get("country") || "US",
|
||||
property_type: formData.get("property_type"),
|
||||
total_units: Number(formData.get("total_units")) || 1,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
property ? `/api/properties/${property.id}` : "/api/properties",
|
||||
{ method: property ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(property ? "Property updated" : "Property added")
|
||||
router.push(`/properties/${data.id}`)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property Name <span className="text-red-400">*</span></label>
|
||||
<input name="name" required placeholder="Sunset Apartments" defaultValue={property?.name} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Type</label>
|
||||
<Select
|
||||
name="property_type"
|
||||
defaultValue={property?.property_type ?? "residential"}
|
||||
options={PROPERTY_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Total Units</label>
|
||||
<input name="total_units" type="number" min="1" defaultValue={String(property?.total_units ?? 1)} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Address Line 1 <span className="text-red-400">*</span></label>
|
||||
<input name="address_line1" required placeholder="123 Main Street" defaultValue={property?.address_line1} className={cls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Address Line 2</label>
|
||||
<input name="address_line2" placeholder="Apt, Suite, Floor (optional)" defaultValue={property?.address_line2 ?? ""} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>City <span className="text-red-400">*</span></label>
|
||||
<input name="city" required placeholder="New York" defaultValue={property?.city} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>State</label>
|
||||
<input name="state" placeholder="NY" defaultValue={property?.state ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Postal Code</label>
|
||||
<input name="postal_code" placeholder="10001" defaultValue={property?.postal_code ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Country</label>
|
||||
<input name="country" defaultValue={property?.country ?? "US"} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" placeholder="Optional notes..." defaultValue={property?.notes ?? ""} rows={3} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : property ? "Save Changes" : "Add Property"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Camera, Loader2, X } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
propertyId: string
|
||||
currentImageUrl?: string | null
|
||||
}
|
||||
|
||||
export function PropertyPhotoUpload({ propertyId, currentImageUrl }: Props) {
|
||||
const router = useRouter()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(currentImageUrl ?? null)
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please select an image file")
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("Image must be under 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
// Upload to local storage via the gated upload endpoint
|
||||
const uploadData = new FormData()
|
||||
uploadData.append("file", file)
|
||||
uploadData.append("scope", "property-images")
|
||||
uploadData.append("fixed_name", propertyId)
|
||||
|
||||
const uploadRes = await fetch("/api/upload", { method: "POST", body: uploadData })
|
||||
if (!uploadRes.ok) throw new Error("Upload failed")
|
||||
const { url: publicUrl } = await uploadRes.json()
|
||||
|
||||
// Update property record
|
||||
const res = await fetch(`/api/properties/${propertyId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: publicUrl }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save image URL")
|
||||
|
||||
setPreview(publicUrl)
|
||||
toast.success("Photo updated")
|
||||
router.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "Upload failed")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhoto() {
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/properties/${propertyId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: null }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to remove photo")
|
||||
setPreview(null)
|
||||
toast.success("Photo removed")
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Failed to remove photo")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{preview ? (
|
||||
<div className="group relative h-44 w-full overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<img src={preview} alt="Property" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-white/10 px-3 py-2 text-xs text-white hover:bg-white/20 transition"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" /> Change
|
||||
</button>
|
||||
<button
|
||||
onClick={removePhoto}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-500/20 px-3 py-2 text-xs text-red-400 hover:bg-red-500/30 transition"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> Remove
|
||||
</button>
|
||||
</div>
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex h-44 w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] text-white/30 hover:border-indigo-500/40 hover:text-white/50 transition disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 className="h-6 w-6 animate-spin" />
|
||||
: <>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-xs">Add property photo</span>
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function RentActions({ payment }: { payment: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function markAs(status: string) {
|
||||
setLoading(true)
|
||||
await fetch(`/api/rent/${payment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
paid_date: status === "paid" ? new Date().toISOString().slice(0, 10) : null,
|
||||
}),
|
||||
})
|
||||
setLoading(false)
|
||||
toast.success(status === "paid" ? "Marked as paid" : "Marked as overdue")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
await fetch(`/api/rent/${payment.id}`, { method: "DELETE" })
|
||||
setLoading(false)
|
||||
toast.success("Payment deleted")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (loading) return <span className="text-xs text-white/30">Updating...</span>
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||
{payment.status !== "paid" && (
|
||||
<button onClick={() => markAs("paid")} className="text-xs text-emerald-400 hover:text-emerald-300">
|
||||
Mark Paid
|
||||
</button>
|
||||
)}
|
||||
{payment.status !== "overdue" && payment.status !== "paid" && (
|
||||
<button onClick={() => markAs("overdue")} className="text-xs text-red-400 hover:text-red-300">
|
||||
Mark Overdue
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleDelete} className="text-xs text-white/30 hover:text-red-400">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "paid", label: "Paid" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
{ value: "partial", label: "Partial" },
|
||||
{ value: "waived", label: "Waived" },
|
||||
]
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ value: "", label: "Select method…" },
|
||||
{ value: "cash", label: "Cash" },
|
||||
{ value: "bank_transfer", label: "Bank Transfer" },
|
||||
{ value: "check", label: "Check" },
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
]
|
||||
|
||||
export function RentPaymentForm({ tenants }: { tenants: any[] }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("")
|
||||
|
||||
const tenantOptions = [
|
||||
{ value: "", label: "Select tenant…" },
|
||||
...tenants.map((t: any) => ({
|
||||
value: t.id,
|
||||
label: `${t.first_name} ${t.last_name}${t.property?.name ? ` — ${t.property.name}` : ""}${t.unit ? ` (Unit ${t.unit.unit_number})` : ""}`,
|
||||
})),
|
||||
]
|
||||
|
||||
const selectedTenant = tenants.find((t: any) => t.id === selectedTenantId)
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
tenant_id: formData.get("tenant_id"),
|
||||
property_id: selectedTenant?.property?.id,
|
||||
unit_id: selectedTenant?.unit_id || undefined,
|
||||
amount: Number(formData.get("amount")),
|
||||
due_date: formData.get("due_date"),
|
||||
paid_date: formData.get("paid_date") || undefined,
|
||||
status: formData.get("status"),
|
||||
payment_method: formData.get("payment_method") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch("/api/rent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Payment recorded")
|
||||
router.push("/rent")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Tenant <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="tenant_id"
|
||||
value={selectedTenantId}
|
||||
onChange={setSelectedTenantId}
|
||||
options={tenantOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Amount ($) <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
placeholder="0.00"
|
||||
defaultValue={selectedTenant?.unit?.rent_amount ?? ""}
|
||||
className={cls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Status <span className="text-red-400">*</span></label>
|
||||
<Select name="status" defaultValue="pending" options={STATUSES} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Due Date <span className="text-red-400">*</span></label>
|
||||
<input name="due_date" type="date" required defaultValue={today} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Paid Date</label>
|
||||
<input name="paid_date" type="date" className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Payment Method</label>
|
||||
<Select name="payment_method" defaultValue="" options={PAYMENT_METHODS} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" rows={2} placeholder="Optional..." className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : "Record Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import type { Tenant } from "@/types"
|
||||
|
||||
interface TenantFormProps {
|
||||
properties: { id: string; name: string; units: { id: string; unit_number: string; status: string }[] }[]
|
||||
tenant?: Tenant
|
||||
}
|
||||
|
||||
export function TenantForm({ properties, tenant }: TenantFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(tenant?.property_id ?? "")
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "Select property…" },
|
||||
...properties.map((p) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
const availableUnits = properties
|
||||
.find((p) => p.id === selectedPropertyId)
|
||||
?.units.filter((u) => u.status === "vacant" || u.id === tenant?.unit_id) ?? []
|
||||
|
||||
const unitOptions = [
|
||||
{ value: "", label: "No unit assigned" },
|
||||
...availableUnits.map((u) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body: Record<string, unknown> = {
|
||||
property_id: formData.get("property_id"),
|
||||
unit_id: formData.get("unit_id") || undefined,
|
||||
first_name: formData.get("first_name"),
|
||||
last_name: formData.get("last_name"),
|
||||
email: formData.get("email") || undefined,
|
||||
phone: formData.get("phone") || undefined,
|
||||
emergency_contact_name: formData.get("emergency_contact_name") || undefined,
|
||||
emergency_contact_phone: formData.get("emergency_contact_phone") || undefined,
|
||||
move_in_date: formData.get("move_in_date") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
tenant ? `/api/tenants/${tenant.id}` : "/api/tenants",
|
||||
{ method: tenant ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(tenant ? "Tenant updated" : "Tenant added")
|
||||
router.push(`/tenants/${data.id}`)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>First Name <span className="text-red-400">*</span></label>
|
||||
<input name="first_name" required placeholder="John" defaultValue={tenant?.first_name} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Last Name <span className="text-red-400">*</span></label>
|
||||
<input name="last_name" required placeholder="Smith" defaultValue={tenant?.last_name} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Email</label>
|
||||
<input name="email" type="email" placeholder="john@email.com" defaultValue={tenant?.email ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Phone</label>
|
||||
<input name="phone" placeholder="+1 555 000 0000" defaultValue={tenant?.phone ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={propertyOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPropertyId && (
|
||||
<div>
|
||||
<label className={lbl}>Unit</label>
|
||||
<Select name="unit_id" defaultValue={tenant?.unit_id ?? ""} options={unitOptions} />
|
||||
{availableUnits.length === 0 && (
|
||||
<p className="mt-1 text-xs text-amber-400">No vacant units in this property.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Move-in Date</label>
|
||||
<input name="move_in_date" type="date" defaultValue={tenant?.move_in_date ?? ""} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-5">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-wider text-white/30">Emergency Contact</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Name</label>
|
||||
<input name="emergency_contact_name" placeholder="Jane Smith" defaultValue={tenant?.emergency_contact_name ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Phone</label>
|
||||
<input name="emergency_contact_phone" placeholder="+1 555 000 0001" defaultValue={tenant?.emergency_contact_phone ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" rows={3} placeholder="Optional..." defaultValue={tenant?.notes ?? ""} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : tenant ? "Save Changes" : "Add Tenant"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "vacant", label: "Vacant" },
|
||||
{ value: "occupied", label: "Occupied" },
|
||||
{ value: "maintenance", label: "Maintenance" },
|
||||
{ value: "unavailable", label: "Unavailable" },
|
||||
]
|
||||
|
||||
interface UnitFormProps {
|
||||
propertyId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [status, setStatus] = useState("vacant")
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
property_id: propertyId,
|
||||
unit_number: formData.get("unit_number"),
|
||||
bedrooms: Number(formData.get("bedrooms") ?? 1),
|
||||
bathrooms: Number(formData.get("bathrooms") ?? 1),
|
||||
sq_ft: formData.get("sq_ft") ? Number(formData.get("sq_ft")) : undefined,
|
||||
rent_amount: Number(formData.get("rent_amount")),
|
||||
status,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch("/api/units", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
toast.error(data?.error ?? "Failed to add unit")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Unit added")
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
} else {
|
||||
router.push(`/properties/${propertyId}`)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500"
|
||||
const labelClass = "block text-sm font-medium text-white/70 mb-1.5"
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Unit Number *</label>
|
||||
<input name="unit_number" required placeholder="e.g. 1A, 101" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Monthly Rent ($) *</label>
|
||||
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Bedrooms</label>
|
||||
<input name="bedrooms" type="number" min={0} defaultValue={1} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Bathrooms</label>
|
||||
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={1} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Sq Ft</label>
|
||||
<input name="sq_ft" type="number" min={0} placeholder="Optional" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<Select
|
||||
options={statusOptions}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea name="notes" rows={3} placeholder="Optional notes..." className={`${inputClass} resize-none`} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex-1 rounded-lg border border-white/10 py-2.5 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Adding…" : "Add Unit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user