feat(statements): table layout + statement-scoped transaction view

- Statements page: replace card grid with compact table showing bank,
  account, period, due date, currency, amount (due for CC / balance for
  bank), transaction count, and View button
- Transactions page: wrap in Suspense, read statement_id from URL search
  params on load; show a dismissible indigo banner with bank name and
  billing period when filtering by statement; × Clear filter removes it
This commit is contained in:
2026-03-09 12:03:39 +11:00
parent e3aa17acdd
commit f90ba332bd
2 changed files with 124 additions and 47 deletions
+80 -38
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { useStatements } from "@/lib/hooks"; import { useStatements } from "@/lib/hooks";
function formatDate(d: string | null) { function formatDate(d: string | null) {
if (!d) return "-"; if (!d) return "";
return new Date(d).toLocaleDateString("en-AU", { return new Date(d).toLocaleDateString("en-AU", {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
@@ -12,12 +12,22 @@ function formatDate(d: string | null) {
}); });
} }
function formatCurrency(amount: number | null, currency = "AUD") { function formatPeriod(start: string | null, end: string | null) {
if (amount === null || amount === undefined) return "-"; if (!start && !end) return "";
const fmt = (d: string) =>
new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "2-digit" });
if (!start) return `until ${fmt(end!)}`;
if (!end) return `from ${fmt(start)}`;
return `${fmt(start)} ${fmt(end)}`;
}
function formatAmount(n: number | null): string {
if (n === null || n === undefined) return "—";
return new Intl.NumberFormat("en-AU", { return new Intl.NumberFormat("en-AU", {
style: "currency", style: "currency",
currency, currency: "AUD",
}).format(amount); minimumFractionDigits: 2,
}).format(Number(n));
} }
export default function StatementsPage() { export default function StatementsPage() {
@@ -28,48 +38,80 @@ export default function StatementsPage() {
<h2 className="text-xl font-semibold mb-4">Statements</h2> <h2 className="text-xl font-semibold mb-4">Statements</h2>
{isLoading ? ( {isLoading ? (
<p className="text-zinc-500">Loading...</p> <p className="text-zinc-500 text-sm">Loading...</p>
) : !statements?.length ? ( ) : !statements?.length ? (
<p className="text-zinc-500">No statements found</p> <p className="text-zinc-500 text-sm">No statements found</p>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="border border-zinc-700 rounded-xl overflow-hidden">
{statements.map((s) => ( <table className="w-full text-sm">
<div <thead>
key={s.id} <tr className="border-b border-zinc-800 bg-zinc-900">
className="border border-zinc-800 rounded-lg p-4 bg-zinc-900/50 hover:border-zinc-700 transition-colors" <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>
<div className="flex items-center justify-between mb-2"> <th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
<h3 className="font-medium">{s.bank_name}</h3> <th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / Updated</th>
<span className="text-xs text-zinc-500">{s.currency}</span> <th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody>
{statements.map((s) => {
const isCreditCard = Number(s.credit_limit) > 0 || s.payment_due_date != null;
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
const amountLabel = isCreditCard ? "due" : "balance";
return (
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
<td className="px-4 py-3">
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
{s.bank_name}
</div> </div>
{s.card_name && ( {s.card_name && (
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p> <div className="text-xs text-zinc-500 truncate max-w-[180px]">{s.card_name}</div>
)} )}
<div className="text-sm text-zinc-400 space-y-1"> </td>
<p>Account: {s.account_number}</p> <td className="px-4 py-3 text-zinc-400 font-mono text-xs">
<p> {s.account_number}
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)} </td>
</p> <td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
<p>Due: {formatDate(s.payment_due_date)}</p> {formatPeriod(s.billing_start_date, s.billing_end_date)}
</div> </td>
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between"> <td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
<div> {isCreditCard
<p className="text-lg font-semibold text-red-400"> ? formatDate(s.payment_due_date)
{formatCurrency(s.total_amount_due, s.currency)} : formatDate(s.billing_end_date)}
</p> </td>
<p className="text-xs text-zinc-500"> <td className="px-4 py-3 text-zinc-500 text-xs">
{s.transaction_count} transactions {s.currency}
</p> </td>
</div> <td className="px-4 py-3 text-right tabular-nums">
{displayAmount !== null && displayAmount !== undefined ? (
<span className={isCreditCard ? "text-red-400" : "text-zinc-300"}>
{formatAmount(displayAmount)}
</span>
) : (
<span className="text-zinc-600"></span>
)}
<div className="text-xs text-zinc-600">{amountLabel}</div>
</td>
<td className="px-4 py-3 text-right text-zinc-500">
{s.transaction_count}
</td>
<td className="px-4 py-3">
<Link <Link
href={`/transactions?statement_id=${s.id}`} href={`/transactions?statement_id=${s.id}`}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors" className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
> >
View View
</Link> </Link>
</div> </td>
</div> </tr>
))} );
})}
</tbody>
</table>
</div> </div>
)} )}
</div> </div>
+37 -2
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, Suspense } from "react";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks"; import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories"; import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal"; import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker"; import { TagPicker } from "@/components/tag-picker";
@@ -98,12 +99,24 @@ function InlineEdit({
} }
export default function TransactionsPage() { export default function TransactionsPage() {
return (
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
<TransactionsContent />
</Suspense>
);
}
function TransactionsContent() {
const searchParams = useSearchParams();
const initialStatementId = searchParams.get("statement_id") || "";
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
from: "", from: "",
to: "", to: "",
category: "", category: "",
bank_name: "", bank_name: "",
search: "", search: "",
statement_id: initialStatementId,
tag_id: "", tag_id: "",
sort_by: "transaction_date", sort_by: "transaction_date",
sort_dir: "desc", sort_dir: "desc",
@@ -118,6 +131,7 @@ export default function TransactionsPage() {
const { data, isLoading } = useTransactions(filters); const { data, isLoading } = useTransactions(filters);
const { data: banks } = useBanks(); const { data: banks } = useBanks();
const { data: tags } = useTags(); const { data: tags } = useTags();
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
const updateTxn = useUpdateTransaction(); const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction(); const bulkAction = useBulkAction();
@@ -161,6 +175,27 @@ export default function TransactionsPage() {
<div> <div>
<h2 className="text-xl font-semibold mb-4">Transactions</h2> <h2 className="text-xl font-semibold mb-4">Transactions</h2>
{/* Statement context banner */}
{filters.statement_id && statementInfo && (
<div className="flex items-center gap-3 mb-4 px-3 py-2 bg-indigo-950/40 border border-indigo-800/50 rounded-lg text-sm">
<span className="text-indigo-300 font-medium">{statementInfo.bank_name}</span>
{statementInfo.billing_start_date && statementInfo.billing_end_date && (
<span className="text-zinc-400">
{new Date(statementInfo.billing_start_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
{" "}
{new Date(statementInfo.billing_end_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
</span>
)}
<span className="text-zinc-500 text-xs">{statementInfo.transaction_count} transactions</span>
<button
onClick={() => setFilters((f) => ({ ...f, statement_id: "", offset: 0 }))}
className="ml-auto text-zinc-500 hover:text-zinc-200 text-xs px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
>
× Clear filter
</button>
</div>
)}
{/* Filter bar */} {/* Filter bar */}
<div className="flex flex-wrap gap-3 mb-4"> <div className="flex flex-wrap gap-3 mb-4">
<input <input