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,173 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Sparkles, Loader2, X, AlertTriangle, TrendingUp, Wrench } from "lucide-react"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
interface Summary {
|
||||
reportTitle: string
|
||||
reportDate: string
|
||||
propertyName: string
|
||||
totalRequests: number
|
||||
openRequests: number
|
||||
resolvedRequests: number
|
||||
urgentItems: string[]
|
||||
summary: string
|
||||
recommendations: string[]
|
||||
estimatedTotalCost: number
|
||||
}
|
||||
|
||||
export function AiMaintenanceSummary({ propertyId }: { propertyId: string }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
async function generate() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/maintenance-summary", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ property_id: propertyId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error ?? "Failed to generate summary"); return }
|
||||
setSummary(data)
|
||||
} catch {
|
||||
setError("Network error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-indigo-500/30 bg-indigo-500/10 px-3 py-2 text-xs font-medium text-indigo-300 hover:bg-indigo-500/20 transition-all disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI Summary
|
||||
</button>
|
||||
|
||||
{/* Slide-over panel */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-[#111118] border-l border-white/[0.06] overflow-y-auto shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||
<h2 className="text-sm font-semibold text-white">AI Maintenance Report</h2>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="rounded-lg p-1.5 text-white/40 hover:text-white transition">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<Loader2 className="h-8 w-8 text-indigo-400 animate-spin" />
|
||||
<p className="text-sm text-white/50">Analysing maintenance data…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && !loading && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">{summary.reportTitle}</h3>
|
||||
<p className="text-xs text-white/40 mt-0.5">{summary.reportDate} · {summary.propertyName}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Total", value: summary.totalRequests, color: "text-white" },
|
||||
{ label: "Open", value: summary.openRequests, color: "text-amber-400" },
|
||||
{ label: "Resolved", value: summary.resolvedRequests, color: "text-emerald-400" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 text-center">
|
||||
<p className={`text-xl font-bold ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
{summary.estimatedTotalCost > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3">
|
||||
<span className="text-xs text-amber-400/80">Estimated total cost</span>
|
||||
<span className="text-sm font-bold text-amber-400">{formatCurrency(summary.estimatedTotalCost)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-white/30 mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp className="h-3 w-3" /> Overview
|
||||
</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed">{summary.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Urgent items */}
|
||||
{summary.urgentItems?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-red-400/70 mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3 w-3" /> Urgent Items
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{summary.urgentItems.map((item, i) => (
|
||||
<li key={i} className="text-sm text-white/70 flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-400" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{summary.recommendations?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-indigo-400/70 mb-2 flex items-center gap-1.5">
|
||||
<Wrench className="h-3 w-3" /> Recommendations
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{summary.recommendations.map((rec, i) => (
|
||||
<li key={i} className="text-sm text-white/70 flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-indigo-500/20 text-[10px] font-bold text-indigo-400">{i + 1}</span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generate}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl border border-white/[0.06] px-4 py-2.5 text-xs font-medium text-white/50 hover:text-white hover:border-white/20 transition"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Regenerate report
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user