feat(statements): add bank/type/owner/year filters and row numbering
This commit is contained in:
+99
-14
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
|
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
|
||||||
|
|
||||||
@@ -30,24 +31,114 @@ function formatAmount(n: number | null): string {
|
|||||||
}).format(Number(n));
|
}).format(Number(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectCls =
|
||||||
|
"bg-zinc-900 border border-zinc-700 rounded text-xs px-2 py-1.5 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500";
|
||||||
|
|
||||||
export default function StatementsPage() {
|
export default function StatementsPage() {
|
||||||
const { data: statements, isLoading } = useStatements();
|
const { data: statements, isLoading } = useStatements();
|
||||||
const { data: participants } = useParticipants();
|
const { data: participants } = useParticipants();
|
||||||
const updateStatement = useUpdateStatement();
|
const updateStatement = useUpdateStatement();
|
||||||
|
|
||||||
|
const [bankFilter, setBankFilter] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<"all" | "card" | "bank">("all");
|
||||||
|
const [ownerFilter, setOwnerFilter] = useState("");
|
||||||
|
const [yearFilter, setYearFilter] = useState("");
|
||||||
|
|
||||||
|
const banks = useMemo(
|
||||||
|
() => [...new Set((statements ?? []).map((s) => s.bank_name))].sort(),
|
||||||
|
[statements]
|
||||||
|
);
|
||||||
|
const years = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
...new Set(
|
||||||
|
(statements ?? [])
|
||||||
|
.map((s) => s.billing_end_date?.slice(0, 4))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
),
|
||||||
|
].sort((a, b) => b.localeCompare(a)),
|
||||||
|
[statements]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!statements) return [];
|
||||||
|
return statements.filter((s) => {
|
||||||
|
const isCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||||
|
if (bankFilter && s.bank_name !== bankFilter) return false;
|
||||||
|
if (typeFilter === "card" && !isCard) return false;
|
||||||
|
if (typeFilter === "bank" && isCard) return false;
|
||||||
|
if (ownerFilter && String(s.owner_id) !== ownerFilter) return false;
|
||||||
|
if (yearFilter && s.billing_end_date?.slice(0, 4) !== yearFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [statements, bankFilter, typeFilter, ownerFilter, yearFilter]);
|
||||||
|
|
||||||
|
const hasFilters = bankFilter || typeFilter !== "all" || ownerFilter || yearFilter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Statements</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Statements</h2>
|
||||||
|
{!isLoading && statements && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
{!isLoading && statements && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<select value={bankFilter} onChange={(e) => setBankFilter(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">All banks</option>
|
||||||
|
{banks.map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)} className={selectCls}>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
<option value="card">Credit card</option>
|
||||||
|
<option value="bank">Bank account</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{participants && participants.length > 1 && (
|
||||||
|
<select value={ownerFilter} onChange={(e) => setOwnerFilter(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">All owners</option>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={String(p.id)}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select value={yearFilter} onChange={(e) => setYearFilter(e.target.value)} className={selectCls}>
|
||||||
|
<option value="">All years</option>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>{y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setBankFilter(""); setTypeFilter("all"); setOwnerFilter(""); setYearFilter(""); }}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
× Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
) : !statements?.length ? (
|
) : !filtered.length ? (
|
||||||
<p className="text-zinc-500 text-sm">No statements found</p>
|
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "No statements found"}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
|
<th className="text-left px-3 py-2.5 text-xs text-zinc-600 font-medium w-8">#</th>
|
||||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
||||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
||||||
@@ -60,11 +151,10 @@ export default function StatementsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{statements.map((s) => {
|
{filtered.map((s, idx) => {
|
||||||
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||||
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
|
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
|
||||||
const amount = Number(displayAmount);
|
const amount = Number(displayAmount);
|
||||||
// CC: red (you owe). Bank: green if positive balance, red if overdraft.
|
|
||||||
const amountColor = isCreditCard
|
const amountColor = isCreditCard
|
||||||
? "text-red-400"
|
? "text-red-400"
|
||||||
: amount >= 0
|
: amount >= 0
|
||||||
@@ -73,6 +163,7 @@ export default function StatementsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||||
|
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
|
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
|
||||||
{s.bank_name}
|
{s.bank_name}
|
||||||
@@ -88,18 +179,14 @@ export default function StatementsPage() {
|
|||||||
{formatPeriod(s.billing_start_date, s.billing_end_date)}
|
{formatPeriod(s.billing_start_date, s.billing_end_date)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||||
{isCreditCard
|
{isCreditCard ? formatDate(s.payment_due_date) : formatDate(s.billing_end_date)}
|
||||||
? formatDate(s.payment_due_date)
|
|
||||||
: formatDate(s.billing_end_date)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||||
{s.currency}
|
{s.currency}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
{displayAmount !== null && displayAmount !== undefined ? (
|
{displayAmount !== null && displayAmount !== undefined ? (
|
||||||
<span className={amountColor}>
|
<span className={amountColor}>{formatAmount(displayAmount)}</span>
|
||||||
{formatAmount(displayAmount)}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-zinc-600">—</span>
|
<span className="text-zinc-600">—</span>
|
||||||
)}
|
)}
|
||||||
@@ -117,9 +204,7 @@ export default function StatementsPage() {
|
|||||||
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user