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,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Mail, Search, ArrowRight, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"
|
||||
import { formatDate, formatCurrency } from "@/lib/utils"
|
||||
|
||||
type SortKey = "name" | "property" | "move_in" | "rent"
|
||||
type SortDir = "asc" | "desc"
|
||||
|
||||
function SortIcon({ col, active, dir }: { col: SortKey; active: SortKey; dir: SortDir }) {
|
||||
if (active !== col) return <ArrowUpDown className="h-3 w-3 opacity-30" />
|
||||
return dir === "asc"
|
||||
? <ArrowUp className="h-3 w-3 text-indigo-400" />
|
||||
: <ArrowDown className="h-3 w-3 text-indigo-400" />
|
||||
}
|
||||
|
||||
export function TenantsTable({ tenants }: { tenants: any[] }) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
||||
else { setSortKey(key); setSortDir("asc") }
|
||||
}
|
||||
|
||||
const filtered = tenants
|
||||
.filter((t) => {
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
!q ||
|
||||
`${t.first_name} ${t.last_name}`.toLowerCase().includes(q) ||
|
||||
t.email?.toLowerCase().includes(q) ||
|
||||
t.property?.name?.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let av: string | number = ""
|
||||
let bv: string | number = ""
|
||||
if (sortKey === "name") { av = `${a.first_name} ${a.last_name}`; bv = `${b.first_name} ${b.last_name}` }
|
||||
if (sortKey === "property") { av = a.property?.name ?? ""; bv = b.property?.name ?? "" }
|
||||
if (sortKey === "move_in") { av = a.move_in_date ?? ""; bv = b.move_in_date ?? "" }
|
||||
if (sortKey === "rent") { av = a.unit?.rent_amount ?? 0; bv = b.unit?.rent_amount ?? 0 }
|
||||
if (av < bv) return sortDir === "asc" ? -1 : 1
|
||||
if (av > bv) return sortDir === "asc" ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
|
||||
const th = (label: string, key: SortKey) => (
|
||||
<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={() => toggleSort(key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{label}
|
||||
<SortIcon col={key} active={sortKey} dir={sortDir} />
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-white/25" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, email or property…"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] py-2.5 pl-10 pr-4 text-sm text-white placeholder-white/25 outline-none transition focus:border-indigo-500/50 focus:bg-white/[0.05] focus:ring-1 focus:ring-indigo-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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 tenants match “{search}”</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<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]">
|
||||
{th("Tenant", "name")}
|
||||
{th("Property / Unit", "property")}
|
||||
{th("Move In", "move_in")}
|
||||
{th("Rent / mo", "rent")}
|
||||
<th className="px-5 py-3.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{filtered.map((tenant) => (
|
||||
<tr key={tenant.id} className="group hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/20 text-xs font-bold text-indigo-300 ring-1 ring-inset ring-indigo-500/20">
|
||||
{tenant.first_name?.[0]}{tenant.last_name?.[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{tenant.first_name} {tenant.last_name}</p>
|
||||
{tenant.email && (
|
||||
<span className="flex items-center gap-1 text-xs text-white/35">
|
||||
<Mail className="h-3 w-3" />{tenant.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm text-white/80">{tenant.property?.name ?? "—"}</p>
|
||||
<p className="text-xs text-white/35">Unit {tenant.unit?.unit_number ?? "—"}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm text-white/60">{tenant.move_in_date ? formatDate(tenant.move_in_date) : "—"}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{tenant.unit?.rent_amount ? formatCurrency(tenant.unit.rent_amount) : "—"}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link
|
||||
href={`/tenants/${tenant.id}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-white/30 transition group-hover:text-indigo-400"
|
||||
>
|
||||
View <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{filtered.map((tenant) => (
|
||||
<Link
|
||||
key={tenant.id}
|
||||
href={`/tenants/${tenant.id}`}
|
||||
className="flex items-center gap-3 rounded-xl border border-white/[0.06] bg-[#16161f] p-4 transition hover:border-indigo-500/20 hover:bg-[#1a1a2e]"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/20 text-sm font-bold text-indigo-300">
|
||||
{tenant.first_name?.[0]}{tenant.last_name?.[0]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-white">{tenant.first_name} {tenant.last_name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-white/40">
|
||||
{tenant.property?.name && <span className="truncate">{tenant.property.name}</span>}
|
||||
{tenant.unit?.unit_number && <span>· Unit {tenant.unit.unit_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{tenant.unit?.rent_amount && (
|
||||
<p className="text-sm font-semibold text-white">{formatCurrency(tenant.unit.rent_amount)}</p>
|
||||
)}
|
||||
<p className="text-xs text-white/30">per mo</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user