Files
property-management-network/components/dashboard/command-palette.tsx
Leon SerfatyandClaude Opus 4.8 857b9a7811 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>
2026-06-23 20:36:07 -04:00

365 lines
17 KiB
TypeScript

"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import {
Search, LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, BarChart3, Hammer, ClipboardList,
Bot, Settings, Zap, Plus, X, ArrowRight, Loader2,
} from "lucide-react"
import { cn } from "@/lib/utils"
const COMMANDS = [
{
group: "Navigate",
items: [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, keywords: "home overview" },
{ label: "Properties", href: "/properties", icon: Building2, keywords: "buildings units" },
{ label: "Tenants", href: "/tenants", icon: Users, keywords: "renters residents" },
{ label: "Rent Tracker", href: "/rent", icon: CreditCard, keywords: "payments collect" },
{ label: "Maintenance", href: "/maintenance", icon: Wrench, keywords: "repair fix" },
{ label: "Leases", href: "/leases", icon: FileText, keywords: "contracts agreements" },
{ label: "Expenses", href: "/expenses", icon: Receipt, keywords: "costs bills" },
{ label: "Reports", href: "/reports", icon: BarChart3, keywords: "analytics profit" },
{ label: "Vendors", href: "/vendors", icon: Hammer, keywords: "contractors workers" },
{ label: "Inspections", href: "/inspections", icon: ClipboardList, keywords: "checklist condition" },
{ label: "AI Assistant", href: "/ai", icon: Bot, keywords: "ask chat" },
{ label: "Settings", href: "/settings/profile", icon: Settings, keywords: "profile account" },
],
},
{
group: "Quick Actions",
items: [
{ label: "Add Property", href: "/properties/new", icon: Building2, keywords: "new create" },
{ label: "Add Tenant", href: "/tenants/new", icon: Users, keywords: "new create" },
{ label: "Record Payment", href: "/rent/new", icon: CreditCard, keywords: "new create" },
{ label: "New Maintenance Request",href: "/maintenance/new", icon: Wrench, keywords: "new create" },
{ label: "New Lease", href: "/leases/new", icon: FileText, keywords: "new create" },
{ label: "Add Expense", href: "/expenses/new", icon: Receipt, keywords: "new create" },
{ label: "Generate Rent", href: "/rent/generate", icon: Zap, keywords: "bulk all" },
],
},
]
type CommandItem = (typeof COMMANDS)[0]["items"][0]
interface SearchResults {
tenants: { id: string; first_name: string; last_name: string; email?: string }[]
properties: { id: string; name: string; address_line1?: string; city?: string }[]
maintenance: { id: string; title: string; status: string; priority: string }[]
}
export function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [cursor, setCursor] = useState(0)
const [liveResults, setLiveResults] = useState<SearchResults | null>(null)
const [searching, setSearching] = useState(false)
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Open on Ctrl+K / Cmd+K
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setOpen((v) => !v)
}
if (e.key === "Escape") setOpen(false)
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [])
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50)
setQuery("")
setCursor(0)
setLiveResults(null)
}
}, [open])
// Live search debounce
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (query.length < 2) { setLiveResults(null); return }
setSearching(true)
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
if (res.ok) setLiveResults(await res.json())
} finally {
setSearching(false)
}
}, 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
const filtered: { group: string; items: CommandItem[] }[] = COMMANDS.map((g) => ({
group: g.group,
items: g.items.filter((item) => {
if (!query) return true
const q = query.toLowerCase()
return item.label.toLowerCase().includes(q) || item.keywords.includes(q)
}),
})).filter((g) => g.items.length > 0)
const flat: CommandItem[] = filtered.flatMap((g) => g.items)
function go(href: string) {
router.push(href)
setOpen(false)
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault()
setCursor((v) => Math.min(v + 1, flat.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setCursor((v) => Math.max(v - 1, 0))
} else if (e.key === "Enter") {
if (flat[cursor]) go(flat[cursor].href)
}
}
// Scroll active item into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-idx="${cursor}"]`) as HTMLElement
el?.scrollIntoView({ block: "nearest" })
}, [cursor])
if (!open) return null
let globalIdx = 0
return (
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-[15vh] px-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Panel */}
<div className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-white/[0.1] bg-[#16161f] shadow-2xl shadow-black/80">
{/* Search input */}
<div className="flex items-center gap-3 border-b border-white/[0.06] px-4 py-3.5">
<Search className="h-4 w-4 shrink-0 text-white/30" />
<input
ref={inputRef}
value={query}
onChange={(e) => { setQuery(e.target.value); setCursor(0) }}
onKeyDown={onKeyDown}
placeholder="Search pages, actions…"
className="flex-1 bg-transparent text-sm text-white placeholder-white/30 outline-none"
/>
{query && (
<button onClick={() => setQuery("")} className="text-white/30 hover:text-white transition">
<X className="h-3.5 w-3.5" />
</button>
)}
<kbd className="hidden sm:flex items-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-mono text-white/30">
esc
</kbd>
</div>
{/* Results */}
<div ref={listRef} className="max-h-96 overflow-y-auto py-2">
{/* Live data results when query length >= 2 */}
{query.length >= 2 && (
<>
{searching && (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-white/30">
<Loader2 className="h-3 w-3 animate-spin" /> Searching
</div>
)}
{liveResults && (
<>
{liveResults.tenants.length > 0 && (
<div>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Tenants</p>
{liveResults.tenants.map((t) => {
const idx = globalIdx++
const active = cursor === idx
return (
<button
key={t.id}
data-idx={idx}
onClick={() => go(`/tenants/${t.id}`)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Users className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{t.first_name} {t.last_name}</p>
{t.email && <p className="text-xs text-white/30 truncate">{t.email}</p>}
</div>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
)}
{liveResults.properties.length > 0 && (
<div>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Properties</p>
{liveResults.properties.map((p) => {
const idx = globalIdx++
const active = cursor === idx
return (
<button
key={p.id}
data-idx={idx}
onClick={() => go(`/properties/${p.id}`)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Building2 className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{p.name}</p>
{p.city && <p className="text-xs text-white/30 truncate">{p.address_line1 ? `${p.address_line1}, ` : ""}{p.city}</p>}
</div>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
)}
{liveResults.maintenance.length > 0 && (
<div>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Maintenance</p>
{liveResults.maintenance.map((m) => {
const idx = globalIdx++
const active = cursor === idx
return (
<button
key={m.id}
data-idx={idx}
onClick={() => go(`/maintenance/${m.id}`)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Wrench className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.title}</p>
<p className="text-xs text-white/30 capitalize">{m.priority} · {m.status.replace("_", " ")}</p>
</div>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
)}
{!searching && liveResults.tenants.length === 0 && liveResults.properties.length === 0 && liveResults.maintenance.length === 0 && filtered.length === 0 && (
<p className="py-8 text-center text-sm text-white/30">No results for &ldquo;{query}&rdquo;</p>
)}
</>
)}
{/* Divider before nav items if there are live results */}
{liveResults && (liveResults.tenants.length > 0 || liveResults.properties.length > 0 || liveResults.maintenance.length > 0) && filtered.length > 0 && (
<div className="my-1 border-t border-white/[0.06]" />
)}
</>
)}
{/* Static command items */}
{filtered.length === 0 && query.length < 2 ? (
<p className="py-10 text-center text-sm text-white/30">Type to search</p>
) : (
filtered.map((group) => (
<div key={group.group}>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">
{group.group}
</p>
{group.items.map((item) => {
const idx = globalIdx++
const Icon = item.icon
const active = cursor === idx
return (
<button
key={item.href}
data-idx={idx}
onClick={() => go(item.href)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Icon className="h-3.5 w-3.5" />
</div>
<span className="flex-1 font-medium">{item.label}</span>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-4 border-t border-white/[0.06] px-4 py-2.5">
{[["↑↓", "navigate"], ["↵", "open"], ["esc", "close"]].map(([key, label]) => (
<span key={key} className="flex items-center gap-1.5 text-[10px] text-white/25">
<kbd className="rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 font-mono">{key}</kbd>
{label}
</span>
))}
</div>
</div>
</div>
)
}
// Trigger button for the header
export function CommandTrigger() {
return (
<button
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })
document.dispatchEvent(e)
}}
className="hidden sm:flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs text-white/30 transition hover:border-white/[0.15] hover:text-white/60"
>
<Search className="h-3 w-3" />
<span>Search</span>
<kbd className="ml-1 flex items-center gap-0.5 font-mono text-[10px]">
<span></span><span>K</span>
</kbd>
</button>
)
}