139 lines
6.2 KiB
TypeScript
139 lines
6.2 KiB
TypeScript
import { redirect } from "next/navigation"
|
|||
|
|
import { eq, desc } from "drizzle-orm"
|
||
|
|
import { db } from "@/lib/db"
|
||
|
|
import { properties as propertiesTable } from "@/lib/db/schema"
|
||
|
|
import { getSessionUser } from "@/lib/session"
|
||
|
|
import Link from "next/link"
|
||
|
|
import { Building2, MapPin, BedDouble, ArrowRight, TrendingUp } from "lucide-react"
|
||
|
|
import { EmptyState } from "@/components/shared/empty-state"
|
||
|
|
import { formatCurrency, getOccupancyRate } from "@/lib/utils"
|
||
|
|
|
||
|
|
export const metadata = { title: "Properties" }
|
||
|
|
|
||
|
|
export default async function PropertiesPage() {
|
||
|
|
const user = await getSessionUser()
|
||
|
|
if (!user) redirect("/login")
|
||
|
|
|
||
|
|
const properties = await db.query.properties.findMany({
|
||
|
|
where: eq(propertiesTable.user_id, user.id),
|
||
|
|
with: {
|
||
|
|
units: { columns: { id: true, status: true, rent_amount: true } },
|
||
|
|
},
|
||
|
|
orderBy: desc(propertiesTable.created_at),
|
||
|
|
})
|
||
|
|
|
||
|
|
const totalMonthly = (properties ?? []).reduce((sum: number, p: any) => {
|
||
|
|
return sum + (p.units ?? []).filter((u: any) => u.status === "occupied").reduce((s: number, u: any) => s + Number(u.rent_amount), 0)
|
||
|
|
}, 0)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Page header */}
|
||
|
|
<div className="flex items-end justify-between">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-lg font-semibold text-white">Properties</h2>
|
||
|
|
<p className="text-sm text-white/40 mt-0.5">
|
||
|
|
{properties?.length ?? 0} {(properties?.length ?? 0) === 1 ? "property" : "properties"}
|
||
|
|
{totalMonthly > 0 && <span className="ml-2 text-emerald-400">· {formatCurrency(totalMonthly)}/mo</span>}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!properties?.length ? (
|
||
|
|
<EmptyState
|
||
|
|
icon={Building2}
|
||
|
|
title="No properties yet"
|
||
|
|
description="Add your first rental property to start managing tenants, rent, and maintenance."
|
||
|
|
action={{ label: "Add property", href: "/properties/new" }}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||
|
|
{(properties as any[]).map((property) => {
|
||
|
|
const units = property.units ?? []
|
||
|
|
const occupied = units.filter((u: any) => u.status === "occupied").length
|
||
|
|
const totalUnits = units.length
|
||
|
|
const occupancy = getOccupancyRate(occupied, totalUnits)
|
||
|
|
const monthlyRent = units
|
||
|
|
.filter((u: any) => u.status === "occupied")
|
||
|
|
.reduce((sum: number, u: any) => sum + Number(u.rent_amount), 0)
|
||
|
|
const potential = units.reduce((sum: number, u: any) => sum + Number(u.rent_amount), 0)
|
||
|
|
|
||
|
|
const occupancyColor =
|
||
|
|
occupancy === 100 ? "text-emerald-400" :
|
||
|
|
occupancy >= 50 ? "text-amber-400" :
|
||
|
|
"text-red-400"
|
||
|
|
|
||
|
|
const barColor =
|
||
|
|
occupancy === 100 ? "bg-emerald-500" :
|
||
|
|
occupancy >= 50 ? "bg-amber-500" :
|
||
|
|
"bg-red-500"
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
key={property.id}
|
||
|
|
href={`/properties/${property.id}`}
|
||
|
|
className="group relative rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 transition-all duration-200 hover:border-indigo-500/30 hover:bg-[#1a1a2e] hover:shadow-xl hover:shadow-indigo-500/5"
|
||
|
|
>
|
||
|
|
{/* Top row */}
|
||
|
|
<div className="flex items-start justify-between mb-4">
|
||
|
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-indigo-500/10 ring-1 ring-inset ring-indigo-500/20">
|
||
|
|
<Building2 className="h-5 w-5 text-indigo-400" />
|
||
|
|
</div>
|
||
|
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||
|
|
occupancy === 100 ? "bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20" :
|
||
|
|
occupancy >= 50 ? "bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20" :
|
||
|
|
"bg-red-500/10 text-red-400 ring-1 ring-red-500/20"
|
||
|
|
}`}>
|
||
|
|
{occupancy}% full
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Name + address */}
|
||
|
|
<div>
|
||
|
|
<h3 className="font-semibold text-white group-hover:text-indigo-200 transition-colors">{property.name}</h3>
|
||
|
|
<div className="mt-1 flex items-center gap-1 text-xs text-white/40">
|
||
|
|
<MapPin className="h-3 w-3 shrink-0" />
|
||
|
|
<span className="truncate">{property.address_line1 ? `${property.address_line1}, ` : ""}{property.city}{property.state ? `, ${property.state}` : ""}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Occupancy bar */}
|
||
|
|
<div className="mt-4">
|
||
|
|
<div className="h-1 w-full rounded-full bg-white/[0.06]">
|
||
|
|
<div
|
||
|
|
className={`h-1 rounded-full transition-all ${barColor}`}
|
||
|
|
style={{ width: `${Math.max(occupancy, 2)}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="mt-1 flex items-center justify-between text-[10px] text-white/30">
|
||
|
|
<span>{occupied} of {totalUnits} units occupied</span>
|
||
|
|
<span>{totalUnits - occupied} vacant</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Stats */}
|
||
|
|
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-white/[0.06] pt-4">
|
||
|
|
<div>
|
||
|
|
<p className="text-xs text-white/30">Collected / mo</p>
|
||
|
|
<p className="mt-0.5 text-sm font-semibold text-white">{formatCurrency(monthlyRent)}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-xs text-white/30">Potential / mo</p>
|
||
|
|
<p className="mt-0.5 text-sm font-semibold text-white/60">{formatCurrency(potential)}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Hover arrow */}
|
||
|
|
<div className="mt-3 flex items-center gap-1 text-xs text-indigo-400/0 group-hover:text-indigo-400/80 transition-all">
|
||
|
|
<span>View details</span>
|
||
|
|
<ArrowRight className="h-3 w-3" />
|
||
|
|
</div>
|
||
|
|
</Link>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|