Files
property-management-network/app/(dashboard)/expenses/expenses-client.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

177 lines
8.0 KiB
TypeScript

"use client"
import { useState, useMemo } from "react"
import Link from "next/link"
import { Receipt, Download, Plus } from "lucide-react"
import { EmptyState } from "@/components/shared/empty-state"
import { formatCurrency, formatDate } from "@/lib/utils"
import { DeleteButton } from "@/components/shared/delete-button"
const categoryColors: Record<string, string> = {
repairs: "text-orange-400 bg-orange-500/10 ring-orange-500/20",
utilities: "text-blue-400 bg-blue-500/10 ring-blue-500/20",
insurance: "text-purple-400 bg-purple-500/10 ring-purple-500/20",
mortgage: "text-indigo-400 bg-indigo-500/10 ring-indigo-500/20",
taxes: "text-red-400 bg-red-500/10 ring-red-500/20",
management: "text-cyan-400 bg-cyan-500/10 ring-cyan-500/20",
supplies: "text-green-400 bg-green-500/10 ring-green-500/20",
other: "text-white/40 bg-white/5 ring-white/10",
}
export function ExpensesClient({ expenses: initial, properties }: { expenses: any[]; properties: any[] }) {
const [expenses, setExpenses] = useState(initial)
const [propertyFilter, setPropertyFilter] = useState("")
const filtered = useMemo(() =>
propertyFilter ? expenses.filter((e) => e.property_id === propertyFilter) : expenses,
[expenses, propertyFilter]
)
const total = filtered.reduce((s, e) => s + Number(e.amount), 0)
const byCategory = filtered.reduce((acc: Record<string, number>, e) => {
acc[e.category] = (acc[e.category] ?? 0) + Number(e.amount)
return acc
}, {})
function onDeleted(id: string) {
setExpenses((prev) => prev.filter((e) => e.id !== id))
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Expenses</h2>
<p className="text-sm text-white/40 mt-0.5">
{filtered.length} records
{total > 0 && <span className="ml-2 text-rose-400">{formatCurrency(total)} total</span>}
</p>
</div>
<div className="flex items-center gap-2">
<a
href="/api/expenses/export"
className="flex items-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-xs font-medium text-white/50 hover:border-white/20 hover:text-white transition"
>
<Download className="h-3.5 w-3.5" /> Export CSV
</a>
<Link
href="/expenses/new"
className="flex items-center gap-1.5 rounded-xl bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-500 transition"
>
<Plus className="h-3.5 w-3.5" /> Add Expense
</Link>
</div>
</div>
{/* Property filter */}
{properties.length > 1 && (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setPropertyFilter("")}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${!propertyFilter ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
>
All Properties
</button>
{properties.map((p) => (
<button
key={p.id}
onClick={() => setPropertyFilter(p.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${propertyFilter === p.id ? "bg-indigo-600 text-white" : "border border-white/10 text-white/50 hover:text-white"}`}
>
{p.name}
</button>
))}
</div>
)}
{/* Category breakdown */}
{Object.keys(byCategory).length > 0 && (
<div className="flex gap-2 flex-wrap">
{Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amt]) => (
<div key={cat} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ring-1 ring-inset ${categoryColors[cat] ?? categoryColors.other}`}>
<span className="capitalize">{cat}</span>
<span className="opacity-60">{formatCurrency(amt as number)}</span>
</div>
))}
</div>
)}
{!filtered.length ? (
<EmptyState
icon={Receipt}
title="No expenses yet"
description="Track property expenses to monitor profitability and prepare for tax season."
action={{ label: "Add expense", href: "/expenses/new" }}
/>
) : (
<>
{/* 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]">
{["Description", "Property", "Category", "Date", "Amount", ""].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-medium text-white/30 tracking-wide">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{filtered.map((expense) => (
<tr key={expense.id} className="group hover:bg-white/[0.02] transition">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-white">{expense.description}</p>
{expense.vendor && <p className="text-xs text-white/35">{expense.vendor}</p>}
</td>
<td className="px-5 py-3.5">
<p className="text-sm text-white/60">{expense.property?.name ?? "—"}</p>
{expense.unit && <p className="text-xs text-white/35">Unit {expense.unit.unit_number}</p>}
</td>
<td className="px-5 py-3.5">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${categoryColors[expense.category] ?? categoryColors.other}`}>
{expense.category}
</span>
</td>
<td className="px-5 py-3.5 text-sm text-white/50">{formatDate(expense.expense_date)}</td>
<td className="px-5 py-3.5 text-sm font-bold text-white tabular-nums">{formatCurrency(expense.amount)}</td>
<td className="px-5 py-3.5 text-right">
<DeleteButton id={expense.id} endpoint="/api/expenses" onDeleted={() => onDeleted(expense.id)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-2">
{filtered.map((expense) => (
<div key={expense.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate">{expense.description}</p>
{expense.vendor && <p className="text-xs text-white/35 mt-0.5">{expense.vendor}</p>}
</div>
<p className="shrink-0 text-sm font-bold text-white tabular-nums">{formatCurrency(expense.amount)}</p>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${categoryColors[expense.category] ?? categoryColors.other}`}>
{expense.category}
</span>
<span className="text-xs text-white/35">{formatDate(expense.expense_date)}</span>
</div>
<DeleteButton id={expense.id} endpoint="/api/expenses" onDeleted={() => onDeleted(expense.id)} />
</div>
</div>
))}
</div>
</>
)}
</div>
)
}