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 });
}
+103 -1
View File
@@ -1,8 +1,110 @@
"use client";
import { useState } from "react";
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
const PRESET_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#3b82f6", // blue
"#6b7280", // gray
];
export default function TagsPage() {
const { data: tags, isLoading } = useTags();
const createTag = useCreateTag();
const deleteTag = useDeleteTag();
const [name, setName] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const [error, setError] = useState("");
const handleCreate = async () => {
if (!name.trim()) return;
setError("");
try {
await createTag.mutateAsync({ name: name.trim(), color });
setName("");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to create tag");
}
};
return (
<div>
<h2 className="text-xl font-semibold mb-4">Tags</h2>
<p className="text-zinc-500">Coming soon - tag transactions for trips, projects, and more.</p>
{/* Create form */}
<div className="mb-6 p-4 bg-zinc-900/50 border border-zinc-800 rounded-lg">
<h3 className="text-sm font-medium mb-3">New Tag</h3>
<div className="flex items-center gap-3 flex-wrap">
<input
type="text"
placeholder="Tag name..."
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
/>
<div className="flex items-center gap-1.5">
{PRESET_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-5 h-5 rounded-full transition-transform ${color === c ? "scale-125 ring-2 ring-white ring-offset-1 ring-offset-zinc-900" : "hover:scale-110"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<button
disabled={!name.trim() || createTag.isPending}
onClick={handleCreate}
className="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 rounded text-sm"
>
Add Tag
</button>
</div>
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
</div>
{/* Tags list */}
{isLoading ? (
<p className="text-zinc-500">Loading...</p>
) : !tags?.length ? (
<p className="text-zinc-500">No tags yet. Create one above.</p>
) : (
<div className="space-y-1">
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg"
>
<div className="flex items-center gap-3">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm font-medium">{tag.name}</span>
<span className="text-xs text-zinc-500">
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
</span>
</div>
<button
onClick={() => deleteTag.mutate(tag.id)}
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
>
Delete
</button>
</div>
))}
</div>
)}
</div>
);
}
+88 -3
View File
@@ -1,8 +1,10 @@
"use client";
import { useState, useCallback } from "react";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction } from "@/lib/hooks";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", {
@@ -102,6 +104,7 @@ export default function TransactionsPage() {
category: "",
bank_name: "",
search: "",
tag_id: "",
sort_by: "transaction_date",
sort_dir: "desc",
limit: 50,
@@ -109,9 +112,12 @@ export default function TransactionsPage() {
});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
const { data, isLoading } = useTransactions(filters);
const { data: banks } = useBanks();
const { data: tags } = useTags();
const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction();
@@ -196,6 +202,16 @@ export default function TransactionsPage() {
<option key={b} value={b}>{b}</option>
))}
</select>
<select
value={filters.tag_id}
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Tags</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
{/* Bulk action bar */}
@@ -224,6 +240,39 @@ export default function TransactionsPage() {
>
Apply
</button>
<button
onClick={() =>
setSplitModal({
transactionIds: Array.from(selected),
description: `${selected.size} selected transaction${selected.size !== 1 ? "s" : ""}`,
})
}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 rounded text-sm"
>
Split...
</button>
<select
value={bulkTagId}
onChange={(e) => setBulkTagId(e.target.value)}
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
>
<option value="">Tag as...</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<button
disabled={!bulkTagId || bulkAction.isPending}
onClick={() => {
bulkAction.mutate(
{ action: "tag", ids: Array.from(selected), tag_id: Number(bulkTagId) },
{ onSuccess: () => { setSelected(new Set()); setBulkTagId(""); } }
);
}}
className="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 rounded text-sm"
>
Tag
</button>
<button
onClick={() => setSelected(new Set())}
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
@@ -263,13 +312,15 @@ export default function TransactionsPage() {
<th className="p-2 text-left">Type</th>
<th className="p-2 text-left">Category</th>
<th className="p-2 text-left">Bank</th>
<th className="p-2 text-left">Tags</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="p-8 text-center text-zinc-500">Loading...</td></tr>
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
) : !data?.data.length ? (
<tr><td colSpan={8} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
) : (
data.data.map((t) => (
<tr
@@ -319,6 +370,29 @@ export default function TransactionsPage() {
</div>
</td>
<td className="p-2 text-zinc-400 whitespace-nowrap">{t.bank_name}</td>
<td className="p-2">
<div className="flex items-center gap-1 flex-wrap">
{t.tags?.map((tag) => (
<span
key={tag.id}
className="px-1.5 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: tag.color + "99" }}
>
{tag.name}
</span>
))}
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
</div>
</td>
<td className="p-2">
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Split this transaction"
>
Split
</button>
</td>
</tr>
))
)}
@@ -326,6 +400,17 @@ export default function TransactionsPage() {
</table>
</div>
{/* Split modal */}
{splitModal && (
<SplitModal
transactionId={splitModal.transactionId}
transactionIds={splitModal.transactionIds}
amount={splitModal.amount}
description={splitModal.description}
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
/>
)}
{/* Pagination */}
{data && data.total > filters.limit && (
<div className="flex items-center justify-between mt-4">