feat(finance): Phase 4 — Tags

- tags table: name, color; transaction_tags junction table
- GET/POST /api/tags; DELETE /api/tags/:id
- POST/DELETE /api/transactions/:id/tags for per-transaction tagging
- Bulk tag/untag via /api/transactions/bulk (action: tag/untag)
- Tags returned inline with transaction list via LATERAL join
- Tag filter on Transactions page
- Bulk "Tag as..." in bulk action bar
- Tag pills + "+" picker on each transaction row
- /tags page: create with color picker, list with counts, delete
This commit is contained in:
2026-03-08 16:28:03 +11:00
parent 35a5be97b0
commit 93450f7caa
11 changed files with 770 additions and 21 deletions
+124 -14
View File
@@ -1,5 +1,11 @@
import { queryRaw } from "./db";
export interface TagRow {
id: number;
name: string;
color: string;
}
export interface TransactionRow {
id: number;
statement_id: number;
@@ -23,6 +29,10 @@ export interface TransactionRow {
effective_merchant: string;
// statement context
bank_name: string;
owner_id: number;
owner_name: string;
// tags
tags: TagRow[];
}
export interface StatementRow {
@@ -31,6 +41,7 @@ export interface StatementRow {
card_name: string | null;
account_number: string;
account_type: string | null;
account_holder_name: string | null;
billing_start_date: string | null;
billing_end_date: string | null;
total_amount_due: number;
@@ -45,6 +56,8 @@ export interface StatementRow {
credit_limit: number | null;
currency: string;
tier_used: string | null;
owner_id: number;
owner_name: string;
created_at: string;
transaction_count: number;
}
@@ -56,16 +69,17 @@ interface TransactionFilters {
bank_name?: string;
search?: string;
statement_id?: string;
tag_id?: string;
sort_by?: string;
sort_dir?: string;
limit?: number;
offset?: number;
}
export async function getTransactions(filters: TransactionFilters) {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [`s.owner_id = $1`];
const params: unknown[] = [ownerId];
let paramIdx = 2;
if (filters.from) {
conditions.push(`t.transaction_date >= $${paramIdx++}`);
@@ -92,15 +106,18 @@ export async function getTransactions(filters: TransactionFilters) {
conditions.push(`t.statement_id = $${paramIdx++}`);
params.push(Number(filters.statement_id));
}
if (filters.tag_id) {
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
params.push(Number(filters.tag_id));
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const where = `WHERE ${conditions.join(" AND ")}`;
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
const limit = filters.limit || 50;
const offset = filters.offset || 0;
// Count query
const countSql = `
SELECT COUNT(*)::int as total
FROM transactions t
@@ -111,23 +128,35 @@ export async function getTransactions(filters: TransactionFilters) {
const countResult = await queryRaw<{ total: number }>(countSql, params);
const total = countResult[0]?.total || 0;
// Data query
const dataSql = `
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name
s.bank_name, s.owner_id,
p.name as owner_name,
txn_tags.tags
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
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
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = t.id
) txn_tags ON true
${where}
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
`;
params.push(limit, offset);
const data = await queryRaw<TransactionRow>(dataSql, params);
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
const data = raw.map((r) => ({
...r,
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
})) as TransactionRow[];
return { data, total, limit, offset };
}
@@ -138,31 +167,38 @@ export async function getTransactionById(id: number) {
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name
s.bank_name, s.owner_id,
p.name as owner_name
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
WHERE t.id = $1
`;
const rows = await queryRaw<TransactionRow>(sql, [id]);
return rows[0] || null;
}
export async function getStatements() {
export async function getStatements(ownerId: number) {
const sql = `
SELECT s.*,
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
p.name as owner_name
FROM statements s
LEFT JOIN participants p ON p.id = s.owner_id
WHERE s.owner_id = $1
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
`;
return queryRaw<StatementRow>(sql);
return queryRaw<StatementRow>(sql, [ownerId]);
}
export async function getStatementById(id: number) {
const sql = `
SELECT s.*,
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
p.name as owner_name
FROM statements s
LEFT JOIN participants p ON p.id = s.owner_id
WHERE s.id = $1
`;
const rows = await queryRaw<StatementRow>(sql, [id]);
@@ -185,3 +221,77 @@ export async function getBankNames() {
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
return queryRaw<{ bank_name: string }>(sql);
}
export interface ParticipantBalance {
id: number;
name: string;
total_owed: number;
unsettled_count: number;
}
export async function getParticipantBalances(ownerId: number) {
return queryRaw<ParticipantBalance>(`
SELECT p.id, p.name,
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
FROM participants p
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
LEFT JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE (s.owner_id = $1 OR s.id IS NULL)
GROUP BY p.id, p.name
ORDER BY p.name
`, [ownerId]);
}
export interface SharedTransactionRow extends TransactionRow {
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
}
export async function getTags() {
return queryRaw<TagRow & { transaction_count: number }>(`
SELECT tg.id, tg.name, tg.color,
COUNT(tt.transaction_id)::int as transaction_count
FROM tags tg
LEFT JOIN transaction_tags tt ON tt.tag_id = tg.id
GROUP BY tg.id
ORDER BY tg.name
`);
}
export async function getSharedTransactions(ownerId: number) {
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
p_owner.name as owner_name,
json_agg(json_build_object(
'split_id', ts.id,
'participant_id', ts.participant_id,
'name', p.name,
'share_percent', ts.share_percent,
'settled', ts.settled
) ORDER BY p.name) as split_data
FROM transactions t
JOIN transaction_splits ts ON ts.transaction_id = t.id
JOIN participants p ON p.id = ts.participant_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
WHERE s.owner_id = $1
AND EXISTS (
SELECT 1 FROM transaction_splits ts2
JOIN participants p2 ON p2.id = ts2.participant_id
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
)
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
ORDER BY t.transaction_date DESC
`, [ownerId]);
return rows.map((r) => ({
...r,
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
}));
}