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:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
"use client"
import { useState } from "react"
import { formatDistanceToNow } from "date-fns"
import {
DollarSign, UserPlus, Wrench, FileText, AlertTriangle,
Building2, ClipboardCheck, Users, Receipt, Zap, Activity,
} from "lucide-react"
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
rent_paid: { icon: DollarSign, color: "text-emerald-400", bg: "bg-emerald-500/10" },
rent_overdue: { icon: AlertTriangle, color: "text-red-400", bg: "bg-red-500/10" },
tenant_added: { icon: UserPlus, color: "text-blue-400", bg: "bg-blue-500/10" },
tenant_removed: { icon: Users, color: "text-orange-400", bg: "bg-orange-500/10" },
maintenance_opened: { icon: Wrench, color: "text-yellow-400", bg: "bg-yellow-500/10" },
maintenance_resolved: { icon: ClipboardCheck, color: "text-emerald-400", bg: "bg-emerald-500/10" },
lease_created: { icon: FileText, color: "text-indigo-400", bg: "bg-indigo-500/10" },
lease_expiring: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10" },
expense_added: { icon: Receipt, color: "text-purple-400", bg: "bg-purple-500/10" },
property_added: { icon: Building2, color: "text-cyan-400", bg: "bg-cyan-500/10" },
inspection_completed: { icon: ClipboardCheck, color: "text-teal-400", bg: "bg-teal-500/10" },
vendor_added: { icon: Users, color: "text-pink-400", bg: "bg-pink-500/10" },
ai_action: { icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10" },
}
const FILTER_OPTIONS = [
{ label: "All", value: "" },
{ label: "Rent", value: "rent" },
{ label: "Tenants", value: "tenant" },
{ label: "Maintenance", value: "maintenance" },
{ label: "Leases", value: "lease" },
{ label: "AI", value: "ai" },
]
export function ActivityFeed({ activities }: { activities: any[] }) {
const [filter, setFilter] = useState("")
const filtered = filter
? activities.filter((a) => a.type.startsWith(filter))
: activities
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Activity Feed</h2>
<p className="text-sm text-white/40 mt-0.5">{filtered.length} events</p>
</div>
<Activity className="h-5 w-5 text-white/20" />
</div>
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{FILTER_OPTIONS.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
filter === f.value
? "bg-indigo-600 text-white"
: "border border-white/10 text-white/50 hover:text-white"
}`}
>
{f.label}
</button>
))}
</div>
{/* Feed */}
{filtered.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
<Activity className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No activity yet</p>
<p className="text-xs text-white/20 mt-1">Actions like adding tenants, recording payments, and maintenance requests will appear here</p>
</div>
) : (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] divide-y divide-white/[0.04] overflow-hidden">
{filtered.map((activity) => {
const cfg = typeConfig[activity.type] ?? { icon: Activity, color: "text-white/40", bg: "bg-white/5" }
const Icon = cfg.icon
return (
<div key={activity.id} className="flex items-start gap-4 px-5 py-4 hover:bg-white/[0.02] transition">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${cfg.bg}`}>
<Icon className={`h-3.5 w-3.5 ${cfg.color}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{activity.title}</p>
{activity.description && (
<p className="text-xs text-white/40 mt-0.5">{activity.description}</p>
)}
</div>
<p className="shrink-0 text-xs text-white/25 mt-0.5">
{formatDistanceToNow(new Date(activity.created_at), { addSuffix: true })}
</p>
</div>
)
})}
</div>
)}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { activity_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { ActivityFeed } from "./activity-feed"
export const metadata = { title: "Activity" }
export default async function ActivityPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const activities = await db
.select()
.from(activity_log)
.where(eq(activity_log.user_id, user.id))
.orderBy(desc(activity_log.created_at))
.limit(100)
return <ActivityFeed activities={activities ?? []} />
}
@@ -0,0 +1,216 @@
"use client"
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import {
Zap, BarChart3, Sparkles, Activity, Bot,
TrendingUp, ShieldAlert, Wrench, ArrowRight,
CheckCircle, AlertTriangle, Brain,
} from "lucide-react"
import { formatCurrency } from "@/lib/utils"
const riskBadge: Record<string, string> = {
critical: "text-red-400 bg-red-500/10 ring-red-500/20",
high: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
low: "text-emerald-400 bg-emerald-500/10 ring-emerald-500/20",
}
const priorityBadge: Record<string, string> = {
high: "text-red-400 bg-red-500/10 ring-red-500/20",
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
low: "text-white/40 bg-white/5 ring-white/10",
}
interface Props {
recentRecs: any[]
recentPredictions: any[]
activityLog: any[]
stats: {
totalImpact: number
approvedRecs: number
pendingRecs: number
occupancyRate: number
totalRevenue: number
overdueAmount: number
criticalMaintenance: number
riskAlerts: number
}
}
export function AiDashboardClient({ recentRecs, recentPredictions, activityLog, stats }: Props) {
const hasAlerts = stats.riskAlerts > 0 || stats.criticalMaintenance > 0 || stats.overdueAmount > 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500/10">
<Brain className="h-5 w-5 text-violet-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">AI Dashboard</h2>
<p className="text-sm text-white/40">Your portfolio intelligence at a glance</p>
</div>
</div>
{/* Alert banner */}
{hasAlerts && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 flex items-start gap-3">
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 space-y-1">
{stats.riskAlerts > 0 && <p className="text-sm text-red-300">{stats.riskAlerts} active risk alert{stats.riskAlerts > 1 ? "s" : ""} in your portfolio</p>}
{stats.criticalMaintenance > 0 && <p className="text-sm text-orange-300">{stats.criticalMaintenance} high-priority maintenance request{stats.criticalMaintenance > 1 ? "s" : ""} open</p>}
{stats.overdueAmount > 0 && <p className="text-sm text-amber-300">{formatCurrency(stats.overdueAmount)} in overdue rent</p>}
</div>
<Link href="/predictions" className="shrink-0 text-xs text-red-400 hover:text-red-300 transition flex items-center gap-1">
View <ArrowRight className="h-3 w-3" />
</Link>
</div>
)}
{/* Impact stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "AI Impact", value: formatCurrency(stats.totalImpact), sub: "est. monthly value", color: "text-violet-400", icon: Sparkles },
{ label: "Approved", value: stats.approvedRecs, sub: "recommendations", color: "text-emerald-400", icon: CheckCircle },
{ label: "Pending Review", value: stats.pendingRecs, sub: "recommendations", color: "text-amber-400", icon: Zap },
{ label: "Risk Alerts", value: stats.riskAlerts, sub: "active", color: "text-red-400", icon: ShieldAlert },
].map((s) => {
const Icon = s.icon
return (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 ${s.color}`} />
<span className="text-xs text-white/40">{s.label}</span>
</div>
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
<p className="text-xs text-white/25 mt-1">{s.sub}</p>
</div>
)
})}
</div>
{/* Quick links */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "AI Assistant", href: "/ai", icon: Bot, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" },
{ label: "AI Insights", href: "/recommendations", icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
{ label: "Predictions", href: "/predictions", icon: BarChart3, color: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
{ label: "Impact Tracking", href: "/impact", icon: TrendingUp,color: "text-emerald-400",bg: "bg-emerald-500/10",border: "border-emerald-500/20"},
].map((item) => {
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className={`rounded-xl border ${item.border} ${item.bg} p-4 flex items-center justify-between group hover:brightness-125 transition`}
>
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${item.color}`} />
<span className="text-sm font-medium text-white">{item.label}</span>
</div>
<ArrowRight className="h-3.5 w-3.5 text-white/20 group-hover:text-white/50 transition" />
</Link>
)
})}
</div>
<div className="grid sm:grid-cols-2 gap-4">
{/* Recent recommendations */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06] flex items-center justify-between">
<p className="text-sm font-semibold text-white">Recent Recommendations</p>
<Link href="/recommendations" className="text-xs text-indigo-400 hover:text-indigo-300 transition flex items-center gap-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
{recentRecs.length === 0 ? (
<div className="py-10 text-center">
<p className="text-xs text-white/25">No recommendations yet</p>
<Link href="/recommendations" className="text-xs text-indigo-400 hover:underline mt-1 block">Generate now</Link>
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{recentRecs.map((r) => (
<div key={r.id} className="px-5 py-3.5 flex items-start gap-3">
<Zap className="h-4 w-4 text-violet-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{r.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${priorityBadge[r.priority] ?? priorityBadge.low}`}>
{r.priority}
</span>
<span className={`text-[10px] font-medium capitalize ${r.status === "approved" ? "text-emerald-400" : r.status === "dismissed" ? "text-white/25" : "text-amber-400"}`}>
{r.status}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Recent predictions */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06] flex items-center justify-between">
<p className="text-sm font-semibold text-white">Recent Predictions</p>
<Link href="/predictions" className="text-xs text-indigo-400 hover:text-indigo-300 transition flex items-center gap-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
{recentPredictions.length === 0 ? (
<div className="py-10 text-center">
<p className="text-xs text-white/25">No predictions yet</p>
<Link href="/predictions" className="text-xs text-indigo-400 hover:underline mt-1 block">Run analysis</Link>
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{recentPredictions.map((p) => (
<div key={p.id} className="px-5 py-3.5 flex items-start gap-3">
<BarChart3 className="h-4 w-4 text-blue-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{p.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${riskBadge[p.risk_level] ?? riskBadge.low}`}>
{p.risk_level}
</span>
<span className="text-xs text-white/30">{p.timeframe}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* AI Activity log */}
{activityLog.length > 0 && (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06] flex items-center justify-between">
<p className="text-sm font-semibold text-white flex items-center gap-2">
<Activity className="h-4 w-4 text-violet-400" />
Recent AI Actions
</p>
<Link href="/activity" className="text-xs text-indigo-400 hover:text-indigo-300 transition flex items-center gap-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="divide-y divide-white/[0.04]">
{activityLog.map((a) => (
<div key={a.id} className="px-5 py-3 flex items-center gap-3">
<Zap className="h-3.5 w-3.5 text-violet-400 shrink-0" />
<p className="flex-1 text-sm text-white/60">{a.title}</p>
<span className="text-xs text-white/20 shrink-0">
{formatDistanceToNow(new Date(a.created_at), { addSuffix: true })}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { redirect } from "next/navigation"
import { and, desc, eq, gte, inArray } from "drizzle-orm"
import { db } from "@/lib/db"
import {
ai_recommendations,
ai_predictions,
activity_log,
rent_payments,
units as unitsTable,
maintenance_requests,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { AiDashboardClient } from "./ai-dashboard-client"
export const metadata = { title: "AI Dashboard" }
export default async function AiDashboardPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const now = new Date()
const threeMonthsAgo = new Date(now)
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
const [recs, predictions, activityLog, payments, units, maintenance] = await Promise.all([
db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.orderBy(desc(ai_recommendations.created_at))
.limit(3),
db
.select()
.from(ai_predictions)
.where(eq(ai_predictions.user_id, user.id))
.orderBy(desc(ai_predictions.created_at))
.limit(3),
db
.select()
.from(activity_log)
.where(and(eq(activity_log.user_id, user.id), eq(activity_log.type, "ai_action")))
.orderBy(desc(activity_log.created_at))
.limit(5),
db
.select({ amount: rent_payments.amount, status: rent_payments.status })
.from(rent_payments)
.where(
and(
eq(rent_payments.user_id, user.id),
gte(rent_payments.due_date, threeMonthsAgo.toISOString().slice(0, 10))
)
),
db
.select({ status: unitsTable.status })
.from(unitsTable)
.where(eq(unitsTable.user_id, user.id)),
db
.select({ status: maintenance_requests.status, priority: maintenance_requests.priority })
.from(maintenance_requests)
.where(
and(
eq(maintenance_requests.user_id, user.id),
inArray(maintenance_requests.status, ["open", "in_progress"])
)
),
])
const allRecsData = await db
.select({ status: ai_recommendations.status, action_data: ai_recommendations.action_data })
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
const approvedRecs = allRecsData.filter((r) => r.status === "approved")
let totalImpact = 0
for (const r of approvedRecs) {
totalImpact += Number(r.action_data?.estimated_value ?? 0)
}
const occupiedUnits = units?.filter((u: any) => u.status === "occupied").length ?? 0
const totalUnits = units?.length ?? 0
const occupancyRate = totalUnits > 0 ? Math.round((occupiedUnits / totalUnits) * 100) : 0
const totalRevenue = payments?.filter((p: any) => p.status === "paid").reduce((s: number, p: any) => s + Number(p.amount), 0) ?? 0
const overdueAmount = payments?.filter((p: any) => p.status === "overdue").reduce((s: number, p: any) => s + Number(p.amount), 0) ?? 0
const criticalMaintenance = maintenance?.filter((m: any) => m.priority === "emergency" || m.priority === "high").length ?? 0
const riskAlerts = predictions?.filter((p: any) => ["critical", "high"].includes(p.risk_level)).length ?? 0
return (
<AiDashboardClient
recentRecs={recs ?? []}
recentPredictions={predictions ?? []}
activityLog={activityLog ?? []}
stats={{
totalImpact,
approvedRecs: approvedRecs.length,
pendingRecs: allRecsData.filter((r) => r.status === "pending").length,
occupancyRate,
totalRevenue,
overdueAmount,
criticalMaintenance,
riskAlerts,
}}
/>
)
}
+307
View File
@@ -0,0 +1,307 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { Send, Bot, Sparkles, Lock, Loader2, RotateCcw, Copy, Check, Zap } from "lucide-react"
import Link from "next/link"
import type { Plan } from "@/types"
interface Message {
role: "user" | "assistant"
content: string
error?: boolean
}
const SUGGESTED = [
"Which tenants have overdue rent this month?",
"Summarise my open maintenance requests",
"How is my occupancy rate?",
"Which leases are expiring in 60 days?",
"What were my total expenses this quarter?",
"Which property earns the most rent?",
]
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => {
navigator.clipboard.writeText(text).catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[10px] text-white/25 hover:text-white/60 hover:bg-white/[0.04] transition"
title="Copy"
>
{copied ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy</>}
</button>
)
}
function MessageBubble({ msg }: { msg: Message }) {
if (msg.role === "user") {
return (
<div className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-indigo-600 px-4 py-3 shadow-lg shadow-indigo-500/10">
<p className="text-sm text-white leading-relaxed">{msg.content}</p>
</div>
</div>
)
}
return (
<div className="flex gap-3 items-start">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-md shadow-indigo-500/20 mt-0.5">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="flex-1 min-w-0">
<div className={`rounded-2xl rounded-tl-sm px-4 py-3 ${
msg.error
? "bg-red-500/8 border border-red-500/20 text-red-300"
: "bg-[#1d1d2a] border border-white/[0.06] text-white/85"
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
</div>
{!msg.error && (
<div className="mt-1 pl-1">
<CopyButton text={msg.content} />
</div>
)}
</div>
</div>
)
}
interface AiChatProps {
plan: Plan
limit: number
used: number
}
export function AiChat({ plan, limit, used }: AiChatProps) {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [currentUsed, setCurrentUsed] = useState(used)
const bottomRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const isLocked = limit === 0
const isExhausted = !isLocked && currentUsed >= limit
const usagePct = limit > 0 ? Math.min((currentUsed / limit) * 100, 100) : 0
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, loading])
async function send(question: string) {
if (!question.trim() || loading || isLocked || isExhausted) return
setMessages((prev) => [...prev, { role: "user", content: question }])
setInput("")
setLoading(true)
// Reset textarea height
if (inputRef.current) {
inputRef.current.style.height = "auto"
}
try {
const res = await fetch("/api/ai/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
})
const data = await res.json()
if (!res.ok) {
setMessages((prev) => [...prev, { role: "assistant", content: data.error ?? "Something went wrong.", error: true }])
} else {
setMessages((prev) => [...prev, { role: "assistant", content: data.answer }])
if (data.usage) setCurrentUsed(data.usage.used)
}
} catch {
setMessages((prev) => [...prev, { role: "assistant", content: "Network error. Please try again.", error: true }])
} finally {
setLoading(false)
}
}
function handleKey(e: React.KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send(input)
}
}
return (
<div className="flex flex-col h-[calc(100vh-64px-2rem)] max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-5 shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/20">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-base font-bold text-white">AI Assistant</h2>
<p className="text-xs text-white/35">Powered by your live portfolio data</p>
</div>
</div>
<div className="flex items-center gap-3">
{!isLocked && (
<div className="hidden sm:flex flex-col items-end gap-1">
<div className="flex items-center gap-1.5">
<Zap className="h-3 w-3 text-white/30" />
<span className={`text-xs font-medium tabular-nums ${isExhausted ? "text-red-400" : "text-white/50"}`}>
{currentUsed} / {limit}
</span>
</div>
<div className="w-20 h-1 rounded-full bg-white/[0.06]">
<div
className={`h-1 rounded-full transition-all ${isExhausted ? "bg-red-500" : usagePct > 75 ? "bg-amber-500" : "bg-indigo-500"}`}
style={{ width: `${usagePct}%` }}
/>
</div>
</div>
)}
{messages.length > 0 && (
<button
onClick={() => setMessages([])}
className="flex items-center gap-1.5 rounded-xl border border-white/[0.06] px-3 py-1.5 text-xs text-white/35 hover:text-white hover:border-white/20 transition"
>
<RotateCcw className="h-3 w-3" /> Clear
</button>
)}
</div>
</div>
{/* Locked state */}
{isLocked ? (
<div className="flex-1 flex items-center justify-center">
<div className="relative text-center max-w-sm px-4">
<div className="absolute inset-0 rounded-3xl bg-indigo-500/5 blur-3xl -z-10" />
<div className="relative">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/15 to-violet-500/10 border border-indigo-500/20 mx-auto mb-5 shadow-xl shadow-indigo-500/5">
<Lock className="h-9 w-9 text-indigo-400" />
</div>
<h3 className="text-xl font-bold text-white mb-2">AI requires Pro plan</h3>
<p className="text-sm text-white/45 mb-8 leading-relaxed">
Upgrade to unlock AI-powered insights about your properties, tenants, rent collection, and more.
</p>
<div className="space-y-3">
<Link
href="/settings/billing"
className="flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-xl hover:shadow-indigo-500/25"
>
<Sparkles className="h-4 w-4" />
Upgrade to Pro 50 AI calls/mo
</Link>
<Link
href="/settings/demo"
className="block text-xs text-white/30 hover:text-white/60 transition"
>
Testing? Switch plan in Demo Data
</Link>
</div>
</div>
</div>
</div>
) : (
<>
{/* Chat area */}
<div className="flex-1 overflow-y-auto space-y-5 pr-1 pb-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-7 text-center">
<div>
<div className="relative mx-auto mb-5 h-20 w-20">
<div className="absolute inset-0 rounded-2xl bg-indigo-500/10 blur-xl" />
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/20 to-violet-500/10 border border-indigo-500/20">
<Bot className="h-9 w-9 text-indigo-300" />
</div>
</div>
<h3 className="text-xl font-bold text-white mb-2">How can I help?</h3>
<p className="text-sm text-white/40 max-w-xs leading-relaxed">
I have full access to your live portfolio properties, tenants, payments, maintenance, and leases.
</p>
</div>
<div className="w-full max-w-lg">
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/25 mb-3">Try asking</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SUGGESTED.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-left text-xs text-white/55 hover:border-indigo-500/30 hover:bg-indigo-500/5 hover:text-white/90 transition-all duration-150"
>
{q}
</button>
))}
</div>
</div>
</div>
) : (
<>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{loading && (
<div className="flex gap-3 items-start">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 mt-0.5">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="rounded-2xl rounded-tl-sm bg-[#1d1d2a] border border-white/[0.06] px-4 py-3">
<div className="flex items-center gap-2.5">
<Loader2 className="h-3.5 w-3.5 text-indigo-400 animate-spin" />
<span className="text-xs text-white/35">Analysing your portfolio</span>
</div>
</div>
</div>
)}
<div ref={bottomRef} />
</>
)}
</div>
{/* Input */}
<div className="shrink-0 mt-2">
{isExhausted && (
<div className="mb-2 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-2.5 text-center text-xs text-red-400">
Monthly limit reached. <Link href="/settings/billing" className="font-semibold underline underline-offset-2">Upgrade for more calls </Link>
</div>
)}
<div className={`relative flex items-end gap-2 rounded-2xl border bg-[#16161f] p-3 transition-colors ${
isExhausted ? "border-red-500/20 opacity-60" : "border-white/[0.08] focus-within:border-indigo-500/40"
}`}>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKey}
placeholder={isExhausted ? "Monthly limit reached" : "Ask anything about your portfolio…"}
rows={1}
disabled={loading || isExhausted}
className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-white/25 focus:outline-none min-h-[24px] max-h-32 leading-6 disabled:cursor-not-allowed"
onInput={(e) => {
const el = e.currentTarget
el.style.height = "auto"
el.style.height = `${Math.min(el.scrollHeight, 128)}px`
}}
/>
<button
onClick={() => send(input)}
disabled={!input.trim() || loading || isExhausted}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white transition-all hover:bg-indigo-500 hover:shadow-md hover:shadow-indigo-500/30 disabled:opacity-35 disabled:cursor-not-allowed"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</button>
</div>
<p className="mt-1.5 text-center text-[10px] text-white/20">
Enter to send · Shift+Enter for new line{!isLocked && ` · ${limit - currentUsed} calls left this month`}
</p>
</div>
</>
)}
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/shared/skeleton"
export default function Loading() {
return (
<div className="max-w-3xl mx-auto space-y-4">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-3.5 w-56" />
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-4">
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { AiChat } from "./ai-chat"
import { redirect } from "next/navigation"
import { and, eq, gte, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, usage_events } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
export const metadata = { title: "AI Assistant — Property Management Network" }
export default async function AiPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true },
})
const plan = (profile?.plan ?? "starter") as Plan
const limit = PLAN_LIMITS[plan].maxAiCalls
// Count usage this month
const monthStart = new Date()
monthStart.setDate(1)
monthStart.setHours(0, 0, 0, 0)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(usage_events)
.where(
and(
eq(usage_events.user_id, user.id),
gte(usage_events.created_at, monthStart.toISOString())
)
)
const used = count ?? 0
return <AiChat plan={plan} limit={limit} used={used} />
}
@@ -0,0 +1,223 @@
"use client"
import { useState } from "react"
import { ChevronLeft, ChevronRight, CreditCard, FileText } from "lucide-react"
import { cn, formatCurrency } from "@/lib/utils"
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"]
interface CalendarClientProps {
payments: any[]
leases: any[]
}
export function CalendarClient({ payments, leases }: CalendarClientProps) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [selected, setSelected] = useState<string | null>(null)
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
// Build calendar grid
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells: (number | null)[] = [
...Array(firstDay).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
]
// Pad to complete last row
while (cells.length % 7 !== 0) cells.push(null)
function dateKey(day: number) {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
}
// Group events by date
const eventsByDate: Record<string, { type: "payment" | "lease"; item: any }[]> = {}
for (const p of payments) {
const key = p.due_date?.slice(0, 10)
if (!key) continue
if (!eventsByDate[key]) eventsByDate[key] = []
eventsByDate[key].push({ type: "payment", item: p })
}
for (const l of leases) {
const key = l.lease_end?.slice(0, 10)
if (!key) continue
if (!eventsByDate[key]) eventsByDate[key] = []
eventsByDate[key].push({ type: "lease", item: l })
}
const selectedEvents = selected ? (eventsByDate[selected] ?? []) : []
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Calendar grid */}
<div className="lg:col-span-2 rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
{/* Month nav */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<button onClick={prevMonth} className="rounded-lg p-1.5 text-white/40 hover:text-white transition hover:bg-white/[0.05]">
<ChevronLeft className="h-4 w-4" />
</button>
<h3 className="text-sm font-semibold text-white">{MONTHS[month]} {year}</h3>
<button onClick={nextMonth} className="rounded-lg p-1.5 text-white/40 hover:text-white transition hover:bg-white/[0.05]">
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-white/[0.04]">
{DAYS.map((d) => (
<div key={d} className="py-2 text-center text-[10px] font-semibold uppercase tracking-wider text-white/25">
{d}
</div>
))}
</div>
{/* Days */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
const key = day ? dateKey(day) : null
const events = key ? (eventsByDate[key] ?? []) : []
const isToday = key === todayKey
const isSelected = key === selected
const paymentEvents = events.filter((e) => e.type === "payment")
const leaseEvents = events.filter((e) => e.type === "lease")
return (
<div
key={i}
onClick={() => day && key && setSelected(isSelected ? null : key)}
className={cn(
"relative min-h-[72px] border-b border-r border-white/[0.03] p-1.5 transition-colors",
day ? "cursor-pointer hover:bg-white/[0.03]" : "opacity-0 pointer-events-none",
isSelected && "bg-indigo-600/10 border-indigo-500/20",
)}
>
{day && (
<>
<span className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium",
isToday ? "bg-indigo-600 text-white font-bold" : "text-white/50"
)}>
{day}
</span>
<div className="mt-1 space-y-0.5">
{paymentEvents.slice(0, 2).map((e, j) => (
<div key={j} className={cn(
"truncate rounded px-1 py-0.5 text-[9px] font-medium",
e.item.status === "paid"
? "bg-emerald-500/15 text-emerald-400"
: e.item.status === "overdue"
? "bg-red-500/15 text-red-400"
: "bg-indigo-500/15 text-indigo-400"
)}>
{e.item.tenant?.first_name} {formatCurrency(e.item.amount)}
</div>
))}
{leaseEvents.slice(0, 1).map((e, j) => (
<div key={j} className="truncate rounded bg-amber-500/15 px-1 py-0.5 text-[9px] font-medium text-amber-400">
Lease ends: {e.item.tenant?.first_name}
</div>
))}
{events.length > 3 && (
<div className="text-[9px] text-white/30 px-1">+{events.length - 3} more</div>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
{/* Side panel */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="border-b border-white/[0.06] px-5 py-4">
<h3 className="text-sm font-semibold text-white">
{selected ? new Date(selected + "T12:00:00").toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }) : "Select a date"}
</h3>
</div>
{!selected ? (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<p className="text-sm text-white/30">Click any day to see events</p>
</div>
) : selectedEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<p className="text-sm text-white/30">No events this day</p>
</div>
) : (
<div className="divide-y divide-white/[0.04] p-3 space-y-1">
{selectedEvents.map((e, i) => (
<div key={i} className={cn(
"flex items-start gap-3 rounded-xl p-3",
e.type === "payment" ? "bg-indigo-500/5" : "bg-amber-500/5"
)}>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border",
e.type === "payment"
? "border-indigo-500/20 bg-indigo-500/10 text-indigo-400"
: "border-amber-500/20 bg-amber-500/10 text-amber-400"
)}>
{e.type === "payment" ? <CreditCard className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />}
</div>
<div className="flex-1 min-w-0">
{e.type === "payment" ? (
<>
<p className="text-xs font-semibold text-white">{e.item.tenant?.first_name} {e.item.tenant?.last_name}</p>
<p className="text-xs text-white/40">{formatCurrency(e.item.amount)} due</p>
<span className={cn(
"mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium capitalize",
e.item.status === "paid" ? "bg-emerald-500/15 text-emerald-400" :
e.item.status === "overdue" ? "bg-red-500/15 text-red-400" :
"bg-white/10 text-white/40"
)}>
{e.item.status}
</span>
</>
) : (
<>
<p className="text-xs font-semibold text-white">Lease Expiry</p>
<p className="text-xs text-white/40">{e.item.tenant?.first_name} {e.item.tenant?.last_name}</p>
<p className="text-xs text-white/30">{e.item.property?.name}</p>
</>
)}
</div>
</div>
))}
</div>
)}
{/* Legend */}
<div className="border-t border-white/[0.06] px-5 py-3 space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-white/20 mb-2">Legend</p>
{[
{ color: "bg-indigo-500/20 text-indigo-400", label: "Rent pending" },
{ color: "bg-emerald-500/20 text-emerald-400", label: "Rent paid" },
{ color: "bg-red-500/20 text-red-400", label: "Rent overdue" },
{ color: "bg-amber-500/20 text-amber-400", label: "Lease expiry" },
].map((l) => (
<div key={l.label} className="flex items-center gap-2">
<div className={cn("h-2 w-2 rounded-full", l.color)} />
<span className="text-xs text-white/40">{l.label}</span>
</div>
))}
</div>
</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Skeleton } from "@/components/shared/skeleton"
export default function Loading() {
return (
<div className="max-w-5xl mx-auto space-y-6">
<div className="space-y-1">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-52" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="border-b border-white/[0.06] px-5 py-4 flex items-center justify-between">
<Skeleton className="h-4 w-8 rounded" />
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-8 rounded" />
</div>
<div className="grid grid-cols-7 border-b border-white/[0.04]">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-8 m-1 rounded" />
))}
</div>
<div className="grid grid-cols-7">
{Array.from({ length: 35 }).map((_, i) => (
<Skeleton key={i} className="h-[72px] m-0.5 rounded" />
))}
</div>
</div>
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
</div>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { redirect } from "next/navigation"
import { and, eq, gte, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import { rent_payments, leases } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { CalendarClient } from "./calendar-client"
export const metadata = { title: "Calendar" }
export default async function CalendarPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const now = new Date()
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const rangeEnd = new Date(now.getFullYear(), now.getMonth() + 3, 0)
const [payments, leaseList] = await Promise.all([
db.query.rent_payments.findMany({
where: and(
eq(rent_payments.user_id, user.id),
gte(rent_payments.due_date, rangeStart.toISOString().slice(0, 10)),
lte(rent_payments.due_date, rangeEnd.toISOString().slice(0, 10))
),
columns: { id: true, due_date: true, amount: true, status: true },
with: {
tenant: { columns: { first_name: true, last_name: true } },
},
}),
db.query.leases.findMany({
where: and(
eq(leases.user_id, user.id),
gte(leases.lease_end, new Date().toISOString().slice(0, 10))
),
columns: { id: true, lease_end: true },
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
},
}),
])
return (
<div className="max-w-5xl mx-auto">
<div className="mb-6">
<h2 className="text-lg font-bold text-white">Calendar</h2>
<p className="text-sm text-white/40 mt-0.5">Rent due dates and lease expirations at a glance</p>
</div>
<CalendarClient payments={payments ?? []} leases={leaseList ?? []} />
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardSkeleton } from "@/components/shared/skeleton"
export default function DashboardLoading() {
return <DashboardSkeleton />
}
+317
View File
@@ -0,0 +1,317 @@
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>
)
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import { useEffect } from "react"
import { AlertTriangle, RefreshCw } from "lucide-react"
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-xl border border-red-500/10 bg-red-500/5 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10 mb-4">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<h2 className="text-base font-semibold text-white">Something went wrong</h2>
<p className="mt-2 max-w-sm text-sm text-white/50">
{error.message || "An unexpected error occurred. Try refreshing the page."}
</p>
<button
onClick={reset}
className="mt-6 flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<RefreshCw className="h-4 w-4" />
Try again
</button>
</div>
)
}
@@ -0,0 +1,176 @@
"use client"
import { useState, useMemo } from "react"
import Link from "next/link"
import { Receipt, Download, Plus } from "lucide-react"
import { EmptyState } from "@/components/shared/empty-state"
import { formatCurrency, formatDate } from "@/lib/utils"
import { DeleteButton } from "@/components/shared/delete-button"
const categoryColors: Record<string, string> = {
repairs: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
utilities: "text-blue-400 bg-blue-500/10 ring-blue-500/20",
insurance: "text-purple-400 bg-purple-500/10 ring-purple-500/20",
mortgage: "text-indigo-400 bg-indigo-500/10 ring-indigo-500/20",
taxes: "text-red-400 bg-red-500/10 ring-red-500/20",
management: "text-cyan-400 bg-cyan-500/10 ring-cyan-500/20",
supplies: "text-green-400 bg-green-500/10 ring-green-500/20",
other: "text-white/40 bg-white/5 ring-white/10",
}
export function ExpensesClient({ expenses: initial, properties }: { expenses: any[]; properties: any[] }) {
const [expenses, setExpenses] = useState(initial)
const [propertyFilter, setPropertyFilter] = useState("")
const filtered = useMemo(() =>
propertyFilter ? expenses.filter((e) => e.property_id === propertyFilter) : expenses,
[expenses, propertyFilter]
)
const total = filtered.reduce((s, e) => s + Number(e.amount), 0)
const byCategory = filtered.reduce((acc: Record<string, number>, e) => {
acc[e.category] = (acc[e.category] ?? 0) + Number(e.amount)
return acc
}, {})
function onDeleted(id: string) {
setExpenses((prev) => prev.filter((e) => e.id !== id))
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Expenses</h2>
<p className="text-sm text-white/40 mt-0.5">
{filtered.length} records
{total > 0 && <span className="ml-2 text-rose-400">{formatCurrency(total)} total</span>}
</p>
</div>
<div className="flex items-center gap-2">
<a
href="/api/expenses/export"
className="flex items-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-xs font-medium text-white/50 hover:border-white/20 hover:text-white transition"
>
<Download className="h-3.5 w-3.5" /> Export CSV
</a>
<Link
href="/expenses/new"
className="flex items-center gap-1.5 rounded-xl bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-500 transition"
>
<Plus className="h-3.5 w-3.5" /> Add Expense
</Link>
</div>
</div>
{/* Property filter */}
{properties.length > 1 && (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setPropertyFilter("")}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${!propertyFilter ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
>
All Properties
</button>
{properties.map((p) => (
<button
key={p.id}
onClick={() => setPropertyFilter(p.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${propertyFilter === p.id ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
>
{p.name}
</button>
))}
</div>
)}
{/* Category breakdown */}
{Object.keys(byCategory).length > 0 && (
<div className="flex gap-2 flex-wrap">
{Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amt]) => (
<div key={cat} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ring-1 ring-inset ${categoryColors[cat] ?? categoryColors.other}`}>
<span className="capitalize">{cat}</span>
<span className="opacity-60">{formatCurrency(amt as number)}</span>
</div>
))}
</div>
)}
{!filtered.length ? (
<EmptyState
icon={Receipt}
title="No expenses yet"
description="Track property expenses to monitor profitability and prepare for tax season."
action={{ label: "Add expense", href: "/expenses/new" }}
/>
) : (
<>
{/* 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]">
{["Description", "Property", "Category", "Date", "Amount", ""].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{filtered.map((expense) => (
<tr key={expense.id} className="group hover:bg-white/[0.02] transition">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-white">{expense.description}</p>
{expense.vendor && <p className="text-xs text-white/35">{expense.vendor}</p>}
</td>
<td className="px-5 py-3.5">
<p className="text-sm text-white/60">{expense.property?.name ?? "—"}</p>
{expense.unit && <p className="text-xs text-white/35">Unit {expense.unit.unit_number}</p>}
</td>
<td className="px-5 py-3.5">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${categoryColors[expense.category] ?? categoryColors.other}`}>
{expense.category}
</span>
</td>
<td className="px-5 py-3.5 text-sm text-white/50">{formatDate(expense.expense_date)}</td>
<td className="px-5 py-3.5 text-sm font-bold text-white tabular-nums">{formatCurrency(expense.amount)}</td>
<td className="px-5 py-3.5 text-right">
<DeleteButton id={expense.id} endpoint="/api/expenses" onDeleted={() => onDeleted(expense.id)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{filtered.map((expense) => (
<div key={expense.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate">{expense.description}</p>
{expense.vendor && <p className="text-xs text-white/35 mt-0.5">{expense.vendor}</p>}
</div>
<p className="shrink-0 text-sm font-bold text-white tabular-nums">{formatCurrency(expense.amount)}</p>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${categoryColors[expense.category] ?? categoryColors.other}`}>
{expense.category}
</span>
<span className="text-xs text-white/35">{formatDate(expense.expense_date)}</span>
</div>
<DeleteButton id={expense.id} endpoint="/api/expenses" onDeleted={() => onDeleted(expense.id)} />
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function ExpensesLoading() {
return <TableSkeleton rows={7} cols={5} />
}
+34
View File
@@ -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 { ExpenseForm } from "@/components/forms/expense-form"
import { BackButton } from "@/components/ui/back-button"
export const metadata = { title: "Add Expense" }
export default async function NewExpensePage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const propertyList = 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 } },
},
orderBy: asc(properties.name),
})
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href="/expenses" />
<div>
<h2 className="text-lg font-semibold text-white">Add Expense</h2>
<p className="text-sm text-white/40">Log a property expense</p>
</div>
<ExpenseForm properties={propertyList ?? []} />
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation"
import { asc, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { expenses, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { ExpensesClient } from "./expenses-client"
export const metadata = { title: "Expenses" }
export default async function ExpensesPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [expenseList, propertyList] = await Promise.all([
db.query.expenses.findMany({
where: eq(expenses.user_id, user.id),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
orderBy: desc(expenses.expense_date),
}),
db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(eq(properties.user_id, user.id))
.orderBy(asc(properties.name)),
])
return (
<ExpensesClient
expenses={expenseList ?? []}
properties={propertyList ?? []}
/>
)
}
@@ -0,0 +1,239 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { formatDistanceToNow } from "date-fns"
import {
Plus, Play, Trash2, ToggleLeft, ToggleRight,
Loader2, Bell, DollarSign, Wrench, FileText, Home, X,
} from "lucide-react"
const RULE_TYPES = [
{ value: "overdue_rent", label: "Overdue Rent Reminder", icon: DollarSign, color: "text-red-400", desc: "Remind tenants when rent is overdue" },
{ value: "maintenance_stale",label: "Stale Maintenance Alert", icon: Wrench, color: "text-orange-400", desc: "Follow up on open maintenance requests" },
{ value: "lease_renewal", label: "Lease Renewal Notice", icon: FileText, color: "text-blue-400", desc: "Alert tenants about expiring leases" },
{ value: "vacant_unit", label: "Vacant Unit Reminder", icon: Home, color: "text-amber-400", desc: "Internal alerts for vacant units" },
]
const typeColors: Record<string, string> = {
overdue_rent: "text-red-400 bg-red-500/10",
maintenance_stale: "text-orange-400 bg-orange-500/10",
lease_renewal: "text-blue-400 bg-blue-500/10",
vacant_unit: "text-amber-400 bg-amber-500/10",
}
export function FollowUpsClient({ rules: initial, logs: initialLogs }: { rules: any[]; logs: any[] }) {
const [rules, setRules] = useState(initial)
const [logs, setLogs] = useState(initialLogs)
const [running, setRunning] = useState(false)
const [showForm, setShowForm] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ type: "overdue_rent", name: "", trigger_days: 3, message_template: "" })
async function runFollowUps() {
setRunning(true)
try {
const res = await fetch("/api/follow-ups/run", { method: "POST" })
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to run"); return }
if (data.sent === 0) {
toast.info("No follow-ups triggered — all rules are up to date")
} else {
toast.success(`${data.sent} follow-up${data.sent !== 1 ? "s" : ""} triggered`)
setLogs((prev) => [...data.results, ...prev].slice(0, 30))
}
// refresh last_run_at
const refreshed = await fetch("/api/follow-ups")
const refreshedData = await refreshed.json()
setRules(refreshedData.rules ?? rules)
} catch {
toast.error("Network error — please try again")
} finally {
setRunning(false)
}
}
async function saveRule() {
if (!form.name.trim()) return
setSaving(true)
try {
const res = await fetch("/api/follow-ups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to save"); return }
setRules((prev) => [...prev, data])
setShowForm(false)
setForm({ type: "overdue_rent", name: "", trigger_days: 3, message_template: "" })
toast.success("Rule added")
} catch {
toast.error("Network error")
} finally {
setSaving(false)
}
}
async function toggleRule(id: string, is_active: boolean) {
const res = await fetch(`/api/follow-ups/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !is_active }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
setRules((prev) => prev.map((r) => r.id === id ? data : r))
}
async function deleteRule(id: string) {
await fetch(`/api/follow-ups/${id}`, { method: "DELETE" })
setRules((prev) => prev.filter((r) => r.id !== id))
toast.success("Rule deleted")
}
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Bell className="h-5 w-5 text-indigo-400" />
Automated Follow-ups
</h2>
<p className="text-sm text-white/40 mt-0.5">Set rules and run them manually to trigger follow-up actions</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-1.5 rounded-xl border border-white/10 px-3 py-2 text-xs font-medium text-white/60 hover:text-white transition"
>
<Plus className="h-3.5 w-3.5" /> Add Rule
</button>
<button
onClick={runFollowUps}
disabled={running || rules.filter((r) => r.is_active).length === 0}
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition"
>
{running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{running ? "Running…" : "Run Now"}
</button>
</div>
</div>
{/* Add rule form */}
{showForm && (
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">New Follow-up Rule</p>
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition"><X className="h-4 w-4" /></button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-white/40 mb-1 block">Rule Name *</label>
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. 3-day overdue reminder" className={cls} />
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Type *</label>
<select value={form.type} onChange={(e) => setForm((f) => ({ ...f, type: e.target.value }))} className={cls}>
{RULE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">
Trigger after{" "}
<input
type="number"
min={1}
max={90}
value={form.trigger_days}
onChange={(e) => setForm((f) => ({ ...f, trigger_days: parseInt(e.target.value) || 1 }))}
className="mx-1 w-14 rounded border border-white/10 bg-white/5 px-2 py-0.5 text-sm text-white text-center outline-none focus:border-indigo-500"
/>
{" "}days
</label>
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Custom message (optional leave blank for default)</label>
<textarea value={form.message_template} onChange={(e) => setForm((f) => ({ ...f, message_template: e.target.value }))} rows={3} placeholder="Leave blank to use the default message…" className={cls} />
</div>
<div className="flex gap-3 pt-1">
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
<button onClick={saveRule} disabled={saving || !form.name.trim()} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
{saving ? "Saving…" : "Save Rule"}
</button>
</div>
</div>
)}
{/* Rules list */}
{rules.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
<Bell className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No follow-up rules yet</p>
<p className="text-xs text-white/20 mt-1">Add rules to automate reminders for overdue rent, maintenance, and lease renewals</p>
</div>
) : (
<div className="space-y-2">
{rules.map((rule) => {
const typeInfo = RULE_TYPES.find((t) => t.value === rule.type)
const Icon = typeInfo?.icon ?? Bell
return (
<div key={rule.id} className={`rounded-xl border border-white/[0.06] bg-[#16161f] p-4 flex items-center gap-4 ${!rule.is_active ? "opacity-50" : ""}`}>
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${typeColors[rule.type] ?? "text-white/40 bg-white/5"}`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white">{rule.name}</p>
<p className="text-xs text-white/40 mt-0.5">
{typeInfo?.desc} · Triggers after {rule.trigger_days} day{rule.trigger_days !== 1 ? "s" : ""}
{rule.last_run_at && <span className="ml-2 text-white/25">Last run {formatDistanceToNow(new Date(rule.last_run_at), { addSuffix: true })}</span>}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleRule(rule.id, rule.is_active)} className="p-1.5 text-white/30 hover:text-white transition" title={rule.is_active ? "Disable" : "Enable"}>
{rule.is_active ? <ToggleRight className="h-5 w-5 text-indigo-400" /> : <ToggleLeft className="h-5 w-5" />}
</button>
<button onClick={() => deleteRule(rule.id)} className="p-1.5 text-white/20 hover:text-red-400 transition rounded-lg hover:bg-red-500/10">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Log */}
{logs.length > 0 && (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Follow-up Log</p>
</div>
<div className="divide-y divide-white/[0.04]">
{logs.map((log) => (
<div key={log.id} className="px-5 py-3.5 flex items-start gap-4">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${typeColors[log.type] ?? "text-white/40 bg-white/5"}`}>
<Bell className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{log.subject}</p>
{log.recipient_email && (
<p className="text-xs text-white/35 mt-0.5">{log.recipient_name} · {log.recipient_email}</p>
)}
<p className="text-xs text-white/25 mt-1 line-clamp-2">{log.message}</p>
</div>
<p className="shrink-0 text-xs text-white/25 mt-0.5">
{formatDistanceToNow(new Date(log.created_at), { addSuffix: true })}
</p>
</div>
))}
</div>
</div>
)}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { redirect } from "next/navigation"
import { asc, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { follow_up_rules, follow_up_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { FollowUpsClient } from "./follow-ups-client"
export const metadata = { title: "Automated Follow-ups" }
export default async function FollowUpsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [rules, logs] = await Promise.all([
db
.select()
.from(follow_up_rules)
.where(eq(follow_up_rules.user_id, user.id))
.orderBy(asc(follow_up_rules.created_at)),
db
.select()
.from(follow_up_log)
.where(eq(follow_up_log.user_id, user.id))
.orderBy(desc(follow_up_log.created_at))
.limit(30),
])
return <FollowUpsClient rules={rules ?? []} logs={logs ?? []} />
}
+161
View File
@@ -0,0 +1,161 @@
"use client"
import { formatDistanceToNow } from "date-fns"
import { TrendingUp, ShieldCheck, PiggyBank, Zap, CheckCircle, BarChart3 } from "lucide-react"
import { formatCurrency } from "@/lib/utils"
const typeLabels: Record<string, string> = {
rent_increase: "Rent Increase",
vacancy_alert: "Vacancy",
maintenance_urgent: "Maintenance",
lease_renewal: "Lease Renewal",
expense_alert: "Expense",
cash_flow: "Cash Flow",
risk_alert: "Risk",
opportunity: "Opportunity",
}
export function ImpactClient({
stats,
activityLog,
}: {
stats: {
totals: { generated: number; approved: number; dismissed: number; pending: number; approval_rate: number }
impact: { revenue: number; savings: number; risk_prevented: number; total: number }
recent_approved: any[]
}
activityLog: any[]
}) {
const { totals, impact, recent_approved } = stats
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-violet-400" />
AI Impact Tracking
</h2>
<p className="text-sm text-white/40 mt-0.5">Estimated value from approved AI recommendations</p>
</div>
{/* Impact cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-emerald-400" />
<span className="text-xs text-emerald-400 font-medium">Revenue Added</span>
</div>
<p className="text-2xl font-bold text-white">{formatCurrency(impact.revenue)}</p>
<p className="text-xs text-white/30 mt-1">per month est.</p>
</div>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<PiggyBank className="h-4 w-4 text-blue-400" />
<span className="text-xs text-blue-400 font-medium">Cost Savings</span>
</div>
<p className="text-2xl font-bold text-white">{formatCurrency(impact.savings)}</p>
<p className="text-xs text-white/30 mt-1">per month est.</p>
</div>
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<ShieldCheck className="h-4 w-4 text-amber-400" />
<span className="text-xs text-amber-400 font-medium">Risk Prevented</span>
</div>
<p className="text-2xl font-bold text-white">{formatCurrency(impact.risk_prevented)}</p>
<p className="text-xs text-white/30 mt-1">per month est.</p>
</div>
<div className="rounded-xl border border-violet-500/20 bg-violet-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<Zap className="h-4 w-4 text-violet-400" />
<span className="text-xs text-violet-400 font-medium">Total Impact</span>
</div>
<p className="text-2xl font-bold text-white">{formatCurrency(impact.total)}</p>
<p className="text-xs text-white/30 mt-1">per month est.</p>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Generated", value: totals.generated, color: "text-white" },
{ label: "Approved", value: totals.approved, color: "text-emerald-400" },
{ label: "Dismissed", value: totals.dismissed, color: "text-white/40" },
{ label: "Approval Rate", value: `${totals.approval_rate}%`, color: "text-violet-400" },
].map((s) => (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4 text-center">
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
<p className="text-xs text-white/30 mt-1">{s.label}</p>
</div>
))}
</div>
<div className="grid sm:grid-cols-2 gap-4">
{/* Recent approved */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Recently Approved</p>
</div>
{recent_approved.length === 0 ? (
<div className="py-10 text-center">
<p className="text-xs text-white/25">No approved recommendations yet</p>
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{recent_approved.map((r) => (
<div key={r.id} className="px-5 py-3.5 flex items-start gap-3">
<CheckCircle className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{r.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-white/30">{typeLabels[r.type] ?? r.type}</span>
{r.action_data?.estimated_value > 0 && (
<span className="text-xs text-emerald-400">
+{formatCurrency(r.action_data.estimated_value)}/mo
</span>
)}
</div>
</div>
{r.applied_at && (
<span className="text-xs text-white/20 shrink-0">
{formatDistanceToNow(new Date(r.applied_at), { addSuffix: true })}
</span>
)}
</div>
))}
</div>
)}
</div>
{/* AI activity log */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">AI Action Log</p>
</div>
{activityLog.length === 0 ? (
<div className="py-10 text-center">
<p className="text-xs text-white/25">No AI actions yet</p>
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{activityLog.map((a) => (
<div key={a.id} className="px-5 py-3.5 flex items-start gap-3">
<Zap className="h-4 w-4 text-violet-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white/70">{a.title}</p>
</div>
<span className="text-xs text-white/20 shrink-0">
{formatDistanceToNow(new Date(a.created_at), { addSuffix: true })}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { redirect } from "next/navigation"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations, activity_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { ImpactClient } from "./impact-client"
export const metadata = { title: "AI Impact" }
export default async function ImpactPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [recs, activityRows] = await Promise.all([
db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id)),
db
.select()
.from(activity_log)
.where(and(eq(activity_log.user_id, user.id), eq(activity_log.type, "ai_action")))
.orderBy(desc(activity_log.created_at))
.limit(20),
])
const all = recs ?? []
const approved = all.filter((r) => r.status === "approved")
const dismissed = all.filter((r) => r.status === "dismissed")
let totalRevenue = 0
let totalSavings = 0
let totalRiskPrevented = 0
for (const r of approved) {
const val = Number(r.action_data?.estimated_value ?? 0)
const vtype = r.action_data?.value_type ?? "revenue"
if (vtype === "revenue") totalRevenue += val
else if (vtype === "savings") totalSavings += val
else if (vtype === "risk_prevention") totalRiskPrevented += val
}
const stats = {
totals: {
generated: all.length,
approved: approved.length,
dismissed: dismissed.length,
pending: all.filter((r) => r.status === "pending").length,
approval_rate: all.length > 0 ? Math.round((approved.length / all.length) * 100) : 0,
},
impact: {
revenue: totalRevenue,
savings: totalSavings,
risk_prevented: totalRiskPrevented,
total: totalRevenue + totalSavings + totalRiskPrevented,
},
recent_approved: approved
.sort((a, b) => new Date(b.applied_at ?? 0).getTime() - new Date(a.applied_at ?? 0).getTime())
.slice(0, 5),
}
return <ImpactClient stats={stats} activityLog={activityRows ?? []} />
}
@@ -0,0 +1,184 @@
"use client"
import { useState } from "react"
import { ClipboardList, Plus, X, CheckCircle2, Clock, Trash2 } from "lucide-react"
import { Select } from "@/components/ui/select"
import { formatDate } from "@/lib/utils"
import { toast } from "sonner"
const INSPECTION_TYPES = [
{ value: "move_in", label: "Move-In" },
{ value: "move_out", label: "Move-Out" },
{ value: "routine", label: "Routine" },
]
const typeColors: Record<string, string> = {
move_in: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
move_out: "text-rose-400 bg-rose-500/10 border-rose-500/20",
routine: "text-blue-400 bg-blue-500/10 border-blue-500/20",
}
const statusIcon: Record<string, React.ElementType> = {
draft: Clock,
completed: CheckCircle2,
}
export function InspectionManager({ inspections: initial, properties }: { inspections: any[]; properties: any[] }) {
const [inspections, setInspections] = useState(initial)
const [showForm, setShowForm] = useState(false)
const [loading, setLoading] = useState(false)
const [selectedProp, setSelectedProp] = useState("")
const [form, setForm] = useState({ type: "move_in", unit_id: "", date: new Date().toISOString().slice(0, 10), notes: "" })
const propertyOptions = [
{ value: "", label: "Select property…" },
...properties.map((p: any) => ({ value: p.id, label: p.name })),
]
const units = properties.find((p: any) => p.id === selectedProp)?.units ?? []
const unitOptions = [
{ value: "", label: "No specific unit" },
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
]
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
async function toggleStatus(id: string, current: string) {
const next = current === "completed" ? "draft" : "completed"
const res = await fetch(`/api/inspections/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: next }),
})
if (res.ok) {
setInspections(v => v.map(i => i.id === id ? { ...i, status: next } : i))
toast.success(next === "completed" ? "Marked complete" : "Marked draft")
}
}
async function deleteInspection(id: string) {
await fetch(`/api/inspections/${id}`, { method: "DELETE" })
setInspections(v => v.filter(i => i.id !== id))
toast.success("Inspection deleted")
}
async function create() {
if (!selectedProp) { toast.error("Select a property"); return }
setLoading(true)
const res = await fetch("/api/inspections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ property_id: selectedProp, ...form }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { toast.error(data.error ?? "Failed"); return }
setInspections(v => [data, ...v])
setShowForm(false)
setSelectedProp("")
setForm({ type: "move_in", unit_id: "", date: new Date().toISOString().slice(0, 10), notes: "" })
toast.success("Inspection created")
}
return (
<div className="space-y-4">
{!showForm && (
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 transition hover:shadow-lg hover:shadow-indigo-500/25">
<Plus className="h-4 w-4" /> New Inspection
</button>
)}
{showForm && (
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">New Inspection</p>
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition"><X className="h-4 w-4" /></button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-white/40 mb-1 block">Type</label>
<Select value={form.type} onChange={v => setForm(f => ({ ...f, type: v }))} options={INSPECTION_TYPES} />
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Date</label>
<input type="date" value={form.date} onChange={e => setForm(f => ({ ...f, date: e.target.value }))} className={cls} />
</div>
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Property *</label>
<Select value={selectedProp} onChange={setSelectedProp} options={propertyOptions} />
</div>
{selectedProp && (
<div>
<label className="text-xs text-white/40 mb-1 block">Unit</label>
<Select value={form.unit_id} onChange={v => setForm(f => ({ ...f, unit_id: v }))} options={unitOptions} />
</div>
)}
<div>
<label className="text-xs text-white/40 mb-1 block">Notes</label>
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="Optional notes…" className={cls} />
</div>
<div className="flex gap-3 pt-1">
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
<button onClick={create} disabled={loading || !selectedProp} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
{loading ? "Creating…" : "Create Inspection"}
</button>
</div>
</div>
)}
{inspections.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
<ClipboardList className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No inspections yet</p>
<p className="text-xs text-white/20 mt-1">Create move-in and move-out checklists for each unit</p>
</div>
) : (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">{inspections.length} Inspection{inspections.length !== 1 ? "s" : ""}</p>
</div>
<div className="divide-y divide-white/[0.04]">
{inspections.map((ins: any) => {
const StatusIcon = statusIcon[ins.status] ?? Clock
const typeColor = typeColors[ins.type] ?? typeColors.routine
const typeLabel = INSPECTION_TYPES.find(t => t.value === ins.type)?.label ?? ins.type
return (
<div key={ins.id} className="flex items-center gap-4 px-5 py-4">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border ${typeColor}`}>
<ClipboardList className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-white">{typeLabel} Inspection</p>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-medium capitalize ${typeColor}`}>{typeLabel}</span>
</div>
<p className="text-xs text-white/35 mt-0.5">
{ins.property?.name}{ins.unit ? ` · Unit ${ins.unit.unit_number}` : ""} · {formatDate(ins.date)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleStatus(ins.id, ins.status)}
className="flex items-center gap-1 text-xs hover:opacity-80 transition"
title={ins.status === "completed" ? "Mark as draft" : "Mark as complete"}
>
<StatusIcon className={`h-3.5 w-3.5 ${ins.status === "completed" ? "text-emerald-400" : "text-white/30"}`} />
<span className="text-white/30 capitalize">{ins.status}</span>
</button>
<button
onClick={() => deleteInspection(ins.id)}
className="p-1 text-white/20 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
+2
View File
@@ -0,0 +1,2 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function Loading() { return <TableSkeleton rows={5} cols={3} /> }
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { inspections, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { InspectionManager } from "./inspection-manager"
export const metadata = { title: "Inspections" }
export default async function InspectionsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [inspectionList, propertyList] = await Promise.all([
db.query.inspections.findMany({
where: eq(inspections.user_id, user.id),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
orderBy: desc(inspections.created_at),
}),
db.query.properties.findMany({
where: eq(properties.user_id, user.id),
columns: { id: true, name: true },
with: {
units: { columns: { id: true, unit_number: true } },
},
}),
])
return (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<h2 className="text-lg font-bold text-white">Inspections</h2>
<p className="text-sm text-white/40 mt-0.5">Move-in and move-out condition reports per unit</p>
</div>
<InspectionManager inspections={inspectionList ?? []} properties={propertyList ?? []} />
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { redirect } from "next/navigation"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSession } from "@/lib/session"
import { Sidebar } from "@/components/dashboard/sidebar"
import { Header } from "@/components/dashboard/header"
import { CommandPalette } from "@/components/dashboard/command-palette"
import { PageTransition } from "@/components/dashboard/page-transition"
import { Breadcrumbs } from "@/components/dashboard/breadcrumbs"
import { ScrollToTop } from "@/components/ui/scroll-to-top"
import { ImpersonationBanner } from "@/components/admin/impersonation-banner"
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
const user = session?.user
if (!user) {
redirect("/login")
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
})
// Set by the Better Auth admin plugin while an admin is impersonating.
const impersonating = Boolean((session?.session as { impersonatedBy?: string })?.impersonatedBy)
return (
<div className="flex h-screen flex-col bg-[#09090b] overflow-hidden">
{impersonating && <ImpersonationBanner label={profile?.email ?? user.email} />}
<div className="flex flex-1 overflow-hidden">
<Sidebar profile={profile ?? null} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main id="main-scroll" className="flex-1 overflow-y-auto p-4 sm:p-6">
<Breadcrumbs />
<PageTransition>
{children}
</PageTransition>
</main>
</div>
<CommandPalette />
<ScrollToTop />
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function LeasesLoading() {
return <TableSkeleton rows={6} cols={6} />
}
+57
View File
@@ -0,0 +1,57 @@
import { 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 { LeaseForm } from "@/components/forms/lease-form"
import { BackButton } from "@/components/ui/back-button"
export const metadata = { title: "Add Lease" }
export default async function NewLeasePage({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const params = await searchParams
const prefill = {
tenant_id: params.tenant_id ?? "",
property_id: params.property_id ?? "",
unit_id: params.unit_id ?? "",
rent_amount: params.rent_amount ?? "",
}
const [tenants, properties_] = await Promise.all([
db
.select({
id: tenantsTable.id,
first_name: tenantsTable.first_name,
last_name: tenantsTable.last_name,
unit_id: tenantsTable.unit_id,
property_id: tenantsTable.property_id,
})
.from(tenantsTable)
.where(and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")))
.orderBy(asc(tenantsTable.first_name)),
db.query.properties.findMany({
where: eq(properties.user_id, user.id),
columns: { id: true, name: true },
with: {
units: { columns: { id: true, unit_number: true } },
},
orderBy: asc(properties.name),
}),
])
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href="/leases" />
<div>
<h2 className="text-lg font-semibold text-white">Add Lease</h2>
<p className="text-sm text-white/40">
{prefill.tenant_id ? "Renewing lease — dates pre-filled from previous lease" : "Create a lease record for a tenant"}
</p>
</div>
<LeaseForm tenants={tenants ?? []} properties={properties_ ?? []} prefill={prefill} />
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { redirect } from "next/navigation"
import { eq, asc } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases as leasesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import Link from "next/link"
import { FileText, AlertTriangle, ArrowRight } from "lucide-react"
import { EmptyState } from "@/components/shared/empty-state"
import { formatDate, daysUntil } from "@/lib/utils"
import { cn } from "@/lib/utils"
export const metadata = { title: "Leases" }
const statusColors: Record<string, string> = {
active: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
expired: "text-red-400 bg-red-500/10 border-red-500/20",
terminated: "text-white/40 bg-white/5 border-white/10",
renewed: "text-blue-400 bg-blue-500/10 border-blue-500/20",
}
function DaysChip({ days, status }: { days: number; status: string }) {
if (status !== "active") return <span className="text-sm text-white/25"></span>
if (days <= 0) return <span className="text-xs font-medium text-red-400">Expired</span>
const color = days <= 7 ? "text-red-400" : days <= 30 ? "text-amber-400" : days <= 60 ? "text-yellow-400" : "text-white/40"
return (
<span className={cn("flex items-center gap-1 text-sm font-medium", color)}>
{days}d
{days <= 60 && <AlertTriangle className="h-3 w-3" />}
</span>
)
}
export default async function LeasesPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const leases = await db.query.leases.findMany({
where: eq(leasesTable.user_id, user.id),
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
orderBy: asc(leasesTable.lease_end),
})
const active = leases?.filter((l: any) => l.status === "active").length ?? 0
const expiring = leases?.filter((l: any) => l.status === "active" && daysUntil(l.lease_end) <= 60).length ?? 0
const expired = leases?.filter((l: any) => l.status === "expired").length ?? 0
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Active", value: active, color: "text-emerald-400" },
{ label: "Expiring (60 days)", value: expiring, color: "text-amber-400" },
{ label: "Expired", value: expired, color: "text-red-400" },
].map((s) => (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<p className="text-xs text-white/35">{s.label}</p>
<p className={`mt-1 text-2xl font-bold tabular-nums ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{!leases?.length ? (
<EmptyState
icon={FileText}
title="No leases yet"
description="Add leases to track expiry dates and get automatic reminders."
action={{ label: "Add lease", href: "/leases/new" }}
/>
) : (
<>
{/* Desktop table */}
<div className="hidden md: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]">
{["Tenant", "Property / Unit", "Period", "Rent", "Status", "Ends in", ""].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{leases.map((lease) => {
const days = daysUntil(lease.lease_end)
const canRenew = (lease.status === "active" && days <= 60) || lease.status === "expired"
return (
<tr key={lease.id} className="group hover:bg-white/[0.02] transition">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-white">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</p>
</td>
<td className="px-5 py-3.5">
<p className="text-sm text-white/70">{lease.property?.name}</p>
<p className="text-xs text-white/35">Unit {lease.unit?.unit_number ?? "—"}</p>
</td>
<td className="px-5 py-3.5">
<p className="text-xs text-white/50">{formatDate(lease.lease_start)}</p>
<p className="text-xs text-white/50"> {formatDate(lease.lease_end)}</p>
</td>
<td className="px-5 py-3.5">
<p className="text-sm font-semibold text-white tabular-nums">${lease.rent_amount}<span className="text-xs text-white/30">/mo</span></p>
</td>
<td className="px-5 py-3.5">
<span className={cn("rounded-md border px-2 py-0.5 text-xs font-medium capitalize", statusColors[lease.status] ?? statusColors.active)}>
{lease.status}
</span>
</td>
<td className="px-5 py-3.5">
<DaysChip days={days} status={lease.status} />
</td>
<td className="px-5 py-3.5 text-right">
{canRenew && (
<Link
href={`/leases/new?tenant_id=${lease.tenant_id}&property_id=${lease.property_id}&unit_id=${lease.unit_id ?? ""}&rent_amount=${lease.rent_amount}`}
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
>
Renew <ArrowRight className="h-3 w-3" />
</Link>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-2">
{leases.map((lease) => {
const days = daysUntil(lease.lease_end)
const canRenew = (lease.status === "active" && days <= 60) || lease.status === "expired"
return (
<div key={lease.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-white">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</p>
<p className="text-xs text-white/40 mt-0.5">
{lease.property?.name} · Unit {lease.unit?.unit_number ?? "—"}
</p>
</div>
<span className={cn("shrink-0 rounded-md border px-2 py-0.5 text-xs font-medium capitalize", statusColors[lease.status] ?? statusColors.active)}>
{lease.status}
</span>
</div>
<div className="flex items-center justify-between text-xs text-white/40">
<span>{formatDate(lease.lease_start)} {formatDate(lease.lease_end)}</span>
<span className="font-semibold text-white">${lease.rent_amount}/mo</span>
</div>
<div className="flex items-center justify-between border-t border-white/[0.04] pt-2">
<DaysChip days={days} status={lease.status} />
{canRenew && (
<Link
href={`/leases/new?tenant_id=${lease.tenant_id}&property_id=${lease.property_id}&unit_id=${lease.unit_id ?? ""}&rent_amount=${lease.rent_amount}`}
className="text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
>
Renew
</Link>
)}
</div>
</div>
)
})}
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,110 @@
import { notFound, redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import Link from "next/link"
import { formatCurrency, formatDate } from "@/lib/utils"
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
import { MaintenanceStatusUpdater } from "@/components/forms/maintenance-status-updater"
export default async function MaintenanceDetailPage({ params }: { params: Promise<{ requestId: string }> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const { requestId } = await params
const req = await db.query.maintenance_requests.findFirst({
where: and(
eq(maintenance_requests.id, requestId),
eq(maintenance_requests.user_id, user.id)
),
with: {
property: { columns: { name: true, address_line1: true, city: true } },
unit: { columns: { unit_number: true } },
tenant: { columns: { first_name: true, last_name: true, email: true, phone: true } },
},
})
if (!req) notFound()
const request = req as any
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-white/40">
<Link href="/maintenance" className="hover:text-white transition">Maintenance</Link>
<span>/</span>
<span className="text-white/70 truncate">{request.title}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-xl font-bold text-white">{request.title}</h2>
<PriorityBadge priority={request.priority} />
</div>
<p className="mt-1 text-sm text-white/40">
{request.property?.name}{request.unit ? ` · Unit ${request.unit.unit_number}` : ""} · Opened {formatDate(request.created_at)}
</p>
</div>
<MaintenanceStatusBadge status={request.status} />
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main details */}
<div className="lg:col-span-2 space-y-5">
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-white/30">Description</h3>
<p className="text-sm text-white/80 whitespace-pre-wrap">{request.description}</p>
{request.resolution_notes && (
<>
<h3 className="mb-2 mt-5 text-xs font-medium uppercase tracking-wider text-white/30">Resolution Notes</h3>
<p className="text-sm text-white/80 whitespace-pre-wrap">{request.resolution_notes}</p>
</>
)}
</div>
{/* Status updater */}
<MaintenanceStatusUpdater request={req} />
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Details card */}
<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">Details</h3>
<Row label="Category" value={request.category} />
<Row label="Priority" value={request.priority} />
<Row label="Status" value={request.status.replace("_", " ")} />
{request.assigned_to && <Row label="Assigned To" value={request.assigned_to} />}
{request.estimated_cost && <Row label="Est. Cost" value={formatCurrency(request.estimated_cost)} />}
{request.actual_cost && <Row label="Actual Cost" value={formatCurrency(request.actual_cost)} />}
{request.resolved_at && <Row label="Resolved" value={formatDate(request.resolved_at)} />}
</div>
{/* Tenant card */}
{request.tenant && (
<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">Tenant</h3>
<p className="text-sm font-medium text-white">{request.tenant.first_name} {request.tenant.last_name}</p>
{request.tenant.email && <p className="text-xs text-white/40">{request.tenant.email}</p>}
{request.tenant.phone && <p className="text-xs text-white/40">{request.tenant.phone}</p>}
</div>
)}
</div>
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-xs text-white/40">{label}</span>
<span className="text-xs font-medium text-white capitalize">{value}</span>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { CardGridSkeleton } from "@/components/shared/skeleton"
export default function MaintenanceLoading() {
return <CardGridSkeleton cards={6} />
}
@@ -0,0 +1,119 @@
"use client"
import { useState, useMemo } from "react"
import Link from "next/link"
import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
import { EmptyState } from "@/components/shared/empty-state"
import { formatDate } from "@/lib/utils"
import { Wrench, SlidersHorizontal } from "lucide-react"
import { Select } from "@/components/ui/select"
export function MaintenanceList({ requests, properties }: { requests: any[]; properties: any[] }) {
const [propertyId, setPropertyId] = useState("")
const [status, setStatus] = useState("")
const filtered = useMemo(() =>
requests.filter((r) => {
if (propertyId && r.property_id !== propertyId) return false
if (status && r.status !== status) return false
return true
}), [requests, propertyId, status])
const open = requests.filter((r) => r.status === "open").length
const inProgress = requests.filter((r) => r.status === "in_progress").length
const resolved = requests.filter((r) => r.status === "resolved").length
return (
<div className="space-y-5">
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Open", value: open, color: "text-amber-400", dot: "bg-amber-500" },
{ label: "In Progress", value: inProgress, color: "text-blue-400", dot: "bg-blue-500" },
{ label: "Resolved", value: resolved, color: "text-emerald-400", dot: "bg-emerald-500" },
].map((s) => (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<div className="flex items-center gap-2">
<span className={`h-1.5 w-1.5 rounded-full ${s.dot}`} />
<p className="text-xs text-white/35">{s.label}</p>
</div>
<p className={`mt-1.5 text-2xl font-bold tabular-nums ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap">
<SlidersHorizontal className="h-3.5 w-3.5 text-white/25 shrink-0" />
<Select
value={propertyId}
onChange={setPropertyId}
options={[
{ value: "", label: "All properties" },
...properties.map((p) => ({ value: p.id, label: p.name })),
]}
className="w-40"
/>
<Select
value={status}
onChange={setStatus}
options={[
{ value: "", label: "All statuses" },
{ value: "open", label: "Open" },
{ value: "in_progress", label: "In Progress" },
{ value: "resolved", label: "Resolved" },
{ value: "closed", label: "Closed" },
]}
className="w-36"
/>
{(propertyId || status) && (
<button
onClick={() => { setPropertyId(""); setStatus("") }}
className="text-xs text-white/35 hover:text-white/70 transition"
>
Clear
</button>
)}
<span className="ml-auto text-xs text-white/25">{filtered.length} result{filtered.length !== 1 ? "s" : ""}</span>
</div>
{!filtered.length ? (
<EmptyState
icon={Wrench}
title="No maintenance requests"
description="Create requests to track and manage property maintenance issues."
action={{ label: "New request", href: "/maintenance/new" }}
/>
) : (
<div className="space-y-2">
{filtered.map((req: any) => (
<Link
key={req.id}
href={`/maintenance/${req.id}`}
className="group flex items-start gap-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-4 transition-all duration-150 hover:border-indigo-500/25 hover:bg-[#1a1a2e] hover:shadow-lg hover:shadow-indigo-500/5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-white group-hover:text-indigo-100 transition-colors">{req.title}</p>
<PriorityBadge priority={req.priority} />
</div>
{req.description && (
<p className="mt-1 text-xs text-white/40 line-clamp-1">{req.description}</p>
)}
<div className="mt-2 flex items-center gap-2 flex-wrap text-xs text-white/30">
{req.property?.name && <span>{req.property.name}</span>}
{req.unit && <span>· Unit {req.unit.unit_number}</span>}
{req.tenant && <span>· {req.tenant.first_name} {req.tenant.last_name}</span>}
<span>· {formatDate(req.created_at)}</span>
</div>
</div>
<div className="shrink-0 mt-0.5">
<MaintenanceStatusBadge status={req.status} />
</div>
</Link>
))}
</div>
)}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { MaintenanceForm } from "@/components/forms/maintenance-form"
import { BackButton } from "@/components/ui/back-button"
export const metadata = { title: "New Maintenance Request" }
export default async function NewMaintenancePage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [propertyList, tenantList] = await Promise.all([
db.query.properties.findMany({
where: eq(properties.user_id, user.id),
columns: { id: true, name: true },
with: {
units: { columns: { id: true, unit_number: true } },
},
orderBy: asc(properties.name),
}),
db
.select({
id: tenants.id,
first_name: tenants.first_name,
last_name: tenants.last_name,
unit_id: tenants.unit_id,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
])
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href="/maintenance" />
<div>
<h2 className="text-lg font-semibold text-white">New Maintenance Request</h2>
<p className="text-sm text-white/40">Log a maintenance issue for a property</p>
</div>
<MaintenanceForm properties={propertyList ?? []} tenants={tenantList ?? []} />
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { redirect } from "next/navigation"
import { asc, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { maintenance_requests, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import Link from "next/link"
import { Plus } from "lucide-react"
import { MaintenanceList } from "./maintenance-list"
export const metadata = { title: "Maintenance" }
export default async function MaintenancePage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [requests, propertyList] = await Promise.all([
db.query.maintenance_requests.findMany({
where: eq(maintenance_requests.user_id, user.id),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
tenant: { columns: { first_name: true, last_name: true } },
},
orderBy: desc(maintenance_requests.created_at),
}),
db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(eq(properties.user_id, user.id))
.orderBy(asc(properties.name)),
])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Maintenance</h2>
<p className="text-sm text-white/40">{requests?.length ?? 0} total requests</p>
</div>
<Link href="/maintenance/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" /> New Request
</Link>
</div>
<MaintenanceList requests={requests ?? []} properties={propertyList ?? []} />
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_predictions } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { PredictionsClient } from "./predictions-client"
export const metadata = { title: "Predictive Analytics" }
export default async function PredictionsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const predictions = await db
.select()
.from(ai_predictions)
.where(eq(ai_predictions.user_id, user.id))
.orderBy(desc(ai_predictions.created_at))
return <PredictionsClient predictions={predictions ?? []} />
}
@@ -0,0 +1,212 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import {
TrendingUp, TrendingDown, AlertTriangle, ShieldAlert,
Wrench, Home, Zap, RefreshCw, Loader2, BarChart3,
ArrowUpRight, ArrowDownRight,
} from "lucide-react"
import { formatCurrency } from "@/lib/utils"
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; border: string }> = {
revenue_forecast: { icon: TrendingUp, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
occupancy_forecast: { icon: Home, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" },
cash_flow_risk: { icon: TrendingDown, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
tenant_risk: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
maintenance_risk: { icon: Wrench, color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20" },
vacancy_risk: { icon: ShieldAlert, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
growth_opportunity: { icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
}
const riskBadge: Record<string, string> = {
critical: "text-red-400 bg-red-500/10 ring-red-500/20",
high: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
low: "text-emerald-400 bg-emerald-500/10 ring-emerald-500/20",
}
const confidenceBadge: Record<string, string> = {
high: "text-emerald-400",
medium: "text-amber-400",
low: "text-white/30",
}
export function PredictionsClient({ predictions: initial }: { predictions: any[] }) {
const [predictions, setPredictions] = useState(initial)
const [generating, setGenerating] = useState(false)
const [filter, setFilter] = useState("")
const riskItems = predictions.filter((p) => ["critical", "high"].includes(p.risk_level))
const filtered = filter ? predictions.filter((p) => p.type === filter) : predictions
async function generate() {
setGenerating(true)
try {
const res = await fetch("/api/ai/predictions", { method: "POST" })
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to generate"); return }
setPredictions(data)
toast.success(`${data.length} predictions generated`)
setFilter("")
} catch {
toast.error("Network error — please try again")
} finally {
setGenerating(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-blue-400" />
Predictive Analytics
</h2>
<p className="text-sm text-white/40 mt-0.5">
AI-powered forecasts and risk alerts based on your portfolio trends
{riskItems.length > 0 && (
<span className="ml-2 text-red-400">{riskItems.length} risk alert{riskItems.length > 1 ? "s" : ""}</span>
)}
</p>
</div>
<button
onClick={generate}
disabled={generating}
className="flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50 transition"
>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{generating ? "Analyzing…" : "Run Analysis"}
</button>
</div>
{/* Risk alerts banner */}
{riskItems.length > 0 && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
<div className="flex items-center gap-2 mb-3">
<ShieldAlert className="h-4 w-4 text-red-400" />
<p className="text-sm font-semibold text-red-400">{riskItems.length} Active Risk Alert{riskItems.length > 1 ? "s" : ""}</p>
</div>
<div className="space-y-1.5">
{riskItems.map((r) => (
<div key={r.id} className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${riskBadge[r.risk_level]}`}>
{r.risk_level}
</span>
<span className="text-sm text-white/70">{r.title}</span>
<span className="text-xs text-white/30">{r.timeframe}</span>
</div>
))}
</div>
</div>
)}
{/* Filter */}
{predictions.length > 0 && (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setFilter("")}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${!filter ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
>
All ({predictions.length})
</button>
{["revenue_forecast", "occupancy_forecast", "cash_flow_risk", "vacancy_risk", "growth_opportunity"].map((t) => {
const count = predictions.filter((p) => p.type === t).length
if (!count) return null
return (
<button
key={t}
onClick={() => setFilter(t)}
className={`rounded-full px-3 py-1 text-xs font-medium capitalize transition ${filter === t ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
>
{t.replace(/_/g, " ")} ({count})
</button>
)
})}
</div>
)}
{/* Cards */}
{filtered.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
<BarChart3 className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No predictions yet</p>
<p className="text-xs text-white/20 mt-1">Click "Run Analysis" to generate AI-powered forecasts</p>
</div>
) : (
<div className="grid sm:grid-cols-2 gap-3">
{filtered.map((pred) => {
const cfg = typeConfig[pred.type] ?? typeConfig.growth_opportunity
const Icon = cfg.icon
const changePercent = pred.data?.change_percent ?? 0
const isPositive = changePercent >= 0
const isRisk = ["cash_flow_risk", "tenant_risk", "maintenance_risk", "vacancy_risk"].includes(pred.type)
return (
<div key={pred.id} className={`rounded-xl border ${cfg.border} bg-[#16161f] p-5`}>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${cfg.bg}`}>
<Icon className={`h-4 w-4 ${cfg.color}`} />
</div>
<div>
<p className="text-sm font-semibold text-white">{pred.title}</p>
<p className="text-xs text-white/30">{pred.timeframe}</p>
</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset shrink-0 ${riskBadge[pred.risk_level] ?? riskBadge.low}`}>
{pred.risk_level}
</span>
</div>
<p className="text-sm text-white/50 leading-relaxed mb-3">{pred.prediction}</p>
{pred.data?.metric && (
<div className="rounded-lg bg-white/[0.03] border border-white/[0.06] p-3 flex items-center justify-between">
<div>
<p className="text-xs text-white/30">{pred.data.metric}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-sm text-white/50">
{typeof pred.data.current_value === "number" && pred.data.metric?.toLowerCase().includes("revenue")
? formatCurrency(pred.data.current_value)
: `${pred.data.current_value}${pred.data.metric?.includes("%") || pred.data.metric?.toLowerCase().includes("rate") ? "%" : ""}`}
</span>
<span className="text-white/20"></span>
<span className="text-sm font-semibold text-white">
{typeof pred.data.predicted_value === "number" && pred.data.metric?.toLowerCase().includes("revenue")
? formatCurrency(pred.data.predicted_value)
: `${pred.data.predicted_value}${pred.data.metric?.includes("%") || pred.data.metric?.toLowerCase().includes("rate") ? "%" : ""}`}
</span>
</div>
</div>
{changePercent !== 0 && (
<div className={`flex items-center gap-1 text-sm font-semibold ${
isRisk
? (isPositive ? "text-red-400" : "text-emerald-400")
: (isPositive ? "text-emerald-400" : "text-red-400")
}`}>
{isPositive
? <ArrowUpRight className="h-4 w-4" />
: <ArrowDownRight className="h-4 w-4" />}
{Math.abs(changePercent)}%
</div>
)}
</div>
)}
<div className="flex items-center gap-1 mt-3">
<span className="text-xs text-white/25">Confidence:</span>
<span className={`text-xs font-medium capitalize ${confidenceBadge[pred.confidence] ?? confidenceBadge.medium}`}>
{pred.confidence}
</span>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
@@ -0,0 +1,116 @@
"use client"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { FileText, Download, Trash2, Loader2 } from "lucide-react"
import { FileUpload } from "@/components/shared/file-upload"
import { formatDate } from "@/lib/utils"
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function DocumentsPage() {
const params = useParams()
const propertyId = params.propertyId as string
const router = useRouter()
const [docs, setDocs] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [deleting, setDeleting] = useState<string | null>(null)
const [propertyName, setPropertyName] = useState("")
useEffect(() => {
fetch(`/api/documents?property_id=${propertyId}`)
.then((r) => r.json())
.then((data) => {
setDocs(data.documents ?? [])
setPropertyName(data.propertyName ?? "")
setLoading(false)
})
}, [propertyId])
async function deleteDoc(id: string) {
setDeleting(id)
await fetch(`/api/documents/${id}`, { method: "DELETE" })
setDocs((prev) => prev.filter((d) => d.id !== id))
setDeleting(null)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-sm text-white/40 mb-1">
<Link href="/properties" className="hover:text-white transition">Properties</Link>
<span>/</span>
<Link href={`/properties/${propertyId}`} className="hover:text-white transition">{propertyName || propertyId}</Link>
<span>/</span>
<span className="text-white/70">Documents</span>
</div>
<h2 className="text-lg font-semibold text-white">Documents</h2>
<p className="text-sm text-white/40">{docs.length} file{docs.length !== 1 ? "s" : ""}</p>
</div>
</div>
<FileUpload
propertyId={propertyId}
onUploaded={(doc) => setDocs((prev) => [doc, ...prev])}
/>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-white/30" />
</div>
) : docs.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
<FileText className="h-10 w-10 text-white/10 mb-3" />
<p className="text-sm text-white/40">No documents yet</p>
<p className="text-xs text-white/25 mt-1">Upload leases, insurance, or any property files</p>
</div>
) : (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="divide-y divide-white/[0.04]">
{docs.map((doc) => (
<div key={doc.id} className="flex items-center gap-4 px-5 py-4">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-500/10">
<FileText className="h-4 w-4 text-indigo-400" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-white">{doc.name}</p>
<p className="text-xs text-white/40">
{formatBytes(doc.file_size ?? 0)} · {formatDate(doc.created_at)}
</p>
</div>
<div className="flex items-center gap-2">
<a
href={doc.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 text-white/50 hover:text-white transition"
>
<Download className="h-3.5 w-3.5" />
</a>
<button
onClick={() => deleteDoc(doc.id)}
disabled={deleting === doc.id}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 text-white/50 hover:text-red-400 hover:border-red-500/30 transition disabled:opacity-40"
>
{deleting === doc.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,28 @@
import { notFound, redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { PropertyForm } from "@/components/forms/property-form"
export default async function EditPropertyPage({ params }: { params: Promise<{ propertyId: string }> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const { propertyId } = await params
const property = await db.query.properties.findFirst({
where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)),
})
if (!property) notFound()
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Edit Property</h2>
<p className="text-sm text-white/40">{property.name}</p>
</div>
<PropertyForm property={property} />
</div>
)
}
@@ -0,0 +1,201 @@
import { notFound, redirect } from "next/navigation"
import { and, eq, gte } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties as propertiesTable, rent_payments, expenses as expensesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import Link from "next/link"
import { MapPin, Plus, BedDouble, Bath, Edit } from "lucide-react"
import { formatCurrency, getOccupancyRate } from "@/lib/utils"
import { DeletePropertyButton } from "@/components/forms/delete-property-button"
import { AiMaintenanceSummary } from "@/components/forms/ai-maintenance-summary"
import { PropertyRevenueChart } from "@/components/dashboard/property-revenue-chart"
import { PropertyPhotoUpload } from "@/components/forms/property-photo-upload"
export default async function PropertyDetailPage({ params }: { params: Promise<{ propertyId: string }> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const { propertyId } = await params
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
sixMonthsAgo.setDate(1)
const rangeStart = sixMonthsAgo.toISOString().slice(0, 10)
const [property, payments, expenses] = await Promise.all([
db.query.properties.findFirst({
where: and(eq(propertiesTable.id, propertyId), eq(propertiesTable.user_id, user.id)),
with: {
units: {
with: {
current_tenant: { columns: { first_name: true, last_name: true } },
},
},
},
}),
db
.select({ amount: rent_payments.amount, status: rent_payments.status, due_date: rent_payments.due_date })
.from(rent_payments)
.where(
and(
eq(rent_payments.user_id, user.id),
eq(rent_payments.property_id, propertyId),
gte(rent_payments.due_date, rangeStart)
)
),
db
.select({ amount: expensesTable.amount, expense_date: expensesTable.expense_date })
.from(expensesTable)
.where(
and(
eq(expensesTable.user_id, user.id),
eq(expensesTable.property_id, propertyId),
gte(expensesTable.expense_date, rangeStart)
)
),
])
if (!property) notFound()
// Build 6-month chart data
const chartData = Array.from({ length: 6 }, (_, i) => {
const d = new Date(); d.setMonth(d.getMonth() - (5 - i)); d.setDate(1)
const key = d.toISOString().slice(0, 7)
const label = d.toLocaleDateString("en-US", { month: "short" })
const revenue = (payments ?? []).filter(p => p.status === "paid" && p.due_date?.startsWith(key)).reduce((s, p) => s + Number(p.amount), 0)
const expense = (expenses ?? []).filter(e => e.expense_date?.startsWith(key)).reduce((s, e) => s + Number(e.amount), 0)
return { label, revenue, expense }
})
const units = property.units ?? []
const occupied = units.filter((u: any) => u.status === "occupied").length
const occupancy = getOccupancyRate(occupied, units.length)
const statusColors: Record<string, string> = {
occupied: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
vacant: "text-white/50 bg-white/5 border-white/10",
maintenance: "text-amber-400 bg-amber-500/10 border-amber-500/20",
unavailable: "text-red-400 bg-red-500/10 border-red-500/20",
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-sm text-white/40 mb-1">
<Link href="/properties" className="hover:text-white transition">Properties</Link>
<span>/</span>
<span className="text-white/70">{property.name}</span>
</div>
<h2 className="text-xl font-bold text-white">{property.name}</h2>
<div className="mt-1 flex items-center gap-1 text-sm text-white/40">
<MapPin className="h-3.5 w-3.5" />
{property.address_line1}, {property.city}{property.state ? `, ${property.state}` : ""}
</div>
</div>
<div className="flex items-center gap-2">
<AiMaintenanceSummary propertyId={propertyId} />
<Link
href={`/properties/${propertyId}/edit`}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/70 hover:border-white/20 hover:text-white transition"
>
<Edit className="h-3.5 w-3.5" /> Edit
</Link>
<DeletePropertyButton propertyId={propertyId} />
</div>
</div>
{/* Property Photo */}
<PropertyPhotoUpload propertyId={propertyId} currentImageUrl={property.image_url} />
{/* Stats row */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[
{ label: "Total Units", value: units.length },
{ label: "Occupied", value: occupied },
{ label: "Vacant", value: units.length - occupied },
{ label: "Occupancy", value: `${occupancy}%` },
].map((s) => (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<p className="text-xs text-white/40">{s.label}</p>
<p className="mt-1 text-xl font-bold text-white">{s.value}</p>
</div>
))}
</div>
{/* Units */}
<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">Units</h3>
<Link
href={`/properties/${propertyId}/units/new`}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 transition"
>
<Plus className="h-3.5 w-3.5" /> Add Unit
</Link>
</div>
{units.length === 0 ? (
<div className="py-10 text-center text-sm text-white/30">
No units yet. Add your first unit.
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{units.map((unit: any) => (
<div key={unit.id} className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-sm font-bold text-white">
{unit.unit_number}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">Unit {unit.unit_number}</span>
<span className={`rounded-full border px-2 py-0.5 text-xs font-medium ${statusColors[unit.status] ?? statusColors.vacant}`}>
{unit.status}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-xs text-white/40">
<span className="flex items-center gap-1"><BedDouble className="h-3 w-3" />{unit.bedrooms} bed</span>
<span className="flex items-center gap-1"><Bath className="h-3 w-3" />{unit.bathrooms} bath</span>
{unit.sq_ft && <span>{unit.sq_ft} sqft</span>}
</div>
{unit.current_tenant && (
<p className="mt-0.5 text-xs text-indigo-400">
{unit.current_tenant.first_name} {unit.current_tenant.last_name}
</p>
)}
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-white">{formatCurrency(unit.rent_amount)}<span className="text-xs text-white/40">/mo</span></p>
</div>
</div>
))}
</div>
)}
</div>
{/* Revenue chart */}
<PropertyRevenueChart data={chartData} />
{/* Quick links */}
<div className="flex gap-3 flex-wrap">
{[
{ label: "View Tenants", href: `/tenants?property=${propertyId}` },
{ label: "Maintenance", href: `/maintenance?property=${propertyId}` },
{ label: "Expenses", href: `/expenses?property=${propertyId}` },
{ label: "Documents", href: `/properties/${propertyId}/documents` },
].map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
>
{link.label}
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button"
import { UnitForm } from "@/components/forms/unit-form"
export const metadata = { title: "Add Unit" }
export default async function NewUnitPage({ params }: { params: Promise<{ propertyId: string }> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const { propertyId } = await params
const property = await db.query.properties.findFirst({
where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)),
columns: { id: true, name: true },
})
if (!property) redirect("/properties")
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href={`/properties/${propertyId}`} />
<div>
<h2 className="text-lg font-semibold text-white">Add Unit</h2>
<p className="text-sm text-white/40">{property.name}</p>
</div>
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
<UnitForm propertyId={propertyId} />
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { CardGridSkeleton } from "@/components/shared/skeleton"
export default function PropertiesLoading() {
return <CardGridSkeleton cards={6} />
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation"
import { getSessionUser } from "@/lib/session"
import { PropertyForm } from "@/components/forms/property-form"
import { BackButton } from "@/components/ui/back-button"
export const metadata = { title: "Add Property" }
export default async function NewPropertyPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href="/properties" />
<div>
<h2 className="text-lg font-semibold text-white">Add Property</h2>
<p className="text-sm text-white/40">Fill in the details for your rental property</p>
</div>
<PropertyForm />
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
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>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { RecommendationsClient } from "./recommendations-client"
export const metadata = { title: "AI Recommendations" }
export default async function RecommendationsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const recommendations = await db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.orderBy(desc(ai_recommendations.created_at))
return <RecommendationsClient recommendations={recommendations ?? []} />
}
@@ -0,0 +1,192 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import {
Zap, TrendingUp, AlertTriangle, Wrench, FileText,
DollarSign, RefreshCw, CheckCircle, XCircle, Loader2,
Lightbulb, ShieldAlert,
} from "lucide-react"
const typeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; border: string }> = {
rent_increase: { icon: TrendingUp, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
vacancy_alert: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
maintenance_urgent: { icon: Wrench, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
lease_renewal: { icon: FileText, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" },
expense_alert: { icon: DollarSign, color: "text-orange-400", bg: "bg-orange-500/10", border: "border-orange-500/20" },
cash_flow: { icon: TrendingUp, color: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
risk_alert: { icon: ShieldAlert, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
opportunity: { icon: Lightbulb, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" },
}
const priorityBadge: Record<string, string> = {
high: "text-red-400 bg-red-500/10 ring-red-500/20",
medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20",
low: "text-white/40 bg-white/5 ring-white/10",
}
export function RecommendationsClient({ recommendations: initial }: { recommendations: any[] }) {
const [recs, setRecs] = useState(initial)
const [generating, setGenerating] = useState(false)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [filter, setFilter] = useState<"pending" | "approved" | "dismissed">("pending")
const filtered = recs.filter((r) => r.status === filter)
const pendingCount = recs.filter((r) => r.status === "pending").length
async function generate() {
setGenerating(true)
try {
const res = await fetch("/api/ai/recommendations", { method: "POST" })
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to generate"); return }
setRecs((prev) => [...data, ...prev.filter((r) => r.status !== "pending")])
toast.success(`${data.length} recommendations generated`)
setFilter("pending")
} catch {
toast.error("Network error — please try again")
} finally {
setGenerating(false)
}
}
async function updateStatus(id: string, status: "approved" | "dismissed") {
setActionLoading(id + status)
try {
const res = await fetch(`/api/ai/recommendations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
setRecs((prev) => prev.map((r) => r.id === id ? data : r))
toast.success(status === "approved" ? "Recommendation approved!" : "Dismissed")
} catch {
toast.error("Network error — please try again")
} finally {
setActionLoading(null)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Zap className="h-5 w-5 text-violet-400" />
AI Recommendations
</h2>
<p className="text-sm text-white/40 mt-0.5">
Smart suggestions based on your portfolio data
{pendingCount > 0 && <span className="ml-2 text-violet-400">{pendingCount} pending</span>}
</p>
</div>
<button
onClick={generate}
disabled={generating}
className="flex items-center gap-2 rounded-xl bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-violet-500 disabled:opacity-50 transition"
>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{generating ? "Analyzing…" : "Generate New"}
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-1 rounded-xl border border-white/[0.06] bg-[#16161f] p-1 w-fit">
{(["pending", "approved", "dismissed"] as const).map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`rounded-lg px-4 py-1.5 text-xs font-medium capitalize transition ${
filter === s ? "bg-white/10 text-white" : "text-white/40 hover:text-white"
}`}
>
{s} ({recs.filter((r) => r.status === s).length})
</button>
))}
</div>
{/* List */}
{filtered.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-16 text-center">
<Zap className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">
{filter === "pending" ? "No pending recommendations" : `No ${filter} recommendations`}
</p>
{filter === "pending" && (
<p className="text-xs text-white/20 mt-1">Click "Generate New" to analyze your portfolio</p>
)}
</div>
) : (
<div className="space-y-3">
{filtered.map((rec) => {
const cfg = typeConfig[rec.type] ?? typeConfig.opportunity
const Icon = cfg.icon
return (
<div key={rec.id} className={`rounded-xl border ${cfg.border} bg-[#16161f] p-5`}>
<div className="flex items-start gap-4">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${cfg.bg}`}>
<Icon className={`h-5 w-5 ${cfg.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-white">{rec.title}</p>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ring-1 ring-inset ${priorityBadge[rec.priority] ?? priorityBadge.low}`}>
{rec.priority}
</span>
</div>
<p className="text-sm text-white/50 mt-1.5 leading-relaxed">{rec.description}</p>
{rec.impact && (
<p className={`text-xs font-medium mt-2 ${cfg.color}`}> {rec.impact}</p>
)}
</div>
</div>
{rec.status === "pending" && (
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-white/[0.06]">
<button
onClick={() => updateStatus(rec.id, "approved")}
disabled={!!actionLoading}
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-500 disabled:opacity-50 transition"
>
{actionLoading === rec.id + "approved"
? <Loader2 className="h-3 w-3 animate-spin" />
: <CheckCircle className="h-3 w-3" />}
{rec.action_label ?? "Apply"}
</button>
<button
onClick={() => updateStatus(rec.id, "dismissed")}
disabled={!!actionLoading}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-white/40 hover:text-white disabled:opacity-50 transition"
>
{actionLoading === rec.id + "dismissed"
? <Loader2 className="h-3 w-3 animate-spin" />
: <XCircle className="h-3 w-3" />}
Dismiss
</button>
</div>
)}
{rec.status === "approved" && (
<div className="flex items-center gap-1.5 mt-4 pt-4 border-t border-white/[0.06]">
<CheckCircle className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">Approved</span>
</div>
)}
{rec.status === "dismissed" && (
<div className="flex items-center gap-1.5 mt-4 pt-4 border-t border-white/[0.06]">
<XCircle className="h-3.5 w-3.5 text-white/20" />
<span className="text-xs text-white/30">Dismissed</span>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
@@ -0,0 +1,156 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Zap, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight, Users } from "lucide-react"
import { formatCurrency } from "@/lib/utils"
function monthLabel(year: number, month: number) {
return new Date(year, month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
}
export function BulkGenerateForm({ leases }: { leases: any[] }) {
const router = useRouter()
const now = new Date()
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth())
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ created: number; skipped: number; message: string } | null>(null)
const [error, setError] = useState("")
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1)
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1)
}
const totalRent = leases.reduce((s, l) => s + Number(l.rent_amount), 0)
async function generate() {
setLoading(true)
setError("")
setResult(null)
const res = await fetch("/api/rent/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ year, month }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { setError(data.error ?? "Something went wrong"); return }
setResult(data)
}
return (
<div className="space-y-5">
{/* Month picker */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<p className="text-xs font-medium uppercase tracking-wider text-white/30 mb-4">Select Month</p>
<div className="flex items-center justify-center gap-4">
<button onClick={prevMonth} className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="min-w-[180px] text-center text-lg font-bold text-white">
{monthLabel(year, month)}
</span>
<button onClick={nextMonth} className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition">
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
{/* Summary */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-indigo-400" />
<p className="text-sm font-semibold text-white">Active Tenants ({leases.length})</p>
</div>
<p className="text-sm font-bold text-emerald-400">{formatCurrency(totalRent)}<span className="text-xs text-white/30">/mo total</span></p>
</div>
{leases.length === 0 ? (
<div className="py-10 text-center text-sm text-white/30">
No active leases found. Add leases first.
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{leases.map((l: any) => (
<div key={l.id} className="flex items-center justify-between px-5 py-3">
<div>
<p className="text-sm font-medium text-white">
{l.tenant?.first_name} {l.tenant?.last_name}
</p>
<p className="text-xs text-white/35">
{l.property?.name}{l.unit ? ` · Unit ${l.unit.unit_number}` : ""}
</p>
</div>
<p className="text-sm font-semibold text-white">{formatCurrency(l.rent_amount)}</p>
</div>
))}
</div>
)}
</div>
{/* Result */}
{result && (
<div className={`rounded-xl border px-4 py-3 flex items-start gap-3 ${
result.created > 0
? "border-emerald-500/20 bg-emerald-500/5"
: "border-amber-500/20 bg-amber-500/5"
}`}>
<CheckCircle2 className={`h-5 w-5 shrink-0 mt-0.5 ${result.created > 0 ? "text-emerald-400" : "text-amber-400"}`} />
<div>
<p className={`text-sm font-semibold ${result.created > 0 ? "text-emerald-400" : "text-amber-400"}`}>
{result.message}
</p>
{result.skipped > 0 && (
<p className="text-xs text-white/40 mt-0.5">{result.skipped} already existed and were skipped.</p>
)}
</div>
</div>
)}
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => router.push("/rent")}
className="rounded-xl border border-white/10 px-5 py-2.5 text-sm text-white/50 hover:text-white transition"
>
Cancel
</button>
<button
onClick={generate}
disabled={loading || leases.length === 0}
className="flex-1 flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition hover:shadow-lg hover:shadow-indigo-500/25"
>
{loading ? (
<span className="animate-pulse">Generating</span>
) : (
<>
<Zap className="h-4 w-4" />
Generate {leases.length} Payment{leases.length !== 1 ? "s" : ""} for {monthLabel(year, month)}
</>
)}
</button>
</div>
{(result?.created ?? 0) > 0 && (
<button
onClick={() => router.push("/rent")}
className="w-full text-center text-sm text-indigo-400 hover:text-indigo-300 transition"
>
View Rent Tracker
</button>
)}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases as leasesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { BulkGenerateForm } from "./bulk-generate-form"
export const metadata = { title: "Generate Rent" }
export default async function GenerateRentPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const leases = await db.query.leases.findMany({
where: and(eq(leasesTable.user_id, user.id), eq(leasesTable.status, "active")),
columns: { id: true, rent_amount: true },
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h2 className="text-lg font-bold text-white">Generate Monthly Rent</h2>
<p className="text-sm text-white/40 mt-0.5">
Create pending rent payments for all active tenants in one click
</p>
</div>
<BulkGenerateForm leases={leases ?? []} />
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button"
import { RentCsvImport } from "./rent-csv-import"
export const metadata = { title: "Import Rent Payments" }
export default async function ImportRentPage() {
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")),
columns: { id: true, first_name: true, last_name: true },
with: {
unit: { columns: { unit_number: true } },
property: { columns: { name: true } },
},
})
const properties_ = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(eq(properties.user_id, user.id))
return (
<div className="mx-auto max-w-3xl space-y-6">
<BackButton href="/rent" />
<div>
<h2 className="text-lg font-semibold text-white">Import Rent Payments</h2>
<p className="text-sm text-white/40">Upload a CSV file to add multiple rent payments at once</p>
</div>
<RentCsvImport tenants={tenants ?? []} properties={properties_ ?? []} />
</div>
)
}
@@ -0,0 +1,194 @@
"use client"
import { useState, useRef } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Upload, FileText, CheckCircle, XCircle, Download } from "lucide-react"
interface Tenant { id: string; first_name: string; last_name: string; unit?: any; property?: any }
interface Property { id: string; name: string }
interface ParsedRow {
tenant_id: string
property_id: string
amount: number
due_date: string
status: string
payment_method?: string
notes?: string
_tenantName?: string
_error?: string
}
const SAMPLE_CSV = `tenant_id,property_id,amount,due_date,status,payment_method,notes
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-05-01,pending,,
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-04-01,paid,bank_transfer,April rent`
export function RentCsvImport({ tenants, properties }: { tenants: Tenant[]; properties: Property[] }) {
const router = useRouter()
const fileRef = useRef<HTMLInputElement>(null)
const [rows, setRows] = useState<ParsedRow[]>([])
const [importing, setImporting] = useState(false)
const [done, setDone] = useState(false)
function downloadSample() {
const blob = new Blob([SAMPLE_CSV], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url; a.download = "rent-import-sample.csv"; a.click()
URL.revokeObjectURL(url)
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const { parse } = await import("papaparse")
parse(file, {
header: true,
skipEmptyLines: true,
complete: (result) => {
const parsed: ParsedRow[] = (result.data as any[]).map((row) => {
const tenant = tenants.find((t) => t.id === row.tenant_id?.trim())
const amount = parseFloat(row.amount)
const errors: string[] = []
if (!tenant) errors.push("tenant not found")
if (isNaN(amount) || amount <= 0) errors.push("invalid amount")
if (!row.due_date?.trim()) errors.push("missing due_date")
if (!row.property_id?.trim()) errors.push("missing property_id")
return {
tenant_id: row.tenant_id?.trim(),
property_id: row.property_id?.trim(),
amount,
due_date: row.due_date?.trim(),
status: row.status?.trim() || "pending",
payment_method: row.payment_method?.trim() || undefined,
notes: row.notes?.trim() || undefined,
_tenantName: tenant ? `${tenant.first_name} ${tenant.last_name}` : row.tenant_id,
_error: errors.length ? errors.join(", ") : undefined,
}
})
setRows(parsed)
setDone(false)
},
})
}
async function handleImport() {
const valid = rows.filter((r) => !r._error)
if (!valid.length) { toast.error("No valid rows to import"); return }
setImporting(true)
let success = 0, failed = 0
for (const row of valid) {
const res = await fetch("/api/rent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tenant_id: row.tenant_id,
property_id: row.property_id,
amount: row.amount,
due_date: row.due_date,
status: row.status,
payment_method: row.payment_method,
notes: row.notes,
}),
})
res.ok ? success++ : failed++
}
setImporting(false)
setDone(true)
toast.success(`Imported ${success} payment${success !== 1 ? "s" : ""}${failed ? `, ${failed} failed` : ""}`)
if (success > 0) router.refresh()
}
const validCount = rows.filter((r) => !r._error).length
const invalidCount = rows.filter((r) => !!r._error).length
return (
<div className="space-y-5">
{/* Instructions */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">How it works</h3>
<ol className="space-y-1.5 text-sm text-white/50 list-decimal list-inside">
<li>Download the sample CSV template</li>
<li>Fill in tenant_id and property_id from your dashboard</li>
<li>Upload the filled CSV file</li>
<li>Review rows and click Import</li>
</ol>
<button
onClick={downloadSample}
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-white/60 hover:border-white/20 hover:text-white transition"
>
<Download className="h-3.5 w-3.5" /> Download Sample CSV
</button>
</div>
{/* Upload */}
<div
onClick={() => fileRef.current?.click()}
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] p-10 hover:border-indigo-500/40 hover:bg-indigo-500/5 transition"
>
<Upload className="h-8 w-8 text-white/20" />
<p className="text-sm text-white/40">Click to upload CSV file</p>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
</div>
{/* Preview */}
{rows.length > 0 && (
<div className="rounded-xl 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-3">
<FileText className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">{rows.length} rows parsed</h3>
</div>
<div className="flex items-center gap-3 text-xs">
{validCount > 0 && <span className="text-emerald-400">{validCount} valid</span>}
{invalidCount > 0 && <span className="text-red-400">{invalidCount} invalid</span>}
</div>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
{rows.map((row, i) => (
<div key={i} className="flex items-center justify-between px-5 py-3 gap-4">
<div className="flex items-center gap-3 min-w-0">
{row._error
? <XCircle className="h-4 w-4 shrink-0 text-red-400" />
: <CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
}
<div className="min-w-0">
<p className="text-sm text-white truncate">{row._tenantName}</p>
<p className="text-xs text-white/40">{row.due_date} · ${row.amount}</p>
{row._error && <p className="text-xs text-red-400">{row._error}</p>}
</div>
</div>
<span className="shrink-0 rounded-md border border-white/10 px-2 py-0.5 text-xs text-white/50 capitalize">
{row.status}
</span>
</div>
))}
</div>
{!done && (
<div className="border-t border-white/[0.06] p-4">
<button
onClick={handleImport}
disabled={importing || validCount === 0}
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
>
{importing ? `Importing…` : `Import ${validCount} Payment${validCount !== 1 ? "s" : ""}`}
</button>
</div>
)}
{done && (
<div className="border-t border-white/[0.06] p-4 text-center text-sm text-emerald-400">
Import complete!
</div>
)}
</div>
)}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function RentLoading() {
return <TableSkeleton rows={8} cols={5} />
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants as tenantsTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { RentPaymentForm } from "@/components/forms/rent-payment-form"
import { BackButton } from "@/components/ui/back-button"
export const metadata = { title: "Record Payment" }
export default async function NewRentPaymentPage() {
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")),
columns: { id: true, first_name: true, last_name: true, property_id: true, unit_id: true },
with: {
unit: { columns: { unit_number: true, rent_amount: true } },
property: { columns: { id: true, name: true } },
},
orderBy: asc(tenantsTable.first_name),
})
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href="/rent" />
<div>
<h2 className="text-lg font-semibold text-white">Record Payment</h2>
<p className="text-sm text-white/40">Log a rent payment for a tenant</p>
</div>
<RentPaymentForm tenants={tenants ?? []} />
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { redirect } from "next/navigation"
import { eq, desc } from "drizzle-orm"
import { db } from "@/lib/db"
import { rent_payments } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import Link from "next/link"
import { CreditCard, Plus, Upload } from "lucide-react"
import { EmptyState } from "@/components/shared/empty-state"
import { RentTable } from "./rent-table"
import { CsvExportButton } from "@/components/forms/csv-export-button"
export const metadata = { title: "Rent Tracker" }
export default async function RentPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const payments = await db.query.rent_payments.findMany({
where: eq(rent_payments.user_id, user.id),
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
orderBy: desc(rent_payments.due_date),
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Rent Tracker</h2>
<p className="text-sm text-white/40">{payments?.length ?? 0} total records</p>
</div>
<div className="flex items-center gap-2">
<Link href="/rent/import" className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition">
<Upload className="h-4 w-4" /> Import CSV
</Link>
<CsvExportButton endpoint="/api/export/rent" filename="rent-payments.csv" label="Export CSV" />
<Link href="/rent/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" /> Record Payment
</Link>
</div>
</div>
{!payments?.length ? (
<EmptyState icon={CreditCard} title="No payments yet" description="Record rent payments to track collections." action={{ label: "Record payment", href: "/rent/new" }} />
) : (
<RentTable payments={payments} />
)}
</div>
)
}
+285
View File
@@ -0,0 +1,285 @@
"use client"
import { useState, useMemo } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown, Check, Loader2 } from "lucide-react"
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
import { RentActions } from "@/components/forms/rent-actions"
import { RentReceiptButton } from "@/components/forms/rent-receipt-button"
import { LateNoticeButton } from "@/components/forms/late-notice-button"
import { formatCurrency, formatDate } from "@/lib/utils"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
function monthLabel(d: Date) {
return d.toLocaleDateString("en-US", { month: "long", year: "numeric" })
}
const STATUSES = ["pending", "paid", "overdue"] as const
type Status = typeof STATUSES[number]
// Inline status picker — click badge to cycle through statuses
function InlineStatusEdit({ payment }: { payment: any }) {
const [status, setStatus] = useState<Status>(payment.status)
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
async function updateStatus(next: Status) {
if (next === status) { setOpen(false); return }
setSaving(true)
setOpen(false)
const res = await fetch(`/api/rent/${payment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: next }),
})
setSaving(false)
if (res.ok) {
setStatus(next)
toast.success("Status updated")
} else {
toast.error("Failed to update status")
}
}
if (saving) return <Loader2 className="h-3.5 w-3.5 animate-spin text-white/30" />
return (
<div className="relative">
<button onClick={() => setOpen((v) => !v)} title="Click to change status">
<RentStatusBadge status={status} />
</button>
{open && (
<div className="absolute left-0 z-50 mt-1.5 w-32 overflow-hidden rounded-xl border border-white/[0.08] bg-[#1d1d2a] shadow-2xl shadow-black/60 py-1">
{STATUSES.map((s) => (
<button
key={s}
onClick={() => updateStatus(s)}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-xs capitalize transition-colors",
s === status ? "text-indigo-300 bg-indigo-500/10" : "text-white/60 hover:bg-white/[0.05] hover:text-white"
)}
>
{s}
{s === status && <Check className="h-3 w-3 text-indigo-400" />}
</button>
))}
</div>
)}
</div>
)
}
type SortKey = "tenant" | "due_date" | "amount" | "status"
type SortDir = "asc" | "desc"
function SortTh({ label, col, active, dir, onClick }: { label: string; col: SortKey; active: SortKey; dir: SortDir; onClick: () => void }) {
return (
<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={onClick}
>
<span className="flex items-center gap-1.5">
{label}
{active !== col
? <ArrowUpDown className="h-3 w-3 opacity-30" />
: dir === "asc" ? <ArrowUp className="h-3 w-3 text-indigo-400" /> : <ArrowDown className="h-3 w-3 text-indigo-400" />
}
</span>
</th>
)
}
export function RentTable({ payments }: { payments: any[] }) {
const now = new Date()
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth())
const [sortKey, setSortKey] = useState<SortKey>("due_date")
const [sortDir, setSortDir] = useState<SortDir>("asc")
function prevMonth() {
if (month === 0) { setMonth(11); setYear((y) => y - 1) }
else setMonth((m) => m - 1)
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear((y) => y + 1) }
else setMonth((m) => m + 1)
}
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
else { setSortKey(key); setSortDir("asc") }
}
const filtered = useMemo(() =>
payments
.filter((p) => {
const d = new Date(p.due_date)
return d.getFullYear() === year && d.getMonth() === month
})
.sort((a, b) => {
let av: string | number = ""
let bv: string | number = ""
if (sortKey === "tenant") { av = `${a.tenant?.first_name} ${a.tenant?.last_name}`; bv = `${b.tenant?.first_name} ${b.tenant?.last_name}` }
if (sortKey === "due_date") { av = a.due_date ?? ""; bv = b.due_date ?? "" }
if (sortKey === "amount") { av = Number(a.amount); bv = Number(b.amount) }
if (sortKey === "status") { av = a.status ?? ""; bv = b.status ?? "" }
if (av < bv) return sortDir === "asc" ? -1 : 1
if (av > bv) return sortDir === "asc" ? 1 : -1
return 0
}),
[payments, year, month, sortKey, sortDir]
)
const stats = useMemo(() => ({
collected: filtered.filter((p) => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0),
pending: filtered.filter((p) => p.status === "pending").reduce((s, p) => s + Number(p.amount), 0),
overdue: filtered.filter((p) => p.status === "overdue").reduce((s, p) => s + Number(p.amount), 0),
}), [filtered])
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth()
return (
<div className="space-y-5">
{/* Month navigator */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={prevMonth}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="min-w-[160px] text-center text-sm font-semibold text-white px-1">
{monthLabel(new Date(year, month, 1))}
</span>
<button
onClick={nextMonth}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
>
<ChevronRight className="h-4 w-4" />
</button>
{!isCurrentMonth && (
<button
onClick={() => { setYear(now.getFullYear()); setMonth(now.getMonth()) }}
className="ml-2 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
>
Today
</button>
)}
</div>
<p className="text-xs text-white/30">{filtered.length} record{filtered.length !== 1 ? "s" : ""}</p>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Collected", value: stats.collected, color: "text-emerald-400", bar: "bg-emerald-500" },
{ label: "Pending", value: stats.pending, color: "text-amber-400", bar: "bg-amber-500" },
{ label: "Overdue", value: stats.overdue, color: "text-red-400", bar: "bg-red-500" },
].map((s) => (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<p className="text-xs text-white/35 tracking-wide">{s.label}</p>
<p className={`mt-1.5 text-xl font-bold tabular-nums ${s.color}`}>{formatCurrency(s.value)}</p>
<div className="mt-2 h-1 w-full rounded-full bg-white/[0.06]">
<div
className={`h-1 rounded-full ${s.bar} transition-all`}
style={{ width: s.value > 0 ? `${Math.round((s.value / (stats.collected + stats.pending + stats.overdue || 1)) * 100)}%` : "0%" }}
/>
</div>
</div>
))}
</div>
{/* Table */}
{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 payments in {monthLabel(new Date(year, month, 1))}</p>
</div>
) : (
<>
<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]">
<SortTh label="Tenant" col="tenant" active={sortKey} dir={sortDir} onClick={() => toggleSort("tenant")} />
<th className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">Property / Unit</th>
<SortTh label="Due Date" col="due_date" active={sortKey} dir={sortDir} onClick={() => toggleSort("due_date")} />
<SortTh label="Amount" col="amount" active={sortKey} dir={sortDir} onClick={() => toggleSort("amount")} />
<SortTh label="Status" col="status" active={sortKey} dir={sortDir} onClick={() => toggleSort("status")} />
<th className="px-5 py-3.5" />
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{filtered.map((p) => (
<tr key={p.id} className="group hover:bg-white/[0.02] transition">
<td className="px-5 py-3.5">
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-medium text-white hover:text-indigo-300 transition">
{p.tenant?.first_name} {p.tenant?.last_name}
</Link>
</td>
<td className="px-5 py-3.5">
<p className="text-sm text-white/70">{p.property?.name}</p>
<p className="text-xs text-white/35">Unit {p.unit?.unit_number ?? "—"}</p>
</td>
<td className="px-5 py-3.5">
<p className="text-sm text-white/60">{formatDate(p.due_date)}</p>
{p.paid_date && <p className="text-xs text-white/30">Paid {formatDate(p.paid_date)}</p>}
</td>
<td className="px-5 py-3.5">
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
</td>
<td className="px-5 py-3.5">
<InlineStatusEdit payment={p} />
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<RentReceiptButton
payment={p}
tenant={p.tenant}
property={p.property}
unit={p.unit}
/>
<LateNoticeButton
payment={p}
tenant={p.tenant}
property={p.property}
unit={p.unit}
/>
<RentActions payment={p} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{filtered.map((p) => (
<div key={p.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-semibold text-white hover:text-indigo-300">
{p.tenant?.first_name} {p.tenant?.last_name}
</Link>
<p className="text-xs text-white/40 mt-0.5 truncate">{p.property?.name} · Unit {p.unit?.unit_number ?? "—"}</p>
</div>
<InlineStatusEdit payment={p} />
</div>
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-white/35">Due {formatDate(p.due_date)}</p>
<div className="flex items-center gap-3">
<p className="text-base font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
<RentActions payment={p} />
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
+2
View File
@@ -0,0 +1,2 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function Loading() { return <TableSkeleton rows={6} cols={4} /> }
+107
View File
@@ -0,0 +1,107 @@
import { redirect } from "next/navigation"
import { and, eq, gte } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties as propertiesTable, rent_payments, expenses as expensesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { formatCurrency } from "@/lib/utils"
import { TrendingUp, TrendingDown, Building2, DollarSign, Receipt, BarChart3 } from "lucide-react"
import { ReportsClient } from "./reports-client"
export const metadata = { title: "Reports" }
export default async function ReportsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
// Last 6 months range
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
sixMonthsAgo.setDate(1)
const rangeStart = sixMonthsAgo.toISOString().slice(0, 10)
const [properties, payments, expenses] = await Promise.all([
db
.select({ id: propertiesTable.id, name: propertiesTable.name })
.from(propertiesTable)
.where(eq(propertiesTable.user_id, user.id)),
db
.select({
amount: rent_payments.amount,
status: rent_payments.status,
due_date: rent_payments.due_date,
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, rangeStart))),
db
.select({
amount: expensesTable.amount,
expense_date: expensesTable.expense_date,
property_id: expensesTable.property_id,
category: expensesTable.category,
})
.from(expensesTable)
.where(and(eq(expensesTable.user_id, user.id), gte(expensesTable.expense_date, rangeStart))),
])
// Build monthly buckets for last 6 months
const months: { key: string; label: string }[] = []
for (let i = 5; i >= 0; i--) {
const d = new Date()
d.setMonth(d.getMonth() - i)
d.setDate(1)
const key = d.toISOString().slice(0, 7)
const label = d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
months.push({ key, label })
}
// Monthly revenue & expenses
const monthlyData = months.map(({ key, label }) => {
const revenue = (payments ?? []).filter(p => p.status === "paid" && p.due_date?.startsWith(key)).reduce((s, p) => s + Number(p.amount), 0)
const expense = (expenses ?? []).filter(e => e.expense_date?.startsWith(key)).reduce((s, e) => s + Number(e.amount), 0)
return { key, label, revenue, expense, net: revenue - expense }
})
// Per-property P&L
const propertyPnL = (properties ?? []).map((p: any) => {
const revenue = (payments ?? []).filter(pm => pm.status === "paid" && pm.property_id === p.id).reduce((s, pm) => s + Number(pm.amount), 0)
const expense = (expenses ?? []).filter(e => e.property_id === p.id).reduce((s, e) => s + Number(e.amount), 0)
const net = revenue - expense
const margin = revenue > 0 ? Math.round((net / revenue) * 100) : 0
return { ...p, revenue, expense, net, margin }
}).sort((a, b) => b.net - a.net)
// Summary totals
const totalRevenue = (payments ?? []).filter(p => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0)
const totalExpenses = (expenses ?? []).reduce((s, e) => s + Number(e.amount), 0)
const totalNet = totalRevenue - totalExpenses
const avgMargin = totalRevenue > 0 ? Math.round((totalNet / totalRevenue) * 100) : 0
return (
<div className="space-y-6">
{/* Summary KPI cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Total Revenue", value: formatCurrency(totalRevenue), icon: DollarSign, color: "text-emerald-400", bg: "bg-emerald-500/10" },
{ label: "Total Expenses", value: formatCurrency(totalExpenses), icon: Receipt, color: "text-rose-400", bg: "bg-rose-500/10" },
{ label: "Net Income", value: formatCurrency(totalNet), icon: totalNet >= 0 ? TrendingUp : TrendingDown, color: totalNet >= 0 ? "text-emerald-400" : "text-red-400", bg: totalNet >= 0 ? "bg-emerald-500/10" : "bg-red-500/10" },
{ label: "Profit Margin", value: `${avgMargin}%`, icon: BarChart3, color: "text-indigo-400", bg: "bg-indigo-500/10" },
].map((s) => (
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`flex h-7 w-7 items-center justify-center rounded-lg ${s.bg}`}>
<s.icon className={`h-3.5 w-3.5 ${s.color}`} />
</div>
<p className="text-xs text-white/35">{s.label}</p>
</div>
<p className={`text-xl font-bold tabular-nums ${s.color}`}>{s.value}</p>
<p className="text-[10px] text-white/25 mt-0.5">Last 6 months</p>
</div>
))}
</div>
{/* Client component for interactive charts */}
<ReportsClient monthlyData={monthlyData} propertyPnL={propertyPnL} />
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
"use client"
import { useState } from "react"
import { formatCurrency } from "@/lib/utils"
import { TrendingUp, TrendingDown, Building2 } from "lucide-react"
interface MonthData { key: string; label: string; revenue: number; expense: number; net: number }
interface PropertyPnL { id: string; name: string; revenue: number; expense: number; net: number; margin: number }
export function ReportsClient({ monthlyData, propertyPnL }: { monthlyData: MonthData[]; propertyPnL: PropertyPnL[] }) {
const [view, setView] = useState<"revenue" | "expense" | "net">("revenue")
const maxVal = Math.max(...monthlyData.map(m =>
view === "revenue" ? m.revenue : view === "expense" ? m.expense : Math.abs(m.net)
), 1)
const barColor = view === "revenue" ? "bg-indigo-500" : view === "expense" ? "bg-rose-500" : "bg-emerald-500"
const viewLabel = view === "revenue" ? "Revenue" : view === "expense" ? "Expenses" : "Net Income"
return (
<div className="space-y-6">
{/* Monthly chart */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Monthly {viewLabel}</p>
<div className="flex items-center gap-1 rounded-lg border border-white/[0.06] p-1">
{(["revenue", "expense", "net"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`px-3 py-1 rounded-md text-xs font-medium transition capitalize ${
view === v ? "bg-indigo-600 text-white" : "text-white/40 hover:text-white"
}`}
>
{v === "net" ? "Net" : v === "revenue" ? "Revenue" : "Expenses"}
</button>
))}
</div>
</div>
<div className="px-5 py-5">
<div className="flex items-end gap-2 h-40">
{monthlyData.map((m) => {
const val = view === "revenue" ? m.revenue : view === "expense" ? m.expense : m.net
const height = maxVal > 0 ? Math.max((Math.abs(val) / maxVal) * 100, val !== 0 ? 4 : 0) : 0
const isNegative = val < 0
return (
<div key={m.key} className="flex-1 flex flex-col items-center gap-1 group">
<div className="relative w-full flex items-end justify-center" style={{ height: "128px" }}>
<div
className={`w-full rounded-t-md transition-all duration-300 ${isNegative ? "bg-red-500" : barColor} opacity-70 group-hover:opacity-100`}
style={{ height: `${height}%` }}
title={formatCurrency(val)}
/>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 hidden group-hover:block whitespace-nowrap rounded-lg bg-[#1d1d2a] border border-white/10 px-2 py-1 text-xs text-white shadow-xl z-10">
{formatCurrency(val)}
</div>
</div>
<span className="text-[10px] text-white/30">{m.label}</span>
</div>
)
})}
</div>
</div>
{/* Chart legend */}
<div className="grid grid-cols-3 divide-x divide-white/[0.04] border-t border-white/[0.06]">
{[
{ label: "Total Revenue", value: monthlyData.reduce((s, m) => s + m.revenue, 0), color: "text-emerald-400" },
{ label: "Total Expenses", value: monthlyData.reduce((s, m) => s + m.expense, 0), color: "text-rose-400" },
{ label: "Net Income", value: monthlyData.reduce((s, m) => s + m.net, 0), color: "text-indigo-400" },
].map((s) => (
<div key={s.label} className="px-4 py-3 text-center">
<p className={`text-base font-bold tabular-nums ${s.color}`}>{formatCurrency(s.value)}</p>
<p className="text-[10px] text-white/30 mt-0.5">{s.label}</p>
</div>
))}
</div>
</div>
{/* Per-property P&L */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-4 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Property P&amp;L</p>
<p className="text-xs text-white/35 mt-0.5">Income vs expenses per property (last 6 months)</p>
</div>
{propertyPnL.length === 0 ? (
<div className="py-12 text-center text-sm text-white/30">No property data available</div>
) : (
<div className="divide-y divide-white/[0.04]">
{propertyPnL.map((p) => (
<div key={p.id} className="px-5 py-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/10">
<Building2 className="h-4 w-4 text-indigo-400" />
</div>
<div>
<p className="text-sm font-semibold text-white">{p.name}</p>
<p className="text-xs text-white/35">{p.margin}% profit margin</p>
</div>
</div>
<div className="text-right">
<p className={`text-base font-bold tabular-nums ${p.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{formatCurrency(p.net)}
</p>
<div className={`flex items-center gap-1 justify-end text-xs ${p.net >= 0 ? "text-emerald-400/60" : "text-red-400/60"}`}>
{p.net >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
Net income
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-white/[0.03] border border-white/[0.04] px-3 py-2.5">
<p className="text-[10px] text-white/30 mb-1">Revenue</p>
<p className="text-sm font-bold text-emerald-400 tabular-nums">{formatCurrency(p.revenue)}</p>
<div className="mt-1.5 h-1 w-full rounded-full bg-white/[0.06]">
<div className="h-1 rounded-full bg-emerald-500" style={{ width: p.revenue > 0 ? "100%" : "0%" }} />
</div>
</div>
<div className="rounded-xl bg-white/[0.03] border border-white/[0.04] px-3 py-2.5">
<p className="text-[10px] text-white/30 mb-1">Expenses</p>
<p className="text-sm font-bold text-rose-400 tabular-nums">{formatCurrency(p.expense)}</p>
<div className="mt-1.5 h-1 w-full rounded-full bg-white/[0.06]">
<div className="h-1 rounded-full bg-rose-500" style={{ width: p.revenue > 0 ? `${Math.min((p.expense / p.revenue) * 100, 100)}%` : "0%" }} />
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { redirect } from "next/navigation"
import { and, eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { CheckoutButton } from "@/components/forms/checkout-button"
import { PortalButton } from "@/components/forms/portal-button"
import { getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
import { Check } from "lucide-react"
import type { Plan } from "@/types"
export const metadata = { title: "Billing" }
const PLANS = [
{
key: "starter" as Plan,
name: "Starter",
price: "$0",
interval: "forever",
description: "For landlords just getting started",
features: ["1 property", "3 tenants", "Maintenance tracking", "Rent tracker"],
cta: "Current plan",
highlight: false,
},
{
key: "pro" as Plan,
name: "Pro",
price: "$29",
interval: "/month",
description: "For active landlords growing their portfolio",
features: ["10 properties", "Unlimited tenants", "50 AI calls/month", "5GB storage", "Email notifications", "Stripe rent collection"],
cta: "Upgrade to Pro",
highlight: true,
},
{
key: "landlord" as Plan,
name: "Landlord",
price: "$59",
interval: "/month",
description: "For property managers at scale",
features: ["Unlimited properties", "Unlimited tenants", "200 AI calls/month", "25GB storage", "Team access", "White-label"],
cta: "Upgrade to Landlord",
highlight: false,
},
{
key: "lifetime" as Plan,
name: "Lifetime",
price: "$199",
interval: "one-time",
description: "Everything in Landlord, forever",
features: ["Everything in Landlord", "Lifetime updates", "Priority support", "Flippa-ready asset"],
cta: "Get Lifetime Deal",
highlight: false,
},
]
export default async function BillingPage({
searchParams,
}: {
searchParams: Promise<{ success?: string; canceled?: string }>
}) {
const user = await getSessionUser()
if (!user) redirect("/login")
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: {
plan: true,
subscription_status: true,
plan_expires_at: true,
stripe_customer_id: true,
stripe_subscription_id: true,
},
})
const params = await searchParams
const currentPlan = (profile?.plan ?? "starter") as Plan
const hasStripeAccount = !!profile?.stripe_customer_id
const limits = PLAN_LIMITS[currentPlan]
const [[{ count: propertiesUsed }], [{ count: tenantsUsed }]] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, user.id)),
db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
])
return (
<div className="max-w-5xl space-y-8">
<div>
<h2 className="text-lg font-semibold text-white">Billing</h2>
<p className="text-sm text-white/40">Manage your subscription and plan</p>
</div>
{params.success && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-5 py-4 text-sm text-emerald-400">
Payment successful! Your plan has been upgraded.
</div>
)}
{params.canceled && (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-5 py-4 text-sm text-amber-400">
Checkout canceled no charge was made.
</div>
)}
{/* Current plan */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-white/40">Current Plan</p>
<p className="mt-1 text-xl font-bold text-white">{getPlanLabel(currentPlan)}</p>
{profile?.subscription_status && (
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
)}
</div>
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
<PortalButton />
)}
</div>
</div>
{/* Plans grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{PLANS.map((plan) => {
const isCurrent = currentPlan === plan.key
const isDowngrade = (
currentPlan === "landlord" && (plan.key === "pro" || plan.key === "starter") ||
currentPlan === "lifetime"
)
return (
<div
key={plan.key}
className={`relative flex flex-col rounded-xl border p-5 ${
plan.highlight
? "border-indigo-500/40 bg-indigo-600/5"
: "border-white/[0.06] bg-[#16161f]"
}`}
>
{plan.highlight && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="rounded-full bg-indigo-600 px-3 py-0.5 text-xs font-semibold text-white">
Most Popular
</span>
</div>
)}
<div>
<p className="text-sm font-semibold text-white">{plan.name}</p>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-2xl font-bold text-white">{plan.price}</span>
<span className="text-xs text-white/40">{plan.interval}</span>
</div>
<p className="mt-1 text-xs text-white/40">{plan.description}</p>
</div>
<ul className="mt-4 flex-1 space-y-2">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-white/60">
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
{f}
</li>
))}
</ul>
<div className="mt-5">
{isCurrent ? (
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/40">
Current Plan
</div>
) : plan.key === "starter" || isDowngrade ? (
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/30">
{plan.key === "starter" ? "Free" : "Downgrade via portal"}
</div>
) : (
<CheckoutButton plan={plan.key} label={plan.cta} highlight={plan.highlight} />
)}
</div>
</div>
)
})}
</div>
{/* Usage overview */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-5">
<h3 className="text-sm font-semibold text-white">Usage</h3>
{[
{
label: "Properties",
used: propertiesUsed ?? 0,
max: limits.maxProperties,
},
{
label: "Active Tenants",
used: tenantsUsed ?? 0,
max: limits.maxTenants,
},
].map(({ label, used, max }) => {
const unlimited = max === Infinity
const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100))
const nearLimit = !unlimited && pct >= 80
return (
<div key={label}>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="text-white/60">{label}</span>
<span className={nearLimit ? "text-amber-400 font-medium" : "text-white/40"}>
{used} / {unlimited ? "Unlimited" : max}
</span>
</div>
{!unlimited && (
<div className="h-1.5 w-full rounded-full bg-white/[0.06]">
<div
className={`h-full rounded-full transition-all ${
pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500"
}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
)
})}
<div className="grid grid-cols-2 gap-4 pt-1 sm:grid-cols-2 border-t border-white/[0.06]">
<div>
<p className="text-xs text-white/40">AI Calls / mo</p>
<p className="mt-1 text-sm font-semibold text-white">{limits.maxAiCalls === 0 ? "Not included" : limits.maxAiCalls}</p>
</div>
<div>
<p className="text-xs text-white/40">Storage</p>
<p className="mt-1 text-sm font-semibold text-white">{limits.maxStorageMB >= 1024 ? `${limits.maxStorageMB / 1024}GB` : `${limits.maxStorageMB}MB`}</p>
</div>
</div>
</div>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { seedDemoData, clearDemoData, setTestPlan } from "@/app/actions/seed-demo"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { redirect, notFound } from "next/navigation"
import {
Building2, Users, CreditCard, Wrench,
FileText, Receipt, Sparkles, Trash2, CheckCircle2, Zap, Crown, Infinity
} from "lucide-react"
const DEMO_CONTENTS = [
{ icon: Building2, color: "text-indigo-400 bg-indigo-500/10", label: "3 Properties", detail: "Maple Court, Riverdale Flats, Crestwood Villa" },
{ icon: Building2, color: "text-violet-400 bg-violet-500/10", label: "7 Units", detail: "Mix of 1, 2 & 3-bedroom units across all properties" },
{ icon: Users, color: "text-blue-400 bg-blue-500/10", label: "6 Tenants", detail: "Sarah, Marcus, Priya, David, Emily & James — with contacts" },
{ icon: FileText, color: "text-emerald-400 bg-emerald-500/10", label: "6 Leases", detail: "Active leases, 2 expiring soon to trigger alerts" },
{ icon: CreditCard, color: "text-teal-400 bg-teal-500/10", label: "30+ Payments", detail: "6 months of history — paid, pending & overdue statuses" },
{ icon: Wrench, color: "text-amber-400 bg-amber-500/10", label: "6 Maintenance Requests", detail: "Open, in-progress & resolved — with priorities" },
{ icon: Receipt, color: "text-rose-400 bg-rose-500/10", label: "8 Expenses", detail: "Repairs, insurance, utilities, taxes — with vendors" },
]
const PLANS = [
{ value: "starter", label: "Starter", icon: Zap, color: "text-white/60 border-white/10 hover:border-white/20", desc: "Free — no AI" },
{ value: "pro", label: "Pro", icon: Sparkles, color: "text-indigo-300 border-indigo-500/30 hover:border-indigo-500/60 bg-indigo-500/5", desc: "50 AI calls/mo" },
{ value: "landlord", label: "Landlord", icon: Crown, color: "text-violet-300 border-violet-500/30 hover:border-violet-500/60 bg-violet-500/5", desc: "200 AI calls/mo" },
{ value: "lifetime", label: "Lifetime", icon: Infinity, color: "text-amber-300 border-amber-500/30 hover:border-amber-500/60 bg-amber-500/5", desc: "Unlimited — all features" },
] as const
export default async function DemoDataPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
if (process.env.NODE_ENV === "production") notFound()
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true },
})
const currentPlan = profile?.plan ?? "starter"
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Plan switcher — most prominent */}
<div className="rounded-2xl border border-indigo-500/20 bg-gradient-to-br from-indigo-600/10 via-[#16161f] to-violet-600/5 overflow-hidden">
<div className="px-5 py-4 border-b border-white/[0.06]">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-indigo-400" />
<p className="text-sm font-semibold text-white">Test Plan</p>
</div>
<p className="text-xs text-white/40 mt-0.5">
Switch plans instantly to test different features current: <span className="text-indigo-300 font-semibold capitalize">{currentPlan}</span>
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 p-4">
{PLANS.map((plan) => {
const isActive = currentPlan === plan.value
return (
<form key={plan.value} action={setTestPlan.bind(null, plan.value)}>
<button
type="submit"
className={`w-full flex flex-col items-center gap-1.5 rounded-xl border px-3 py-3 transition-all ${plan.color} ${isActive ? "ring-2 ring-indigo-500/50 ring-offset-1 ring-offset-[#16161f]" : ""}`}
>
<plan.icon className="h-4 w-4" />
<span className="text-xs font-semibold">{plan.label}</span>
<span className="text-[10px] opacity-60">{plan.desc}</span>
{isActive && <span className="text-[9px] font-bold text-emerald-400 uppercase tracking-wider">Active</span>}
</button>
</form>
)
})}
</div>
<div className="px-5 pb-4">
<p className="text-[10px] text-white/25 text-center">
For testing only does not affect real billing
</p>
</div>
</div>
{/* Demo data section */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-4 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Demo Data</p>
<p className="text-xs text-white/40 mt-0.5">Populate your account with realistic sample data</p>
</div>
<div className="divide-y divide-white/[0.04]">
{DEMO_CONTENTS.map((item) => (
<div key={item.label} className="flex items-center gap-4 px-5 py-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${item.color}`}>
<item.icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white">{item.label}</p>
<p className="text-xs text-white/40 truncate">{item.detail}</p>
</div>
<CheckCircle2 className="h-4 w-4 text-emerald-400/40 shrink-0 ml-auto" />
</div>
))}
</div>
</div>
{/* Warning */}
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3">
<p className="text-xs text-amber-400/80 leading-relaxed">
<span className="font-semibold text-amber-400">Note:</span> "Load demo data" adds records to your account. Use "Clear all data" to wipe everything when done testing.
</p>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3">
<form action={seedDemoData} className="flex-1">
<button
type="submit"
className="w-full flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
>
<Sparkles className="h-4 w-4" />
Load demo data
</button>
</form>
<form action={clearDemoData}>
<button
type="submit"
className="w-full sm:w-auto flex items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-3 text-sm font-semibold text-red-400 hover:bg-red-500/10 hover:border-red-500/40 transition-all"
>
<Trash2 className="h-4 w-4" />
Clear all data
</button>
</form>
</div>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { Skeleton } from "@/components/shared/skeleton"
export default function Loading() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<Skeleton className="h-5 w-24" />
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-6 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
))}
<Skeleton className="h-10 w-32 rounded-xl mt-2" />
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function SettingsPage() {
redirect("/settings/profile")
}
+27
View File
@@ -0,0 +1,27 @@
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 { ProfileForm } from "@/components/forms/profile-form"
export const metadata = { title: "Profile Settings" }
export default async function ProfileSettingsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
})
return (
<div className="max-w-xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Profile Settings</h2>
<p className="text-sm text-white/40">Update your personal information</p>
</div>
<ProfileForm profile={profile} />
</div>
)
}
@@ -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>
)
}
+186
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function TenantsLoading() {
return <TableSkeleton rows={6} cols={5} />
}
+34
View File
@@ -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>
)
}
+57
View File
@@ -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>
)
}
+168
View File
@@ -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 &ldquo;{search}&rdquo;</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>
)
}
+2
View File
@@ -0,0 +1,2 @@
import { TableSkeleton } from "@/components/shared/skeleton"
export default function Loading() { return <TableSkeleton rows={5} cols={4} /> }
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation"
import { asc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { vendors, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { VendorManager } from "./vendor-manager"
export const metadata = { title: "Vendors" }
export default async function VendorsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const [vendorList, propertyList] = await Promise.all([
db
.select()
.from(vendors)
.where(eq(vendors.user_id, user.id))
.orderBy(asc(vendors.name)),
db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(eq(properties.user_id, user.id)),
])
return (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<h2 className="text-lg font-bold text-white">Vendor Directory</h2>
<p className="text-sm text-white/40 mt-0.5">Save contractor and vendor contacts for quick access</p>
</div>
<VendorManager vendors={vendorList ?? []} properties={propertyList ?? []} />
</div>
)
}
+211
View File
@@ -0,0 +1,211 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Plus, Trash2, Phone, Mail, Wrench, X, Pencil, Check } from "lucide-react"
import { Select } from "@/components/ui/select"
import { toast } from "sonner"
const TRADES = [
{ value: "plumber", label: "Plumber" },
{ value: "electrician", label: "Electrician" },
{ value: "hvac", label: "HVAC" },
{ value: "handyman", label: "Handyman" },
{ value: "cleaner", label: "Cleaner" },
{ value: "landscaper", label: "Landscaper" },
{ value: "roofer", label: "Roofer" },
{ value: "painter", label: "Painter" },
{ value: "contractor", label: "Contractor" },
{ value: "other", label: "Other" },
]
const tradeColors: Record<string, string> = {
plumber: "text-blue-400 bg-blue-500/10",
electrician: "text-yellow-400 bg-yellow-500/10",
hvac: "text-cyan-400 bg-cyan-500/10",
handyman: "text-orange-400 bg-orange-500/10",
cleaner: "text-green-400 bg-green-500/10",
landscaper: "text-emerald-400 bg-emerald-500/10",
roofer: "text-amber-400 bg-amber-500/10",
painter: "text-purple-400 bg-purple-500/10",
contractor: "text-indigo-400 bg-indigo-500/10",
other: "text-white/40 bg-white/5",
}
export function VendorManager({ vendors: initial, properties }: { vendors: any[]; properties: any[] }) {
const router = useRouter()
const [vendors, setVendors] = useState(initial)
const [showForm, setShowForm] = useState(false)
const [loading, setLoading] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState<any>({})
const [form, setForm] = useState({ name: "", trade: "handyman", phone: "", email: "", notes: "", property_id: "" })
const propertyOptions = [
{ value: "", label: "All properties" },
...properties.map((p: any) => ({ value: p.id, label: p.name })),
]
async function addVendor() {
if (!form.name.trim()) return
setLoading(true)
const res = await fetch("/api/vendors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { toast.error(data.error ?? "Failed to add vendor"); return }
setVendors(v => [...v, data])
setForm({ name: "", trade: "handyman", phone: "", email: "", notes: "", property_id: "" })
setShowForm(false)
toast.success("Vendor added")
}
function startEdit(v: any) {
setEditingId(v.id)
setEditForm({ name: v.name, trade: v.trade, phone: v.phone ?? "", email: v.email ?? "", notes: v.notes ?? "" })
}
async function saveEdit(id: string) {
const res = await fetch(`/api/vendors/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
setVendors(v => v.map(x => x.id === id ? { ...x, ...data } : x))
setEditingId(null)
toast.success("Vendor updated")
}
async function deleteVendor(id: string) {
await fetch(`/api/vendors/${id}`, { method: "DELETE" })
setVendors(v => v.filter(x => x.id !== id))
toast.success("Vendor removed")
}
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
return (
<div className="space-y-4">
{/* Add button */}
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 transition hover:shadow-lg hover:shadow-indigo-500/25"
>
<Plus className="h-4 w-4" /> Add Vendor
</button>
)}
{/* Add form */}
{showForm && (
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">New Vendor</p>
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-white/40 mb-1 block">Name *</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="John's Plumbing" className={cls} />
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Trade *</label>
<Select value={form.trade} onChange={v => setForm(f => ({ ...f, trade: v }))} options={TRADES} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-white/40 mb-1 block">Phone</label>
<input value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1 555 000 0000" className={cls} />
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Email</label>
<input value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} placeholder="vendor@email.com" className={cls} />
</div>
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Property (optional)</label>
<Select value={form.property_id} onChange={v => setForm(f => ({ ...f, property_id: v }))} options={propertyOptions} />
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Notes</label>
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="Reliable, good rates..." className={cls} />
</div>
<div className="flex gap-3 pt-1">
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
<button onClick={addVendor} disabled={loading || !form.name.trim()} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
{loading ? "Adding…" : "Add Vendor"}
</button>
</div>
</div>
)}
{/* Vendor list */}
{vendors.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
<Wrench className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No vendors yet</p>
<p className="text-xs text-white/20 mt-1">Add contractors and service providers for quick access</p>
</div>
) : (
<div className="space-y-2">
{vendors.map((v: any) => (
<div key={v.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
{editingId === v.id ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<input value={editForm.name} onChange={e => setEditForm((f: any) => ({ ...f, name: e.target.value }))} placeholder="Name" className={cls} />
<Select value={editForm.trade} onChange={(val: string) => setEditForm((f: any) => ({ ...f, trade: val }))} options={TRADES} />
</div>
<div className="grid grid-cols-2 gap-3">
<input value={editForm.phone} onChange={e => setEditForm((f: any) => ({ ...f, phone: e.target.value }))} placeholder="Phone" className={cls} />
<input value={editForm.email} onChange={e => setEditForm((f: any) => ({ ...f, email: e.target.value }))} placeholder="Email" className={cls} />
</div>
<input value={editForm.notes} onChange={e => setEditForm((f: any) => ({ ...f, notes: e.target.value }))} placeholder="Notes" className={cls} />
<div className="flex gap-2">
<button onClick={() => setEditingId(null)} className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-white/40 hover:text-white transition">Cancel</button>
<button onClick={() => saveEdit(v.id)} className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500 transition">
<Check className="h-3 w-3" /> Save
</button>
</div>
</div>
) : (
<div className="flex items-start gap-4">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-xs font-bold uppercase ${tradeColors[v.trade] ?? tradeColors.other}`}>
{v.name[0]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-white">{v.name}</p>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${tradeColors[v.trade] ?? tradeColors.other}`}>{v.trade}</span>
</div>
<div className="flex items-center gap-3 mt-1 flex-wrap">
{v.phone && <span className="flex items-center gap-1 text-xs text-white/40"><Phone className="h-3 w-3" />{v.phone}</span>}
{v.email && <span className="flex items-center gap-1 text-xs text-white/40"><Mail className="h-3 w-3" />{v.email}</span>}
</div>
{v.notes && <p className="text-xs text-white/25 mt-1 italic">{v.notes}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => startEdit(v)} className="p-1.5 text-white/20 hover:text-indigo-400 transition rounded-lg hover:bg-indigo-500/10">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => deleteVendor(v.id)} className="p-1.5 text-white/20 hover:text-red-400 transition rounded-lg hover:bg-red-500/10">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}