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,156 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Zap, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight, Users } from "lucide-react"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
function monthLabel(year: number, month: number) {
|
||||
return new Date(year, month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||
}
|
||||
|
||||
export function BulkGenerateForm({ leases }: { leases: any[] }) {
|
||||
const router = useRouter()
|
||||
const now = new Date()
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ created: number; skipped: number; message: string } | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
function prevMonth() {
|
||||
if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1)
|
||||
}
|
||||
function nextMonth() {
|
||||
if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1)
|
||||
}
|
||||
|
||||
const totalRent = leases.reduce((s, l) => s + Number(l.rent_amount), 0)
|
||||
|
||||
async function generate() {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
setResult(null)
|
||||
const res = await fetch("/api/rent/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
if (!res.ok) { setError(data.error ?? "Something went wrong"); return }
|
||||
setResult(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Month picker */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-white/30 mb-4">Select Month</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button onClick={prevMonth} className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[180px] text-center text-lg font-bold text-white">
|
||||
{monthLabel(year, month)}
|
||||
</span>
|
||||
<button onClick={nextMonth} className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-indigo-400" />
|
||||
<p className="text-sm font-semibold text-white">Active Tenants ({leases.length})</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-emerald-400">{formatCurrency(totalRent)}<span className="text-xs text-white/30">/mo total</span></p>
|
||||
</div>
|
||||
|
||||
{leases.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-white/30">
|
||||
No active leases found. Add leases first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{leases.map((l: any) => (
|
||||
<div key={l.id} className="flex items-center justify-between px-5 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{l.tenant?.first_name} {l.tenant?.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-white/35">
|
||||
{l.property?.name}{l.unit ? ` · Unit ${l.unit.unit_number}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-white">{formatCurrency(l.rent_amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className={`rounded-xl border px-4 py-3 flex items-start gap-3 ${
|
||||
result.created > 0
|
||||
? "border-emerald-500/20 bg-emerald-500/5"
|
||||
: "border-amber-500/20 bg-amber-500/5"
|
||||
}`}>
|
||||
<CheckCircle2 className={`h-5 w-5 shrink-0 mt-0.5 ${result.created > 0 ? "text-emerald-400" : "text-amber-400"}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-semibold ${result.created > 0 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||
{result.message}
|
||||
</p>
|
||||
{result.skipped > 0 && (
|
||||
<p className="text-xs text-white/40 mt-0.5">{result.skipped} already existed and were skipped.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/rent")}
|
||||
className="rounded-xl border border-white/10 px-5 py-2.5 text-sm text-white/50 hover:text-white transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading || leases.length === 0}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition hover:shadow-lg hover:shadow-indigo-500/25"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">Generating…</span>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4" />
|
||||
Generate {leases.length} Payment{leases.length !== 1 ? "s" : ""} for {monthLabel(year, month)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(result?.created ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={() => router.push("/rent")}
|
||||
className="w-full text-center text-sm text-indigo-400 hover:text-indigo-300 transition"
|
||||
>
|
||||
View Rent Tracker →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { leases as leasesTable } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { BulkGenerateForm } from "./bulk-generate-form"
|
||||
|
||||
export const metadata = { title: "Generate Rent" }
|
||||
|
||||
export default async function GenerateRentPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const leases = await db.query.leases.findMany({
|
||||
where: and(eq(leasesTable.user_id, user.id), eq(leasesTable.status, "active")),
|
||||
columns: { id: true, rent_amount: true },
|
||||
with: {
|
||||
tenant: { columns: { first_name: true, last_name: true } },
|
||||
property: { columns: { name: true } },
|
||||
unit: { columns: { unit_number: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Generate Monthly Rent</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Create pending rent payments for all active tenants in one click
|
||||
</p>
|
||||
</div>
|
||||
<BulkGenerateForm leases={leases ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { BackButton } from "@/components/ui/back-button"
|
||||
import { RentCsvImport } from "./rent-csv-import"
|
||||
|
||||
export const metadata = { title: "Import Rent Payments" }
|
||||
|
||||
export default async function ImportRentPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const tenants = await db.query.tenants.findMany({
|
||||
where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")),
|
||||
columns: { id: true, first_name: true, last_name: true },
|
||||
with: {
|
||||
unit: { columns: { unit_number: true } },
|
||||
property: { columns: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const properties_ = await db
|
||||
.select({ id: properties.id, name: properties.name })
|
||||
.from(properties)
|
||||
.where(eq(properties.user_id, user.id))
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<BackButton href="/rent" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Import Rent Payments</h2>
|
||||
<p className="text-sm text-white/40">Upload a CSV file to add multiple rent payments at once</p>
|
||||
</div>
|
||||
<RentCsvImport tenants={tenants ?? []} properties={properties_ ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Upload, FileText, CheckCircle, XCircle, Download } from "lucide-react"
|
||||
|
||||
interface Tenant { id: string; first_name: string; last_name: string; unit?: any; property?: any }
|
||||
interface Property { id: string; name: string }
|
||||
|
||||
interface ParsedRow {
|
||||
tenant_id: string
|
||||
property_id: string
|
||||
amount: number
|
||||
due_date: string
|
||||
status: string
|
||||
payment_method?: string
|
||||
notes?: string
|
||||
_tenantName?: string
|
||||
_error?: string
|
||||
}
|
||||
|
||||
const SAMPLE_CSV = `tenant_id,property_id,amount,due_date,status,payment_method,notes
|
||||
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-05-01,pending,,
|
||||
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-04-01,paid,bank_transfer,April rent`
|
||||
|
||||
export function RentCsvImport({ tenants, properties }: { tenants: Tenant[]; properties: Property[] }) {
|
||||
const router = useRouter()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [rows, setRows] = useState<ParsedRow[]>([])
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
function downloadSample() {
|
||||
const blob = new Blob([SAMPLE_CSV], { type: "text/csv" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url; a.download = "rent-import-sample.csv"; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const { parse } = await import("papaparse")
|
||||
parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (result) => {
|
||||
const parsed: ParsedRow[] = (result.data as any[]).map((row) => {
|
||||
const tenant = tenants.find((t) => t.id === row.tenant_id?.trim())
|
||||
const amount = parseFloat(row.amount)
|
||||
const errors: string[] = []
|
||||
|
||||
if (!tenant) errors.push("tenant not found")
|
||||
if (isNaN(amount) || amount <= 0) errors.push("invalid amount")
|
||||
if (!row.due_date?.trim()) errors.push("missing due_date")
|
||||
if (!row.property_id?.trim()) errors.push("missing property_id")
|
||||
|
||||
return {
|
||||
tenant_id: row.tenant_id?.trim(),
|
||||
property_id: row.property_id?.trim(),
|
||||
amount,
|
||||
due_date: row.due_date?.trim(),
|
||||
status: row.status?.trim() || "pending",
|
||||
payment_method: row.payment_method?.trim() || undefined,
|
||||
notes: row.notes?.trim() || undefined,
|
||||
_tenantName: tenant ? `${tenant.first_name} ${tenant.last_name}` : row.tenant_id,
|
||||
_error: errors.length ? errors.join(", ") : undefined,
|
||||
}
|
||||
})
|
||||
setRows(parsed)
|
||||
setDone(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const valid = rows.filter((r) => !r._error)
|
||||
if (!valid.length) { toast.error("No valid rows to import"); return }
|
||||
|
||||
setImporting(true)
|
||||
let success = 0, failed = 0
|
||||
|
||||
for (const row of valid) {
|
||||
const res = await fetch("/api/rent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tenant_id: row.tenant_id,
|
||||
property_id: row.property_id,
|
||||
amount: row.amount,
|
||||
due_date: row.due_date,
|
||||
status: row.status,
|
||||
payment_method: row.payment_method,
|
||||
notes: row.notes,
|
||||
}),
|
||||
})
|
||||
res.ok ? success++ : failed++
|
||||
}
|
||||
|
||||
setImporting(false)
|
||||
setDone(true)
|
||||
toast.success(`Imported ${success} payment${success !== 1 ? "s" : ""}${failed ? `, ${failed} failed` : ""}`)
|
||||
if (success > 0) router.refresh()
|
||||
}
|
||||
|
||||
const validCount = rows.filter((r) => !r._error).length
|
||||
const invalidCount = rows.filter((r) => !!r._error).length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Instructions */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-white">How it works</h3>
|
||||
<ol className="space-y-1.5 text-sm text-white/50 list-decimal list-inside">
|
||||
<li>Download the sample CSV template</li>
|
||||
<li>Fill in tenant_id and property_id from your dashboard</li>
|
||||
<li>Upload the filled CSV file</li>
|
||||
<li>Review rows and click Import</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={downloadSample}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-white/60 hover:border-white/20 hover:text-white transition"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Download Sample CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<div
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] p-10 hover:border-indigo-500/40 hover:bg-indigo-500/5 transition"
|
||||
>
|
||||
<Upload className="h-8 w-8 text-white/20" />
|
||||
<p className="text-sm text-white/40">Click to upload CSV file</p>
|
||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
<h3 className="text-sm font-semibold text-white">{rows.length} rows parsed</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{validCount > 0 && <span className="text-emerald-400">{validCount} valid</span>}
|
||||
{invalidCount > 0 && <span className="text-red-400">{invalidCount} invalid</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-5 py-3 gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{row._error
|
||||
? <XCircle className="h-4 w-4 shrink-0 text-red-400" />
|
||||
: <CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
|
||||
}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white truncate">{row._tenantName}</p>
|
||||
<p className="text-xs text-white/40">{row.due_date} · ${row.amount}</p>
|
||||
{row._error && <p className="text-xs text-red-400">{row._error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md border border-white/10 px-2 py-0.5 text-xs text-white/50 capitalize">
|
||||
{row.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!done && (
|
||||
<div className="border-t border-white/[0.06] p-4">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || validCount === 0}
|
||||
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
|
||||
>
|
||||
{importing ? `Importing…` : `Import ${validCount} Payment${validCount !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{done && (
|
||||
<div className="border-t border-white/[0.06] p-4 text-center text-sm text-emerald-400">
|
||||
Import complete!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function RentLoading() {
|
||||
return <TableSkeleton rows={8} cols={5} />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { tenants as tenantsTable } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { RentPaymentForm } from "@/components/forms/rent-payment-form"
|
||||
import { BackButton } from "@/components/ui/back-button"
|
||||
|
||||
export const metadata = { title: "Record Payment" }
|
||||
|
||||
export default async function NewRentPaymentPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const tenants = await db.query.tenants.findMany({
|
||||
where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")),
|
||||
columns: { id: true, first_name: true, last_name: true, property_id: true, unit_id: true },
|
||||
with: {
|
||||
unit: { columns: { unit_number: true, rent_amount: true } },
|
||||
property: { columns: { id: true, name: true } },
|
||||
},
|
||||
orderBy: asc(tenantsTable.first_name),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<BackButton href="/rent" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Record Payment</h2>
|
||||
<p className="text-sm text-white/40">Log a rent payment for a tenant</p>
|
||||
</div>
|
||||
<RentPaymentForm tenants={tenants ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { eq, desc } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { rent_payments } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import Link from "next/link"
|
||||
import { CreditCard, Plus, Upload } from "lucide-react"
|
||||
import { EmptyState } from "@/components/shared/empty-state"
|
||||
import { RentTable } from "./rent-table"
|
||||
import { CsvExportButton } from "@/components/forms/csv-export-button"
|
||||
|
||||
export const metadata = { title: "Rent Tracker" }
|
||||
|
||||
export default async function RentPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const payments = await db.query.rent_payments.findMany({
|
||||
where: eq(rent_payments.user_id, user.id),
|
||||
with: {
|
||||
tenant: { columns: { first_name: true, last_name: true } },
|
||||
property: { columns: { name: true } },
|
||||
unit: { columns: { unit_number: true } },
|
||||
},
|
||||
orderBy: desc(rent_payments.due_date),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Rent Tracker</h2>
|
||||
<p className="text-sm text-white/40">{payments?.length ?? 0} total records</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/rent/import" 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">
|
||||
<Upload className="h-4 w-4" /> Import CSV
|
||||
</Link>
|
||||
<CsvExportButton endpoint="/api/export/rent" filename="rent-payments.csv" label="Export CSV" />
|
||||
<Link href="/rent/new" className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition">
|
||||
<Plus className="h-4 w-4" /> Record Payment
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!payments?.length ? (
|
||||
<EmptyState icon={CreditCard} title="No payments yet" description="Record rent payments to track collections." action={{ label: "Record payment", href: "/rent/new" }} />
|
||||
) : (
|
||||
<RentTable payments={payments} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown, Check, Loader2 } from "lucide-react"
|
||||
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
||||
import { RentActions } from "@/components/forms/rent-actions"
|
||||
import { RentReceiptButton } from "@/components/forms/rent-receipt-button"
|
||||
import { LateNoticeButton } from "@/components/forms/late-notice-button"
|
||||
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function monthLabel(d: Date) {
|
||||
return d.toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||
}
|
||||
|
||||
const STATUSES = ["pending", "paid", "overdue"] as const
|
||||
type Status = typeof STATUSES[number]
|
||||
|
||||
// Inline status picker — click badge to cycle through statuses
|
||||
function InlineStatusEdit({ payment }: { payment: any }) {
|
||||
const [status, setStatus] = useState<Status>(payment.status)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function updateStatus(next: Status) {
|
||||
if (next === status) { setOpen(false); return }
|
||||
setSaving(true)
|
||||
setOpen(false)
|
||||
const res = await fetch(`/api/rent/${payment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: next }),
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.ok) {
|
||||
setStatus(next)
|
||||
toast.success("Status updated")
|
||||
} else {
|
||||
toast.error("Failed to update status")
|
||||
}
|
||||
}
|
||||
|
||||
if (saving) return <Loader2 className="h-3.5 w-3.5 animate-spin text-white/30" />
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setOpen((v) => !v)} title="Click to change status">
|
||||
<RentStatusBadge status={status} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 z-50 mt-1.5 w-32 overflow-hidden rounded-xl border border-white/[0.08] bg-[#1d1d2a] shadow-2xl shadow-black/60 py-1">
|
||||
{STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => updateStatus(s)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-2 text-xs capitalize transition-colors",
|
||||
s === status ? "text-indigo-300 bg-indigo-500/10" : "text-white/60 hover:bg-white/[0.05] hover:text-white"
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
{s === status && <Check className="h-3 w-3 text-indigo-400" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SortKey = "tenant" | "due_date" | "amount" | "status"
|
||||
type SortDir = "asc" | "desc"
|
||||
|
||||
function SortTh({ label, col, active, dir, onClick }: { label: string; col: SortKey; active: SortKey; dir: SortDir; onClick: () => void }) {
|
||||
return (
|
||||
<th
|
||||
className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide cursor-pointer select-none hover:text-white/60 transition-colors"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{label}
|
||||
{active !== col
|
||||
? <ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||
: dir === "asc" ? <ArrowUp className="h-3 w-3 text-indigo-400" /> : <ArrowDown className="h-3 w-3 text-indigo-400" />
|
||||
}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export function RentTable({ payments }: { payments: any[] }) {
|
||||
const now = new Date()
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth())
|
||||
const [sortKey, setSortKey] = useState<SortKey>("due_date")
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||
|
||||
function prevMonth() {
|
||||
if (month === 0) { setMonth(11); setYear((y) => y - 1) }
|
||||
else setMonth((m) => m - 1)
|
||||
}
|
||||
function nextMonth() {
|
||||
if (month === 11) { setMonth(0); setYear((y) => y + 1) }
|
||||
else setMonth((m) => m + 1)
|
||||
}
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
||||
else { setSortKey(key); setSortDir("asc") }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
payments
|
||||
.filter((p) => {
|
||||
const d = new Date(p.due_date)
|
||||
return d.getFullYear() === year && d.getMonth() === month
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let av: string | number = ""
|
||||
let bv: string | number = ""
|
||||
if (sortKey === "tenant") { av = `${a.tenant?.first_name} ${a.tenant?.last_name}`; bv = `${b.tenant?.first_name} ${b.tenant?.last_name}` }
|
||||
if (sortKey === "due_date") { av = a.due_date ?? ""; bv = b.due_date ?? "" }
|
||||
if (sortKey === "amount") { av = Number(a.amount); bv = Number(b.amount) }
|
||||
if (sortKey === "status") { av = a.status ?? ""; bv = b.status ?? "" }
|
||||
if (av < bv) return sortDir === "asc" ? -1 : 1
|
||||
if (av > bv) return sortDir === "asc" ? 1 : -1
|
||||
return 0
|
||||
}),
|
||||
[payments, year, month, sortKey, sortDir]
|
||||
)
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
collected: filtered.filter((p) => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0),
|
||||
pending: filtered.filter((p) => p.status === "pending").reduce((s, p) => s + Number(p.amount), 0),
|
||||
overdue: filtered.filter((p) => p.status === "overdue").reduce((s, p) => s + Number(p.amount), 0),
|
||||
}), [filtered])
|
||||
|
||||
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth()
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Month navigator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[160px] text-center text-sm font-semibold text-white px-1">
|
||||
{monthLabel(new Date(year, month, 1))}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
{!isCurrentMonth && (
|
||||
<button
|
||||
onClick={() => { setYear(now.getFullYear()); setMonth(now.getMonth()) }}
|
||||
className="ml-2 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-white/30">{filtered.length} record{filtered.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Collected", value: stats.collected, color: "text-emerald-400", bar: "bg-emerald-500" },
|
||||
{ label: "Pending", value: stats.pending, color: "text-amber-400", bar: "bg-amber-500" },
|
||||
{ label: "Overdue", value: stats.overdue, color: "text-red-400", bar: "bg-red-500" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||
<p className="text-xs text-white/35 tracking-wide">{s.label}</p>
|
||||
<p className={`mt-1.5 text-xl font-bold tabular-nums ${s.color}`}>{formatCurrency(s.value)}</p>
|
||||
<div className="mt-2 h-1 w-full rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className={`h-1 rounded-full ${s.bar} transition-all`}
|
||||
style={{ width: s.value > 0 ? `${Math.round((s.value / (stats.collected + stats.pending + stats.overdue || 1)) * 100)}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||
<p className="text-sm text-white/30">No payments in {monthLabel(new Date(year, month, 1))}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<SortTh label="Tenant" col="tenant" active={sortKey} dir={sortDir} onClick={() => toggleSort("tenant")} />
|
||||
<th className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">Property / Unit</th>
|
||||
<SortTh label="Due Date" col="due_date" active={sortKey} dir={sortDir} onClick={() => toggleSort("due_date")} />
|
||||
<SortTh label="Amount" col="amount" active={sortKey} dir={sortDir} onClick={() => toggleSort("amount")} />
|
||||
<SortTh label="Status" col="status" active={sortKey} dir={sortDir} onClick={() => toggleSort("status")} />
|
||||
<th className="px-5 py-3.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id} className="group hover:bg-white/[0.02] transition">
|
||||
<td className="px-5 py-3.5">
|
||||
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-medium text-white hover:text-indigo-300 transition">
|
||||
{p.tenant?.first_name} {p.tenant?.last_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<p className="text-sm text-white/70">{p.property?.name}</p>
|
||||
<p className="text-xs text-white/35">Unit {p.unit?.unit_number ?? "—"}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<p className="text-sm text-white/60">{formatDate(p.due_date)}</p>
|
||||
{p.paid_date && <p className="text-xs text-white/30">Paid {formatDate(p.paid_date)}</p>}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<InlineStatusEdit payment={p} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<RentReceiptButton
|
||||
payment={p}
|
||||
tenant={p.tenant}
|
||||
property={p.property}
|
||||
unit={p.unit}
|
||||
/>
|
||||
<LateNoticeButton
|
||||
payment={p}
|
||||
tenant={p.tenant}
|
||||
property={p.property}
|
||||
unit={p.unit}
|
||||
/>
|
||||
<RentActions payment={p} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{filtered.map((p) => (
|
||||
<div key={p.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-semibold text-white hover:text-indigo-300">
|
||||
{p.tenant?.first_name} {p.tenant?.last_name}
|
||||
</Link>
|
||||
<p className="text-xs text-white/40 mt-0.5 truncate">{p.property?.name} · Unit {p.unit?.unit_number ?? "—"}</p>
|
||||
</div>
|
||||
<InlineStatusEdit payment={p} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-xs text-white/35">Due {formatDate(p.due_date)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-base font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
|
||||
<RentActions payment={p} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user