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:
+80
-38
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user