feat(transactions): add imported date column, split filter, and sortable columns

- Show created_at as "Imported" column in transactions and shared views
- For reconciled transactions, show original CSV import date (not statement processing date) via LEFT JOIN on reconciled_with_id
- Add has_split filter (all/split only/unsplit only) to transactions page
- Transactions table: sortable by imported date; split filter dropdown
- Shared table: client-side sort by date, imported, and amount
This commit is contained in:
2026-05-10 16:04:54 +10:00
parent 4a49add277
commit 0c1f88ed9c
6 changed files with 97 additions and 25 deletions
+1
View File
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest) {
offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined,
});
return NextResponse.json(result);
+44 -7
View File
@@ -270,10 +270,30 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
}
// ── Main page ─────────────────────────────────────────────────────────────────
type SortCol = "transaction_date" | "created_at" | "amount";
export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]);
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const realTagIds = tagIds.filter((id) => id !== "untagged");
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
const transactions = [...rawTransactions].sort((a, b) => {
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
return sortDir === "desc" ? bv - av : av - bv;
});
function toggleSort(col: SortCol) {
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
else { setSortCol(col); setSortDir("desc"); }
}
function SortIcon({ col }: { col: SortCol }) {
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5"></span>;
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: me } = useCurrentUser();
const [addingParticipant, setAddingParticipant] = useState(false);
@@ -353,7 +373,7 @@ export default function SharedPage() {
</div>
{/* Transaction list */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
<div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Split Transactions</h3>
</div>
@@ -364,12 +384,28 @@ export default function SharedPage() {
No split transactions yet. Use the Split button on any transaction.
</p>
) : (
<table className="w-full text-sm">
<table className="w-full text-sm min-w-[520px]">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Date</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
<th
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("transaction_date")}
>
Date <SortIcon col="transaction_date" />
</th>
<th
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("created_at")}
>
Imported <SortIcon col="created_at" />
</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
<th
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
onClick={() => toggleSort("amount")}
>
Amount <SortIcon col="amount" />
</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
<th className="px-4 py-2"></th>
</tr>
@@ -380,7 +416,8 @@ export default function SharedPage() {
return (
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
<td className="px-4 py-3 max-w-xs">
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
{tx.effective_merchant && (
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
+14 -8
View File
@@ -134,12 +134,12 @@ export default function StatementsPage() {
) : !filtered.length ? (
<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">
<table className="w-full text-sm">
<div className="border border-zinc-700 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[800px]">
<thead>
<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 sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">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">Period</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
@@ -147,7 +147,7 @@ export default function StatementsPage() {
<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="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5 hidden sm:table-cell"></th>
</tr>
</thead>
<tbody>
@@ -164,13 +164,19 @@ export default function StatementsPage() {
return (
<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">
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
<td className="px-4 py-3 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
{s.bank_name}
</div>
{s.card_name && (
<div className="text-xs text-zinc-500 truncate max-w-[180px]">{s.card_name}</div>
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
)}
<Link
href={`/transactions?statement_id=${s.id}`}
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
>
View
</Link>
</td>
<td className="px-4 py-3 text-zinc-400 font-mono text-xs">
{s.account_number}
@@ -211,7 +217,7 @@ export default function StatementsPage() {
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
)}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3 hidden sm:table-cell">
<Link
href={`/transactions?statement_id=${s.id}`}
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
+25 -8
View File
@@ -476,6 +476,7 @@ function TransactionsContent() {
offset: 0,
amount_min: undefined as number | undefined,
amount_max: undefined as number | undefined,
has_split: "" as string,
});
const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
@@ -610,7 +611,7 @@ function TransactionsContent() {
value={queryInput}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search… or >500 <=1500 200-800"
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-full sm:w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
/>
{queryTokens.length > 0 && (
<div className="flex flex-wrap gap-1">
@@ -677,6 +678,15 @@ function TransactionsContent() {
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
placeholder="All Types"
/>
<select
value={filters.has_split}
onChange={(e) => setFilters((f) => ({ ...f, has_split: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
>
<option value="">All Splits</option>
<option value="yes">Split only</option>
<option value="no">Unsplit only</option>
</select>
</div>
{/* Bulk action bar */}
@@ -749,10 +759,10 @@ function TransactionsContent() {
{/* Table */}
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
<table className="w-full text-sm">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/50">
<th className="p-2 w-8">
<th className="p-2 w-8 sticky left-0 z-10 bg-zinc-900">
<input
type="checkbox"
checked={data?.data.length ? selected.size === data.data.length : false}
@@ -761,11 +771,17 @@ function TransactionsContent() {
/>
</th>
<th
className="p-2 text-left cursor-pointer hover:text-white"
className="p-2 text-left cursor-pointer hover:text-white sticky left-8 z-10 bg-zinc-900 border-r border-zinc-800/80 whitespace-nowrap"
onClick={() => toggleSort("transaction_date")}
>
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
</th>
<th
className="p-2 text-left text-zinc-500 cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("created_at")}
>
Imported {filters.sort_by === "created_at" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
</th>
<th className="p-2 text-left">Description</th>
<th className="p-2 text-left">Merchant</th>
<th
@@ -783,9 +799,9 @@ function TransactionsContent() {
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">Loading...</td></tr>
) : !data?.data.length ? (
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
) : (
data.data.map((t) => (
<tr
@@ -794,7 +810,7 @@ function TransactionsContent() {
selected.has(t.id) ? "bg-zinc-800/40" : ""
}`}
>
<td className="p-2">
<td className={`p-2 sticky left-0 z-10 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>
<input
type="checkbox"
checked={selected.has(t.id)}
@@ -802,7 +818,8 @@ function TransactionsContent() {
className="accent-blue-600"
/>
</td>
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
<td className="p-2 max-w-xs">
<p className="truncate" title={t.description}>{t.description}</p>
{t.notes && (