Files
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

178 lines
8.0 KiB
TypeScript

"use client"
import { useState } from "react"
import Link from "next/link"
import { Mail, Search, ArrowRight, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"
import { formatDate, formatCurrency } from "@/lib/utils"
import { DeleteTenantButton } from "@/components/forms/delete-tenant-button"
type SortKey = "name" | "property" | "move_in" | "rent"
type SortDir = "asc" | "desc"
function SortIcon({ col, active, dir }: { col: SortKey; active: SortKey; dir: SortDir }) {
if (active !== col) return <ArrowUpDown className="h-3 w-3 opacity-30" />
return dir === "asc"
? <ArrowUp className="h-3 w-3 text-indigo-400" />
: <ArrowDown className="h-3 w-3 text-indigo-400" />
}
export function TenantsTable({ tenants }: { tenants: any[] }) {
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortDir, setSortDir] = useState<SortDir>("asc")
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
else { setSortKey(key); setSortDir("asc") }
}
const filtered = tenants
.filter((t) => {
const q = search.toLowerCase()
return (
!q ||
`${t.first_name} ${t.last_name}`.toLowerCase().includes(q) ||
t.email?.toLowerCase().includes(q) ||
t.property?.name?.toLowerCase().includes(q)
)
})
.sort((a, b) => {
let av: string | number = ""
let bv: string | number = ""
if (sortKey === "name") { av = `${a.first_name} ${a.last_name}`; bv = `${b.first_name} ${b.last_name}` }
if (sortKey === "property") { av = a.property?.name ?? ""; bv = b.property?.name ?? "" }
if (sortKey === "move_in") { av = a.move_in_date ?? ""; bv = b.move_in_date ?? "" }
if (sortKey === "rent") { av = a.unit?.rent_amount ?? 0; bv = b.unit?.rent_amount ?? 0 }
if (av < bv) return sortDir === "asc" ? -1 : 1
if (av > bv) return sortDir === "asc" ? 1 : -1
return 0
})
const th = (label: string, key: SortKey) => (
<th
className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide cursor-pointer select-none hover:text-white/60 transition-colors"
onClick={() => toggleSort(key)}
>
<span className="flex items-center gap-1.5">
{label}
<SortIcon col={key} active={sortKey} dir={sortDir} />
</span>
</th>
)
return (
<div className="space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-white/25" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, email or property…"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] py-2.5 pl-10 pr-4 text-sm text-white placeholder-white/25 outline-none transition focus:border-indigo-500/50 focus:bg-white/[0.05] focus:ring-1 focus:ring-indigo-500/30"
/>
</div>
{filtered.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
<p className="text-sm text-white/30">No tenants match &ldquo;{search}&rdquo;</p>
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-white/[0.06]">
{th("Tenant", "name")}
{th("Property / Unit", "property")}
{th("Move In", "move_in")}
{th("Rent / mo", "rent")}
<th className="px-5 py-3.5" />
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{filtered.map((tenant) => (
<tr key={tenant.id} className="group hover:bg-white/[0.02] transition-colors">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/20 text-xs font-bold text-indigo-300 ring-1 ring-inset ring-indigo-500/20">
{tenant.first_name?.[0]}{tenant.last_name?.[0]}
</div>
<div>
<p className="text-sm font-medium text-white">{tenant.first_name} {tenant.last_name}</p>
{tenant.email && (
<span className="flex items-center gap-1 text-xs text-white/35">
<Mail className="h-3 w-3" />{tenant.email}
</span>
)}
</div>
</div>
</td>
<td className="px-5 py-4">
<p className="text-sm text-white/80">{tenant.property?.name ?? "—"}</p>
<p className="text-xs text-white/35">Unit {tenant.unit?.unit_number ?? "—"}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm text-white/60">{tenant.move_in_date ? formatDate(tenant.move_in_date) : "—"}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm font-semibold text-white">
{tenant.unit?.rent_amount ? formatCurrency(tenant.unit.rent_amount) : "—"}
</p>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-3">
<Link
href={`/tenants/${tenant.id}`}
className="inline-flex items-center gap-1 text-xs text-white/30 transition group-hover:text-indigo-400"
>
View <ArrowRight className="h-3 w-3" />
</Link>
<DeleteTenantButton
tenantId={tenant.id}
tenantName={`${tenant.first_name} ${tenant.last_name}`}
refreshOnly
compact
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{filtered.map((tenant) => (
<Link
key={tenant.id}
href={`/tenants/${tenant.id}`}
className="flex items-center gap-3 rounded-xl border border-white/[0.06] bg-[#16161f] p-4 transition hover:border-indigo-500/20 hover:bg-[#1a1a2e]"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500/20 to-violet-500/20 text-sm font-bold text-indigo-300">
{tenant.first_name?.[0]}{tenant.last_name?.[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white">{tenant.first_name} {tenant.last_name}</p>
<div className="flex items-center gap-2 mt-0.5 text-xs text-white/40">
{tenant.property?.name && <span className="truncate">{tenant.property.name}</span>}
{tenant.unit?.unit_number && <span>· Unit {tenant.unit.unit_number}</span>}
</div>
</div>
<div className="shrink-0 text-right">
{tenant.unit?.rent_amount && (
<p className="text-sm font-semibold text-white">{formatCurrency(tenant.unit.rent_amount)}</p>
)}
<p className="text-xs text-white/30">per mo</p>
</div>
</Link>
))}
</div>
</>
)}
</div>
)
}