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>
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
import { redirect } from "next/navigation"
|
|
import { eq } from "drizzle-orm"
|
|
import { db } from "@/lib/db"
|
|
import { profiles } from "@/lib/db/schema"
|
|
import { getSessionUser } from "@/lib/session"
|
|
import {
|
|
getDashboardStats,
|
|
getRecentRentPayments,
|
|
getOpenMaintenanceRequests,
|
|
getExpiringLeases,
|
|
getMonthlyRevenue,
|
|
getExpenseBreakdown,
|
|
} from "@/lib/db/queries"
|
|
import { StatsCard } from "@/components/dashboard/stats-card"
|
|
import { RevenueChart } from "@/components/dashboard/revenue-chart"
|
|
import { ExpenseBreakdownChart } from "@/components/dashboard/expense-breakdown-chart"
|
|
import { QuickActions } from "@/components/dashboard/quick-actions"
|
|
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
|
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
|
|
import { EmptyState } from "@/components/shared/empty-state"
|
|
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
|
|
import {
|
|
Building2, CreditCard, Wrench, TrendingUp,
|
|
AlertTriangle, ArrowRight, CheckCircle2, Clock,
|
|
Sparkles, ChevronRight,
|
|
} from "lucide-react"
|
|
import Link from "next/link"
|
|
|
|
function GreetingBanner({ name }: { name: string }) {
|
|
const hour = new Date().getHours()
|
|
const greeting = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening"
|
|
const now = new Date()
|
|
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
|
|
|
return (
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-8">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-white">
|
|
{greeting}, {name?.split(" ")[0] ?? "there"} 👋
|
|
</h2>
|
|
<p className="text-sm text-white/40 mt-0.5">{dateStr}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-2">
|
|
<div className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
|
|
</div>
|
|
<span className="text-xs font-medium text-emerald-400">All systems operational</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function OnboardingChecklist() {
|
|
const steps = [
|
|
{ label: "Add your first property", href: "/properties/new", icon: Building2 },
|
|
{ label: "Create a tenant profile", href: "/tenants/new", icon: TrendingUp },
|
|
{ label: "Record a rent payment", href: "/rent", icon: CreditCard },
|
|
{ label: "Set up a lease", href: "/leases/new", icon: CheckCircle2 },
|
|
]
|
|
|
|
return (
|
|
<div className="relative overflow-hidden rounded-2xl border border-indigo-500/20 bg-gradient-to-br from-indigo-600/10 via-[#16161f] to-violet-600/5 p-5 mb-6">
|
|
<div className="absolute top-0 right-0 w-40 h-40 rounded-full bg-indigo-600/10 blur-3xl -z-0" />
|
|
<div className="relative">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-600">
|
|
<Sparkles className="h-4 w-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-bold text-white">Welcome to Property Management Network!</h3>
|
|
<p className="text-xs text-white/50">Complete these steps to get started</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{steps.map((step, i) => (
|
|
<Link
|
|
key={step.label}
|
|
href={step.href}
|
|
className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-sm text-white/60 transition-all hover:border-indigo-500/30 hover:bg-indigo-500/5 hover:text-white"
|
|
>
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 text-[10px] font-bold text-white/30 group-hover:border-indigo-500/40 group-hover:text-indigo-400 transition-colors">
|
|
{i + 1}
|
|
</span>
|
|
<span className="flex-1 text-xs font-medium">{step.label}</span>
|
|
<ChevronRight className="h-3.5 w-3.5 opacity-0 group-hover:opacity-100 text-indigo-400 transition-opacity" />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default async function DashboardPage() {
|
|
const user = await getSessionUser()
|
|
if (!user) redirect("/login")
|
|
|
|
// Fetch profile for greeting
|
|
const profile = await db.query.profiles.findFirst({
|
|
where: eq(profiles.id, user.id),
|
|
columns: { full_name: true },
|
|
})
|
|
|
|
const [stats, recentPayments, openMaintenance, expiringLeases, monthlyRevenue, expenseBreakdown] = await Promise.all([
|
|
getDashboardStats(user.id),
|
|
getRecentRentPayments(user.id),
|
|
getOpenMaintenanceRequests(user.id),
|
|
getExpiringLeases(user.id),
|
|
getMonthlyRevenue(user.id),
|
|
getExpenseBreakdown(user.id),
|
|
])
|
|
|
|
const hasData = stats.totalProperties > 0
|
|
|
|
const rentTrendPct = stats.rentCollectedLastMonth > 0
|
|
? Math.round(((stats.rentCollectedThisMonth - stats.rentCollectedLastMonth) / stats.rentCollectedLastMonth) * 100)
|
|
: null
|
|
|
|
const occupancyColor: "default" | "success" | "warning" | "danger" =
|
|
stats.occupancyRate >= 80 ? "success" : stats.occupancyRate >= 50 ? "warning" : "danger"
|
|
|
|
return (
|
|
<div className="min-h-full">
|
|
{/* Greeting */}
|
|
<GreetingBanner name={(profile as any)?.full_name ?? ""} />
|
|
|
|
{/* Onboarding */}
|
|
{!hasData && <OnboardingChecklist />}
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
|
<StatsCard
|
|
label="Properties"
|
|
value={stats.totalProperties}
|
|
sub={`${stats.totalUnits} unit${stats.totalUnits !== 1 ? "s" : ""} total`}
|
|
icon={Building2}
|
|
variant="default"
|
|
progress={stats.totalProperties > 0 ? Math.min(100, (stats.totalProperties / 10) * 100) : 0}
|
|
/>
|
|
<StatsCard
|
|
label="Occupancy"
|
|
value={`${stats.occupancyRate}%`}
|
|
sub={`${stats.occupiedUnits} occupied · ${stats.vacantUnits} vacant`}
|
|
icon={TrendingUp}
|
|
variant={occupancyColor}
|
|
progress={stats.occupancyRate}
|
|
/>
|
|
<StatsCard
|
|
label="Rent Collected"
|
|
value={formatCurrency(stats.rentCollectedThisMonth)}
|
|
sub={`${formatCurrency(stats.rentPendingThisMonth)} pending`}
|
|
icon={CreditCard}
|
|
variant="success"
|
|
trend={rentTrendPct !== null ? { value: rentTrendPct, label: "vs last month" } : undefined}
|
|
/>
|
|
<StatsCard
|
|
label="Maintenance"
|
|
value={stats.openMaintenanceRequests}
|
|
sub={stats.rentOverdue > 0 ? `${formatCurrency(stats.rentOverdue)} overdue` : "No overdue rent"}
|
|
icon={Wrench}
|
|
variant={stats.openMaintenanceRequests > 0 ? "warning" : "default"}
|
|
/>
|
|
</div>
|
|
|
|
{/* Expiring leases alert */}
|
|
{expiringLeases.length > 0 && (
|
|
<div className="mb-6 rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
|
|
<span className="text-sm font-semibold text-amber-400">
|
|
{expiringLeases.length} lease{expiringLeases.length > 1 ? "s" : ""} expiring soon
|
|
</span>
|
|
<Link href="/leases" className="ml-auto flex items-center gap-1 text-xs text-amber-400/70 hover:text-amber-300 transition">
|
|
View all <ArrowRight className="h-3 w-3" />
|
|
</Link>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{expiringLeases.slice(0, 4).map((lease: any) => {
|
|
const days = daysUntil(lease.lease_end)
|
|
return (
|
|
<div key={lease.id} className="flex items-center justify-between rounded-xl bg-amber-500/[0.04] border border-amber-500/10 px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-3.5 w-3.5 text-amber-400/60 shrink-0" />
|
|
<span className="text-xs text-white/70 truncate">
|
|
{lease.tenant?.first_name} {lease.tenant?.last_name}
|
|
</span>
|
|
</div>
|
|
<span className={`text-xs font-semibold tabular-nums shrink-0 ml-2 ${days <= 7 ? "text-red-400" : days <= 30 ? "text-amber-400" : "text-white/50"}`}>
|
|
{days <= 0 ? "Expired" : `${days}d`}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Middle row: Revenue chart + Quick Actions */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 mb-6">
|
|
<div className="lg:col-span-2">
|
|
<RevenueChart
|
|
data={monthlyRevenue}
|
|
thisMonth={stats.rentCollectedThisMonth}
|
|
pending={stats.rentPendingThisMonth}
|
|
/>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<QuickActions />
|
|
<ExpenseBreakdownChart data={expenseBreakdown} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom row: Recent payments + Open maintenance */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
|
|
{/* Recent Payments */}
|
|
<div className="rounded-2xl 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-2">
|
|
<CreditCard className="h-4 w-4 text-white/30" />
|
|
<h2 className="text-sm font-semibold text-white">Recent Payments</h2>
|
|
</div>
|
|
<Link href="/rent" className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition">
|
|
View all <ArrowRight className="h-3 w-3" />
|
|
</Link>
|
|
</div>
|
|
|
|
{recentPayments.length === 0 ? (
|
|
<EmptyState
|
|
icon={CreditCard}
|
|
title="No payments yet"
|
|
description="Record your first rent payment to track collections."
|
|
action={{ label: "Record payment", href: "/rent" }}
|
|
className="py-10"
|
|
/>
|
|
) : (
|
|
<div className="divide-y divide-white/[0.04]">
|
|
{recentPayments.map((payment: any) => (
|
|
<div key={payment.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-white/[0.02] transition-colors">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/30 to-violet-500/30 text-xs font-bold text-indigo-300">
|
|
{payment.tenant?.first_name?.[0]}{payment.tenant?.last_name?.[0]}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-white truncate">
|
|
{payment.tenant?.first_name} {payment.tenant?.last_name}
|
|
</p>
|
|
<p className="text-xs text-white/40 truncate">
|
|
{payment.property?.name} · {formatDate(payment.due_date)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1 shrink-0 ml-3">
|
|
<span className="text-sm font-bold text-white tabular-nums">
|
|
{formatCurrency(payment.amount)}
|
|
</span>
|
|
<RentStatusBadge status={payment.status} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Open Maintenance */}
|
|
<div className="rounded-2xl 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-2">
|
|
<Wrench className="h-4 w-4 text-white/30" />
|
|
<h2 className="text-sm font-semibold text-white">Open Maintenance</h2>
|
|
{stats.openMaintenanceRequests > 0 && (
|
|
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-amber-500/20 px-1.5 text-[10px] font-bold text-amber-400">
|
|
{stats.openMaintenanceRequests}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Link href="/maintenance" className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition">
|
|
View all <ArrowRight className="h-3 w-3" />
|
|
</Link>
|
|
</div>
|
|
|
|
{openMaintenance.length === 0 ? (
|
|
<EmptyState
|
|
icon={Wrench}
|
|
title="No open requests"
|
|
description="All maintenance is up to date."
|
|
className="py-10"
|
|
/>
|
|
) : (
|
|
<div className="divide-y divide-white/[0.04]">
|
|
{openMaintenance.map((req: any) => (
|
|
<Link
|
|
key={req.id}
|
|
href={`/maintenance/${req.id}`}
|
|
className="flex items-center justify-between px-5 py-3.5 hover:bg-white/[0.02] transition-colors group"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-white group-hover:text-indigo-200 transition-colors">
|
|
{req.title}
|
|
</p>
|
|
<p className="text-xs text-white/40 truncate mt-0.5">
|
|
{req.property?.name}{req.tenant ? ` · ${req.tenant.first_name} ${req.tenant.last_name}` : ""}
|
|
</p>
|
|
</div>
|
|
<div className="ml-3 flex flex-col items-end gap-1 shrink-0">
|
|
<PriorityBadge priority={req.priority} />
|
|
<MaintenanceStatusBadge status={req.status} />
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|