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:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+173
View File
@@ -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>
)}
</>
)
}
+35
View File
@@ -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>
)
}
+46
View File
@@ -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>
)
}
+162
View File
@@ -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>
)
}
+138
View File
@@ -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>
)
}
+172
View File
@@ -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>
)
}
+221
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
}
+81
View File
@@ -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>
)
}
+141
View File
@@ -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>
)
}
+133
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+150
View File
@@ -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>
)
}
+154
View File
@@ -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>
)
}
+161
View File
@@ -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>
)
}
+127
View File
@@ -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>
)
}