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:
@@ -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