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>
174 lines
7.4 KiB
TypeScript
174 lines
7.4 KiB
TypeScript
"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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|