187 lines
9.2 KiB
TypeScript
187 lines
9.2 KiB
TypeScript
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>
|
||
|
|
)
|
||
|
|
}
|