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 });
|
||||
}
|
||||
|
||||
+103
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user