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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user