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, offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : 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, amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined,
}); });
return NextResponse.json(result); return NextResponse.json(result);
+44 -7
View File
@@ -270,10 +270,30 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
} }
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
type SortCol = "transaction_date" | "created_at" | "amount";
export default function SharedPage() { export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]); 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 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: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: me } = useCurrentUser(); const { data: me } = useCurrentUser();
const [addingParticipant, setAddingParticipant] = useState(false); const [addingParticipant, setAddingParticipant] = useState(false);
@@ -353,7 +373,7 @@ export default function SharedPage() {
</div> </div>
{/* Transaction list */} {/* 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"> <div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Split Transactions</h3> <h3 className="text-sm font-medium">Split Transactions</h3>
</div> </div>
@@ -364,12 +384,28 @@ export default function SharedPage() {
No split transactions yet. Use the Split button on any transaction. No split transactions yet. Use the Split button on any transaction.
</p> </p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[520px]">
<thead> <thead>
<tr className="border-b border-zinc-800"> <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
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th> className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th> 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="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
<th className="px-4 py-2"></th> <th className="px-4 py-2"></th>
</tr> </tr>
@@ -380,7 +416,8 @@ export default function SharedPage() {
return ( return (
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30"> <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 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> <p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
{tx.effective_merchant && ( {tx.effective_merchant && (
<p className="text-xs text-zinc-500 break-words">{tx.description}</p> <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 ? ( ) : !filtered.length ? (
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "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-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[800px]">
<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-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">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>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</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">Amount</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</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="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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -164,13 +164,19 @@ 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-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 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}> <div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
{s.bank_name} {s.bank_name}
</div> </div>
{s.card_name && ( {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>
<td className="px-4 py-3 text-zinc-400 font-mono text-xs"> <td className="px-4 py-3 text-zinc-400 font-mono text-xs">
{s.account_number} {s.account_number}
@@ -211,7 +217,7 @@ export default function StatementsPage() {
<span className="text-zinc-600 text-xs">{s.owner_name}</span> <span className="text-zinc-600 text-xs">{s.owner_name}</span>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3 hidden sm:table-cell">
<Link <Link
href={`/transactions?statement_id=${s.id}`} 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" 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, offset: 0,
amount_min: undefined as number | undefined, amount_min: undefined as number | undefined,
amount_max: undefined as number | undefined, amount_max: undefined as number | undefined,
has_split: "" as string,
}); });
const [queryInput, setQueryInput] = useState(""); const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]); const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
@@ -610,7 +611,7 @@ function TransactionsContent() {
value={queryInput} value={queryInput}
onChange={(e) => handleQueryChange(e.target.value)} onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search… or >500 <=1500 200-800" 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 && ( {queryTokens.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -677,6 +678,15 @@ function TransactionsContent() {
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))} onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
placeholder="All Types" 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> </div>
{/* Bulk action bar */} {/* Bulk action bar */}
@@ -749,10 +759,10 @@ function TransactionsContent() {
{/* Table */} {/* Table */}
<div className="overflow-x-auto border border-zinc-800 rounded-lg"> <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> <thead>
<tr className="border-b border-zinc-800 bg-zinc-900/50"> <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 <input
type="checkbox" type="checkbox"
checked={data?.data.length ? selected.size === data.data.length : false} checked={data?.data.length ? selected.size === data.data.length : false}
@@ -761,11 +771,17 @@ function TransactionsContent() {
/> />
</th> </th>
<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")} onClick={() => toggleSort("transaction_date")}
> >
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")} Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
</th> </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">Description</th>
<th className="p-2 text-left">Merchant</th> <th className="p-2 text-left">Merchant</th>
<th <th
@@ -783,9 +799,9 @@ function TransactionsContent() {
</thead> </thead>
<tbody> <tbody>
{isLoading ? ( {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 ? ( ) : !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) => ( data.data.map((t) => (
<tr <tr
@@ -794,7 +810,7 @@ function TransactionsContent() {
selected.has(t.id) ? "bg-zinc-800/40" : "" 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 <input
type="checkbox" type="checkbox"
checked={selected.has(t.id)} checked={selected.has(t.id)}
@@ -802,7 +818,8 @@ function TransactionsContent() {
className="accent-blue-600" className="accent-blue-600"
/> />
</td> </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"> <td className="p-2 max-w-xs">
<p className="truncate" title={t.description}>{t.description}</p> <p className="truncate" title={t.description}>{t.description}</p>
{t.notes && ( {t.notes && (
+1
View File
@@ -26,6 +26,7 @@ interface TransactionFilters {
offset?: number; offset?: number;
amount_min?: number; amount_min?: number;
amount_max?: number; amount_max?: number;
has_split?: string;
} }
function buildParams(filters: TransactionFilters): string { function buildParams(filters: TransactionFilters): string {
+12 -2
View File
@@ -82,6 +82,7 @@ interface TransactionFilters {
offset?: number; offset?: number;
amount_min?: number; amount_min?: number;
amount_max?: number; amount_max?: number;
has_split?: string;
} }
export async function getTransactions(ownerId: number, filters: TransactionFilters) { export async function getTransactions(ownerId: number, filters: TransactionFilters) {
@@ -148,10 +149,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
conditions.push(`t.amount <= $${paramIdx++}`); conditions.push(`t.amount <= $${paramIdx++}`);
params.push(filters.amount_max); params.push(filters.amount_max);
} }
if (filters.has_split === "yes") {
conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
} else if (filters.has_split === "no") {
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
}
const where = `WHERE ${conditions.join(" AND ")}`; const where = `WHERE ${conditions.join(" AND ")}`;
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date"; const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "t.transaction_date";
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC"; const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
const limit = filters.limit || 50; const limit = filters.limit || 50;
const offset = filters.offset || 0; const offset = filters.offset || 0;
@@ -174,12 +180,14 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
COALESCE(s.bank_name, 'Manual') as bank_name, COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id, COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name, p.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at,
txn_tags.tags, txn_tags.tags,
txn_splits.splits txn_splits.splits
FROM transactions t FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id) LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
FROM transaction_tags tt FROM transaction_tags tt
@@ -552,6 +560,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
COALESCE(s.bank_name, 'Manual') as bank_name, COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id, COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name, p_owner.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at,
json_agg(json_build_object( json_agg(json_build_object(
'split_id', ts.id, 'split_id', ts.id,
'participant_id', ts.participant_id, 'participant_id', ts.participant_id,
@@ -565,6 +574,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id) LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
WHERE ( WHERE (
( (
COALESCE(t.owner_id, s.owner_id) = $1 COALESCE(t.owner_id, s.owner_id) = $1
@@ -575,7 +585,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
) )
) )
${tagClause} ${tagClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
ORDER BY t.transaction_date DESC ORDER BY t.transaction_date DESC
`, params); `, params);