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,43 @@
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { TenantForm } from "@/components/forms/tenant-form"
|
||||
import { BackButton } from "@/components/ui/back-button"
|
||||
|
||||
export const metadata = { title: "Edit Tenant" }
|
||||
|
||||
export default async function EditTenantPage({ params }: { params: Promise<{ tenantId: string }> }) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const { tenantId } = await params
|
||||
|
||||
const [tenant, properties_] = await Promise.all([
|
||||
db.query.tenants.findFirst({
|
||||
where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, user.id)),
|
||||
}),
|
||||
db.query.properties.findMany({
|
||||
where: eq(properties.user_id, user.id),
|
||||
columns: { id: true, name: true },
|
||||
with: {
|
||||
units: { columns: { id: true, unit_number: true, status: true } },
|
||||
},
|
||||
orderBy: asc(properties.name),
|
||||
}),
|
||||
])
|
||||
|
||||
if (!tenant) notFound()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<BackButton href={`/tenants/${tenantId}`} />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Edit Tenant</h2>
|
||||
<p className="text-sm text-white/40">{tenant.first_name} {tenant.last_name}</p>
|
||||
</div>
|
||||
<TenantForm properties={properties_ ?? []} tenant={tenant} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { and, eq, desc } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { tenants as tenantsTable, rent_payments, maintenance_requests, leases as leasesTable } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import Link from "next/link"
|
||||
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
||||
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
||||
import { Mail, Phone } from "lucide-react"
|
||||
import { CopyButton } from "@/components/shared/copy-button"
|
||||
import { SendReminderButton } from "@/components/shared/send-reminder-button"
|
||||
|
||||
export default async function TenantDetailPage({ params }: { params: Promise<{ tenantId: string }> }) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const { tenantId } = await params
|
||||
|
||||
const tenant = await db.query.tenants.findFirst({
|
||||
where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, user.id)),
|
||||
with: {
|
||||
unit: { columns: { unit_number: true, rent_amount: true, bedrooms: true, bathrooms: true } },
|
||||
property: { columns: { name: true, address_line1: true, city: true, state: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!tenant) notFound()
|
||||
|
||||
const [payments, maintenanceRequests, leases] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(rent_payments)
|
||||
.where(and(eq(rent_payments.user_id, user.id), eq(rent_payments.tenant_id, tenantId)))
|
||||
.orderBy(desc(rent_payments.due_date))
|
||||
.limit(6),
|
||||
db
|
||||
.select()
|
||||
.from(maintenance_requests)
|
||||
.where(and(eq(maintenance_requests.user_id, user.id), eq(maintenance_requests.tenant_id, tenantId)))
|
||||
.orderBy(desc(maintenance_requests.created_at))
|
||||
.limit(5),
|
||||
db
|
||||
.select()
|
||||
.from(leasesTable)
|
||||
.where(and(eq(leasesTable.user_id, user.id), eq(leasesTable.tenant_id, tenantId)))
|
||||
.orderBy(desc(leasesTable.created_at))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
const activeLease = leases?.[0]
|
||||
const portalUrl = `${process.env.NEXT_PUBLIC_APP_URL}/tenant-portal/${tenant.portal_token}`
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-white/40">
|
||||
<Link href="/tenants" className="hover:text-white transition">Tenants</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white/70">{tenant.first_name} {tenant.last_name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600/20 text-lg font-bold text-indigo-400">
|
||||
{tenant.first_name[0]}{tenant.last_name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{tenant.first_name} {tenant.last_name}</h2>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{tenant.email && <span className="flex items-center gap-1 text-sm text-white/50"><Mail className="h-3.5 w-3.5" />{tenant.email}</span>}
|
||||
{tenant.phone && <span className="flex items-center gap-1 text-sm text-white/50"><Phone className="h-3.5 w-3.5" />{tenant.phone}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/tenants/${tenantId}/edit`}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Rent payments */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-white">Rent Payments</h3>
|
||||
<Link href="/rent/new" className="text-xs text-indigo-400 hover:text-indigo-300">+ Record</Link>
|
||||
</div>
|
||||
{!payments?.length ? (
|
||||
<p className="px-5 py-6 text-sm text-white/30">No payments recorded.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{payments.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center justify-between px-5 py-3">
|
||||
<div>
|
||||
<p className="text-sm text-white">{formatDate(p.due_date)}</p>
|
||||
{p.paid_date && <p className="text-xs text-white/40">Paid {formatDate(p.paid_date)}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-white">{formatCurrency(p.amount)}</span>
|
||||
<RentStatusBadge status={p.status} />
|
||||
{(p.status === "pending" || p.status === "overdue") && tenant.email && (
|
||||
<SendReminderButton tenantId={tenant.id} paymentId={p.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Maintenance */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-white">Maintenance Requests</h3>
|
||||
<Link href="/maintenance/new" className="text-xs text-indigo-400 hover:text-indigo-300">+ New</Link>
|
||||
</div>
|
||||
{!maintenanceRequests?.length ? (
|
||||
<p className="px-5 py-6 text-sm text-white/30">No maintenance requests.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{maintenanceRequests.map((r: any) => (
|
||||
<Link key={r.id} href={`/maintenance/${r.id}`} className="flex items-center justify-between px-5 py-3 hover:bg-white/[0.02] transition">
|
||||
<div>
|
||||
<p className="text-sm text-white">{r.title}</p>
|
||||
<p className="text-xs text-white/40">{formatDate(r.created_at)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PriorityBadge priority={r.priority} />
|
||||
<MaintenanceStatusBadge status={r.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Unit info */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Unit</h3>
|
||||
<p className="text-sm font-medium text-white">{tenant.property?.name}</p>
|
||||
{tenant.unit && <p className="text-sm text-white/60">Unit {tenant.unit.unit_number} · {tenant.unit.bedrooms}bd/{tenant.unit.bathrooms}ba</p>}
|
||||
{tenant.unit?.rent_amount && <p className="text-sm font-bold text-white">{formatCurrency(tenant.unit.rent_amount)}<span className="text-xs text-white/40">/mo</span></p>}
|
||||
{tenant.move_in_date && <p className="text-xs text-white/40">Moved in {formatDate(tenant.move_in_date)}</p>}
|
||||
</div>
|
||||
|
||||
{/* Active lease */}
|
||||
{activeLease && (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Lease</h3>
|
||||
<p className="text-xs text-white/50">{formatDate(activeLease.lease_start)} → {formatDate(activeLease.lease_end)}</p>
|
||||
<p className="text-xs text-white/50 capitalize">{activeLease.status}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tenant portal */}
|
||||
<div className="rounded-xl border border-indigo-500/20 bg-indigo-500/5 p-5 space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-indigo-400/60">Tenant Portal</h3>
|
||||
<p className="text-xs text-white/40">Share this link with the tenant to submit maintenance requests.</p>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2">
|
||||
<span className="flex-1 truncate text-xs text-white/60">/tenant-portal/{tenant.portal_token?.slice(0, 12)}…</span>
|
||||
<CopyButton text={portalUrl} />
|
||||
<a href={portalUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-indigo-400 hover:text-indigo-300">Open</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency contact */}
|
||||
{tenant.emergency_contact_name && (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Emergency Contact</h3>
|
||||
<p className="text-sm text-white">{tenant.emergency_contact_name}</p>
|
||||
{tenant.emergency_contact_phone && <p className="text-xs text-white/50">{tenant.emergency_contact_phone}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function TenantsLoading() {
|
||||
return <TableSkeleton rows={6} cols={5} />
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { properties } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { TenantForm } from "@/components/forms/tenant-form"
|
||||
import { BackButton } from "@/components/ui/back-button"
|
||||
|
||||
export const metadata = { title: "Add Tenant" }
|
||||
|
||||
export default async function NewTenantPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const properties_ = await db.query.properties.findMany({
|
||||
where: eq(properties.user_id, user.id),
|
||||
columns: { id: true, name: true },
|
||||
with: {
|
||||
units: { columns: { id: true, unit_number: true, status: true } },
|
||||
},
|
||||
orderBy: asc(properties.name),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<BackButton href="/tenants" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Add Tenant</h2>
|
||||
<p className="text-sm text-white/40">Add a tenant and assign them to a unit</p>
|
||||
</div>
|
||||
<TenantForm properties={properties_ ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { and, eq, desc } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { tenants as tenantsTable } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { Users, Plus } from "lucide-react"
|
||||
import { EmptyState } from "@/components/shared/empty-state"
|
||||
import Link from "next/link"
|
||||
import { TenantsTable } from "./tenants-table"
|
||||
import { CsvExportButton } from "@/components/forms/csv-export-button"
|
||||
|
||||
export const metadata = { title: "Tenants" }
|
||||
|
||||
export default async function TenantsPage() {
|
||||
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")),
|
||||
with: {
|
||||
unit: { columns: { unit_number: true, rent_amount: true } },
|
||||
property: { columns: { name: true } },
|
||||
},
|
||||
orderBy: desc(tenantsTable.created_at),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Tenants</h2>
|
||||
<p className="text-sm text-white/40">{tenants?.length ?? 0} active tenants</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CsvExportButton endpoint="/api/export/tenants" filename="tenants.csv" label="Export CSV" />
|
||||
<Link
|
||||
href="/tenants/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" /> Add Tenant
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!tenants?.length ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No tenants yet"
|
||||
description="Add tenants and assign them to units to start tracking rent and maintenance."
|
||||
action={{ label: "Add tenant", href: "/tenants/new" }}
|
||||
/>
|
||||
) : (
|
||||
<TenantsTable tenants={tenants} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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