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
+8
View File
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
return NextResponse.json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getTags } from "@/lib/queries";
import { queryRaw } from "@/lib/db";
export async function GET() {
const tags = await getTags();
return NextResponse.json(tags);
}
export async function POST(req: NextRequest) {
const { name, color } = await req.json();
if (!name?.trim()) {
return NextResponse.json({ error: "name required" }, { status: 400 });
}
const rows = await queryRaw<{ id: number; name: string; color: string }>(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name.trim(), color || "#6366f1"]
);
return NextResponse.json(rows[0], { status: 201 });
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { tag_id } = await req.json();
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[Number(id), Number(tag_id)]
);
return NextResponse.json({ ok: true });
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { tag_id } = await req.json();
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id = $2`,
[Number(id), Number(tag_id)]
);
return NextResponse.json({ ok: true });
}
+41 -2
View File
@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { prisma, queryRaw } from "@/lib/db";
export async function POST(req: NextRequest) {
const body = await req.json();
const { action, ids, category, merchant_normalized } = body as {
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
action: string;
ids: number[];
category?: string;
merchant_normalized?: string;
splits?: { participant_id: number; share_percent: number }[];
tag_id?: number;
};
if (!ids || !Array.isArray(ids) || ids.length === 0) {
@@ -38,5 +40,42 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ updated: ids.length });
}
if (action === "split" && Array.isArray(splits) && splits.length > 0) {
const total = splits.reduce((s, x) => s + x.share_percent, 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json({ error: "Shares must sum to 100%" }, { status: 400 });
}
await prisma.$transaction(
ids.flatMap((id) => [
prisma.transaction_splits.deleteMany({ where: { transaction_id: id } }),
prisma.transaction_splits.createMany({
data: splits.map((s) => ({
transaction_id: id,
participant_id: s.participant_id,
share_percent: s.share_percent,
})),
}),
])
);
return NextResponse.json({ updated: ids.length });
}
if ((action === "tag" || action === "untag") && tag_id) {
if (action === "tag") {
for (const id of ids) {
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[id, tag_id]
);
}
} else {
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = ANY($1::int[]) AND tag_id = $2`,
[ids, tag_id]
);
}
return NextResponse.json({ updated: ids.length });
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}