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>
240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
"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>
|
|
)
|
|
}
|