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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user