286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
"use client"
|
|||
|
|
|
||
|
|
import { useState, useMemo } from "react"
|
||
|
|
import Link from "next/link"
|
||
|
|
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown, Check, Loader2 } from "lucide-react"
|
||
|
|
import { RentStatusBadge } from "@/components/dashboard/rent-status-badge"
|
||
|
|
import { RentActions } from "@/components/forms/rent-actions"
|
||
|
|
import { RentReceiptButton } from "@/components/forms/rent-receipt-button"
|
||
|
|
import { LateNoticeButton } from "@/components/forms/late-notice-button"
|
||
|
|
import { formatCurrency, formatDate } from "@/lib/utils"
|
||
|
|
import { cn } from "@/lib/utils"
|
||
|
|
import { toast } from "sonner"
|
||
|
|
|
||
|
|
function monthLabel(d: Date) {
|
||
|
|
return d.toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||
|
|
}
|
||
|
|
|
||
|
|
const STATUSES = ["pending", "paid", "overdue"] as const
|
||
|
|
type Status = typeof STATUSES[number]
|
||
|
|
|
||
|
|
// Inline status picker — click badge to cycle through statuses
|
||
|
|
function InlineStatusEdit({ payment }: { payment: any }) {
|
||
|
|
const [status, setStatus] = useState<Status>(payment.status)
|
||
|
|
const [open, setOpen] = useState(false)
|
||
|
|
const [saving, setSaving] = useState(false)
|
||
|
|
|
||
|
|
async function updateStatus(next: Status) {
|
||
|
|
if (next === status) { setOpen(false); return }
|
||
|
|
setSaving(true)
|
||
|
|
setOpen(false)
|
||
|
|
const res = await fetch(`/api/rent/${payment.id}`, {
|
||
|
|
method: "PATCH",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ status: next }),
|
||
|
|
})
|
||
|
|
setSaving(false)
|
||
|
|
if (res.ok) {
|
||
|
|
setStatus(next)
|
||
|
|
toast.success("Status updated")
|
||
|
|
} else {
|
||
|
|
toast.error("Failed to update status")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (saving) return <Loader2 className="h-3.5 w-3.5 animate-spin text-white/30" />
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="relative">
|
||
|
|
<button onClick={() => setOpen((v) => !v)} title="Click to change status">
|
||
|
|
<RentStatusBadge status={status} />
|
||
|
|
</button>
|
||
|
|
{open && (
|
||
|
|
<div className="absolute left-0 z-50 mt-1.5 w-32 overflow-hidden rounded-xl border border-white/[0.08] bg-[#1d1d2a] shadow-2xl shadow-black/60 py-1">
|
||
|
|
{STATUSES.map((s) => (
|
||
|
|
<button
|
||
|
|
key={s}
|
||
|
|
onClick={() => updateStatus(s)}
|
||
|
|
className={cn(
|
||
|
|
"flex w-full items-center justify-between px-3 py-2 text-xs capitalize transition-colors",
|
||
|
|
s === status ? "text-indigo-300 bg-indigo-500/10" : "text-white/60 hover:bg-white/[0.05] hover:text-white"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{s}
|
||
|
|
{s === status && <Check className="h-3 w-3 text-indigo-400" />}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
type SortKey = "tenant" | "due_date" | "amount" | "status"
|
||
|
|
type SortDir = "asc" | "desc"
|
||
|
|
|
||
|
|
function SortTh({ label, col, active, dir, onClick }: { label: string; col: SortKey; active: SortKey; dir: SortDir; onClick: () => void }) {
|
||
|
|
return (
|
||
|
|
<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={onClick}
|
||
|
|
>
|
||
|
|
<span className="flex items-center gap-1.5">
|
||
|
|
{label}
|
||
|
|
{active !== col
|
||
|
|
? <ArrowUpDown className="h-3 w-3 opacity-30" />
|
||
|
|
: dir === "asc" ? <ArrowUp className="h-3 w-3 text-indigo-400" /> : <ArrowDown className="h-3 w-3 text-indigo-400" />
|
||
|
|
}
|
||
|
|
</span>
|
||
|
|
</th>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function RentTable({ payments }: { payments: any[] }) {
|
||
|
|
const now = new Date()
|
||
|
|
const [year, setYear] = useState(now.getFullYear())
|
||
|
|
const [month, setMonth] = useState(now.getMonth())
|
||
|
|
const [sortKey, setSortKey] = useState<SortKey>("due_date")
|
||
|
|
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||
|
|
|
||
|
|
function prevMonth() {
|
||
|
|
if (month === 0) { setMonth(11); setYear((y) => y - 1) }
|
||
|
|
else setMonth((m) => m - 1)
|
||
|
|
}
|
||
|
|
function nextMonth() {
|
||
|
|
if (month === 11) { setMonth(0); setYear((y) => y + 1) }
|
||
|
|
else setMonth((m) => m + 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleSort(key: SortKey) {
|
||
|
|
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
||
|
|
else { setSortKey(key); setSortDir("asc") }
|
||
|
|
}
|
||
|
|
|
||
|
|
const filtered = useMemo(() =>
|
||
|
|
payments
|
||
|
|
.filter((p) => {
|
||
|
|
const d = new Date(p.due_date)
|
||
|
|
return d.getFullYear() === year && d.getMonth() === month
|
||
|
|
})
|
||
|
|
.sort((a, b) => {
|
||
|
|
let av: string | number = ""
|
||
|
|
let bv: string | number = ""
|
||
|
|
if (sortKey === "tenant") { av = `${a.tenant?.first_name} ${a.tenant?.last_name}`; bv = `${b.tenant?.first_name} ${b.tenant?.last_name}` }
|
||
|
|
if (sortKey === "due_date") { av = a.due_date ?? ""; bv = b.due_date ?? "" }
|
||
|
|
if (sortKey === "amount") { av = Number(a.amount); bv = Number(b.amount) }
|
||
|
|
if (sortKey === "status") { av = a.status ?? ""; bv = b.status ?? "" }
|
||
|
|
if (av < bv) return sortDir === "asc" ? -1 : 1
|
||
|
|
if (av > bv) return sortDir === "asc" ? 1 : -1
|
||
|
|
return 0
|
||
|
|
}),
|
||
|
|
[payments, year, month, sortKey, sortDir]
|
||
|
|
)
|
||
|
|
|
||
|
|
const stats = useMemo(() => ({
|
||
|
|
collected: filtered.filter((p) => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0),
|
||
|
|
pending: filtered.filter((p) => p.status === "pending").reduce((s, p) => s + Number(p.amount), 0),
|
||
|
|
overdue: filtered.filter((p) => p.status === "overdue").reduce((s, p) => s + Number(p.amount), 0),
|
||
|
|
}), [filtered])
|
||
|
|
|
||
|
|
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth()
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-5">
|
||
|
|
{/* Month navigator */}
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<button
|
||
|
|
onClick={prevMonth}
|
||
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
|
||
|
|
>
|
||
|
|
<ChevronLeft className="h-4 w-4" />
|
||
|
|
</button>
|
||
|
|
<span className="min-w-[160px] text-center text-sm font-semibold text-white px-1">
|
||
|
|
{monthLabel(new Date(year, month, 1))}
|
||
|
|
</span>
|
||
|
|
<button
|
||
|
|
onClick={nextMonth}
|
||
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/[0.08] text-white/40 hover:text-white hover:border-white/20 transition"
|
||
|
|
>
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
</button>
|
||
|
|
{!isCurrentMonth && (
|
||
|
|
<button
|
||
|
|
onClick={() => { setYear(now.getFullYear()); setMonth(now.getMonth()) }}
|
||
|
|
className="ml-2 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||
|
|
>
|
||
|
|
Today
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-white/30">{filtered.length} record{filtered.length !== 1 ? "s" : ""}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Stats */}
|
||
|
|
<div className="grid grid-cols-3 gap-3">
|
||
|
|
{[
|
||
|
|
{ label: "Collected", value: stats.collected, color: "text-emerald-400", bar: "bg-emerald-500" },
|
||
|
|
{ label: "Pending", value: stats.pending, color: "text-amber-400", bar: "bg-amber-500" },
|
||
|
|
{ label: "Overdue", value: stats.overdue, color: "text-red-400", bar: "bg-red-500" },
|
||
|
|
].map((s) => (
|
||
|
|
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||
|
|
<p className="text-xs text-white/35 tracking-wide">{s.label}</p>
|
||
|
|
<p className={`mt-1.5 text-xl font-bold tabular-nums ${s.color}`}>{formatCurrency(s.value)}</p>
|
||
|
|
<div className="mt-2 h-1 w-full rounded-full bg-white/[0.06]">
|
||
|
|
<div
|
||
|
|
className={`h-1 rounded-full ${s.bar} transition-all`}
|
||
|
|
style={{ width: s.value > 0 ? `${Math.round((s.value / (stats.collected + stats.pending + stats.overdue || 1)) * 100)}%` : "0%" }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Table */}
|
||
|
|
{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 payments in {monthLabel(new Date(year, month, 1))}</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<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]">
|
||
|
|
<SortTh label="Tenant" col="tenant" active={sortKey} dir={sortDir} onClick={() => toggleSort("tenant")} />
|
||
|
|
<th className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">Property / Unit</th>
|
||
|
|
<SortTh label="Due Date" col="due_date" active={sortKey} dir={sortDir} onClick={() => toggleSort("due_date")} />
|
||
|
|
<SortTh label="Amount" col="amount" active={sortKey} dir={sortDir} onClick={() => toggleSort("amount")} />
|
||
|
|
<SortTh label="Status" col="status" active={sortKey} dir={sortDir} onClick={() => toggleSort("status")} />
|
||
|
|
<th className="px-5 py-3.5" />
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody className="divide-y divide-white/[0.04]">
|
||
|
|
{filtered.map((p) => (
|
||
|
|
<tr key={p.id} className="group hover:bg-white/[0.02] transition">
|
||
|
|
<td className="px-5 py-3.5">
|
||
|
|
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-medium text-white hover:text-indigo-300 transition">
|
||
|
|
{p.tenant?.first_name} {p.tenant?.last_name}
|
||
|
|
</Link>
|
||
|
|
</td>
|
||
|
|
<td className="px-5 py-3.5">
|
||
|
|
<p className="text-sm text-white/70">{p.property?.name}</p>
|
||
|
|
<p className="text-xs text-white/35">Unit {p.unit?.unit_number ?? "—"}</p>
|
||
|
|
</td>
|
||
|
|
<td className="px-5 py-3.5">
|
||
|
|
<p className="text-sm text-white/60">{formatDate(p.due_date)}</p>
|
||
|
|
{p.paid_date && <p className="text-xs text-white/30">Paid {formatDate(p.paid_date)}</p>}
|
||
|
|
</td>
|
||
|
|
<td className="px-5 py-3.5">
|
||
|
|
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
|
||
|
|
</td>
|
||
|
|
<td className="px-5 py-3.5">
|
||
|
|
<InlineStatusEdit payment={p} />
|
||
|
|
</td>
|
||
|
|
<td className="px-5 py-3.5 text-right">
|
||
|
|
<div className="flex items-center justify-end gap-2">
|
||
|
|
<RentReceiptButton
|
||
|
|
payment={p}
|
||
|
|
tenant={p.tenant}
|
||
|
|
property={p.property}
|
||
|
|
unit={p.unit}
|
||
|
|
/>
|
||
|
|
<LateNoticeButton
|
||
|
|
payment={p}
|
||
|
|
tenant={p.tenant}
|
||
|
|
property={p.property}
|
||
|
|
unit={p.unit}
|
||
|
|
/>
|
||
|
|
<RentActions payment={p} />
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Mobile cards */}
|
||
|
|
<div className="sm:hidden space-y-2">
|
||
|
|
{filtered.map((p) => (
|
||
|
|
<div key={p.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||
|
|
<div className="flex items-start justify-between gap-3">
|
||
|
|
<div className="min-w-0">
|
||
|
|
<Link href={`/tenants/${p.tenant_id}`} className="text-sm font-semibold text-white hover:text-indigo-300">
|
||
|
|
{p.tenant?.first_name} {p.tenant?.last_name}
|
||
|
|
</Link>
|
||
|
|
<p className="text-xs text-white/40 mt-0.5 truncate">{p.property?.name} · Unit {p.unit?.unit_number ?? "—"}</p>
|
||
|
|
</div>
|
||
|
|
<InlineStatusEdit payment={p} />
|
||
|
|
</div>
|
||
|
|
<div className="mt-3 flex items-center justify-between">
|
||
|
|
<p className="text-xs text-white/35">Due {formatDate(p.due_date)}</p>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<p className="text-base font-bold text-white tabular-nums">{formatCurrency(p.amount)}</p>
|
||
|
|
<RentActions payment={p} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|