Files

319 lines
14 KiB
TypeScript
Raw Permalink Normal View History

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 { getEffectiveOwnerId } from "@/lib/account"
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="mb-8">
<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>
)
}
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")
// Data is scoped to the effective owner's portfolio (team access).
const ownerId = await getEffectiveOwnerId(user.id)
// Fetch profile for greeting (the logged-in user's own name / onboarding state).
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { full_name: true, onboarding_completed: true },
})
const [stats, recentPayments, openMaintenance, expiringLeases, monthlyRevenue, expenseBreakdown] = await Promise.all([
getDashboardStats(ownerId),
getRecentRentPayments(ownerId),
getOpenMaintenanceRequests(ownerId),
getExpiringLeases(ownerId),
getMonthlyRevenue(ownerId),
getExpenseBreakdown(ownerId),
])
const hasData = stats.totalProperties > 0
// New users who haven't finished onboarding and have no data go through the
// dedicated onboarding flow first.
if (!(profile as { onboarding_completed?: boolean })?.onboarding_completed && !hasData) {
redirect("/onboarding")
}
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>
)
}