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:
+225
-1
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TransactionRow, StatementRow } from "./queries";
|
||||
import type { TransactionRow, StatementRow, TagRow } from "./queries";
|
||||
import type { CurrentUser } from "./auth";
|
||||
|
||||
interface TransactionsResponse {
|
||||
data: TransactionRow[];
|
||||
@@ -17,6 +18,7 @@ interface TransactionFilters {
|
||||
bank_name?: string;
|
||||
search?: string;
|
||||
statement_id?: string;
|
||||
tag_id?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
limit?: number;
|
||||
@@ -115,16 +117,238 @@ export function useBulkAction() {
|
||||
ids: number[];
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
tag_id?: number;
|
||||
}) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Bulk action failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
if (variables.action === "split") {
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
}
|
||||
if (variables.action === "tag" || variables.action === "untag") {
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticipants() {
|
||||
return useQuery<{ id: number; name: string; created_at: string }[]>({
|
||||
queryKey: ["participants"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/participants");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticipantBalances() {
|
||||
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
|
||||
queryKey: ["participant-balances"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/participants/balances");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedTransactions() {
|
||||
return useQuery({
|
||||
queryKey: ["shared-transactions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/shared-transactions");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransactionSplits(transactionId: number) {
|
||||
return useQuery({
|
||||
queryKey: ["splits", transactionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/splits`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSplits() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transactionId,
|
||||
splits,
|
||||
}: {
|
||||
transactionId: number;
|
||||
splits: { participant_id: number; share_percent: number }[];
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/splits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ splits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to set splits");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSettleSplits() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => {
|
||||
const res = await fetch("/api/splits/settle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery<CurrentUser>({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/me");
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStatement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, owner_id }: { id: number; owner_id: number }) => {
|
||||
const res = await fetch(`/api/statements/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ owner_id }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["statements"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQuery<(TagRow & { transaction_count: number })[]>({
|
||||
queryKey: ["tags"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/tags");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, color }: { name: string; color?: string }) => {
|
||||
const res = await fetch("/api/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to create tag");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["tags"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/tags/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTransactionTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => {
|
||||
await fetch(`/api/transactions/${transactionId}/tags`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_id: tagId }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveTransactionTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => {
|
||||
await fetch(`/api/transactions/${transactionId}/tags`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_id: tagId }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateParticipant() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, email }: { name: string; email?: string }) => {
|
||||
const res = await fetch("/api/participants", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to create participant");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["participants"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+124
-14
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user