From 0c1f88ed9cfd43016d971d5d7756fdf85f9379e3 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 10 May 2026 16:04:54 +1000 Subject: [PATCH] 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 --- src/app/api/transactions/route.ts | 1 + src/app/shared/page.tsx | 51 ++++++++++++++++++++++++++----- src/app/statements/page.tsx | 22 ++++++++----- src/app/transactions/page.tsx | 33 +++++++++++++++----- src/lib/hooks.ts | 1 + src/lib/queries.ts | 14 +++++++-- 6 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 15401a2..5045a06 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -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); diff --git a/src/app/shared/page.tsx b/src/app/shared/page.tsx index 0a64b98..2c6eb4b 100644 --- a/src/app/shared/page.tsx +++ b/src/app/shared/page.tsx @@ -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([]); + const [sortCol, setSortCol] = useState("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 ; + return {sortDir === "desc" ? "↓" : "↑"}; + } const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds); const { data: me } = useCurrentUser(); const [addingParticipant, setAddingParticipant] = useState(false); @@ -353,7 +373,7 @@ export default function SharedPage() { {/* Transaction list */} -
+

Split Transactions

@@ -364,12 +384,28 @@ export default function SharedPage() { No split transactions yet. Use the Split button on any transaction.

) : ( - +
- - - + + + + @@ -380,7 +416,8 @@ export default function SharedPage() { return ( - +
DateDescriptionAmount toggleSort("transaction_date")} + > + Date + toggleSort("created_at")} + > + Imported + Description toggleSort("amount")} + > + Amount + Splits
{formatDate(tx.transaction_date)} + {formatDate(tx.created_at)}

{tx.effective_merchant || tx.description}

{tx.effective_merchant && (

{tx.description}

diff --git a/src/app/statements/page.tsx b/src/app/statements/page.tsx index a52bb5c..a9bb928 100644 --- a/src/app/statements/page.tsx +++ b/src/app/statements/page.tsx @@ -134,12 +134,12 @@ export default function StatementsPage() { ) : !filtered.length ? (

{hasFilters ? "No statements match filters" : "No statements found"}

) : ( -
- +
+
- + @@ -147,7 +147,7 @@ export default function StatementsPage() { - + @@ -164,13 +164,19 @@ export default function StatementsPage() { return ( - -
#BankBank Account Period Due / EndAmount Txns Owner
{idx + 1} -
+
+
{s.bank_name}
{s.card_name && ( -
{s.card_name}
+
{s.card_name}
)} + + View → +
{s.account_number} @@ -211,7 +217,7 @@ export default function StatementsPage() { {s.owner_name} )} + ([]); @@ -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 && (
@@ -677,6 +678,15 @@ function TransactionsContent() { onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))} placeholder="All Types" /> +
{/* Bulk action bar */} @@ -749,10 +759,10 @@ function TransactionsContent() { {/* Table */}
- +
- + {isLoading ? ( - + ) : !data?.data.length ? ( - + ) : ( data.data.map((t) => ( - - + +
+ toggleSort("transaction_date")} > Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")} toggleSort("created_at")} + > + Imported {filters.sort_by === "created_at" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")} + Description Merchant
Loading...
Loading...
No transactions found
No transactions found
+ {formatDate(t.transaction_date)}{formatDate(t.transaction_date)}{formatDate(t.created_at)}

{t.description}

{t.notes && ( diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index b1e166b..b025a1b 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -26,6 +26,7 @@ interface TransactionFilters { offset?: number; amount_min?: number; amount_max?: number; + has_split?: string; } function buildParams(filters: TransactionFilters): string { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index dae815e..b259d4f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -82,6 +82,7 @@ interface TransactionFilters { offset?: number; amount_min?: number; amount_max?: number; + has_split?: string; } 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++}`); 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 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 limit = filters.limit || 50; 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(t.owner_id, s.owner_id) as owner_id, p.name as owner_name, + COALESCE(src.created_at, t.created_at) as created_at, txn_tags.tags, txn_splits.splits FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.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 transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL 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 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(t.owner_id, s.owner_id) as owner_id, p_owner.name as owner_name, + COALESCE(src.created_at, t.created_at) as created_at, json_agg(json_build_object( 'split_id', ts.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 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 transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL WHERE ( ( COALESCE(t.owner_id, s.owner_id) = $1 @@ -575,7 +585,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[], ) ) ${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 `, params);