177 lines
8.0 KiB
TypeScript
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>
|
||
|
|
)
|
||
|
|
}
|