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,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 ?? []} />
|
||||
}
|
||||
Reference in New Issue
Block a user