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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function ExpensesLoading() {
|
||||
return <TableSkeleton rows={7} cols={5} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ?? []} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
export default function Loading() { return <TableSkeleton rows={5} cols={3} /> }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function LeasesLoading() {
|
||||
return <TableSkeleton rows={6} cols={6} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CardGridSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function PropertiesLoading() {
|
||||
return <CardGridSkeleton cards={6} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function RentLoading() {
|
||||
return <TableSkeleton rows={8} cols={5} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
export default function Loading() { return <TableSkeleton rows={6} cols={4} /> }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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&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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function SettingsPage() {
|
||||
redirect("/settings/profile")
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function TenantsLoading() {
|
||||
return <TableSkeleton rows={6} cols={5} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 “{search}”</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>
|
||||
)
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import { TableSkeleton } from "@/components/shared/skeleton"
|
||||
export default function Loading() { return <TableSkeleton rows={5} cols={4} /> }
|
||||
Vendored
+35
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user