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:
+225
-1
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TransactionRow, StatementRow } from "./queries";
|
||||
import type { TransactionRow, StatementRow, TagRow } from "./queries";
|
||||
import type { CurrentUser } from "./auth";
|
||||
|
||||
interface TransactionsResponse {
|
||||
data: TransactionRow[];
|
||||
@@ -17,6 +18,7 @@ interface TransactionFilters {
|
||||
bank_name?: string;
|
||||
search?: string;
|
||||
statement_id?: string;
|
||||
tag_id?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
limit?: number;
|
||||
@@ -115,16 +117,238 @@ export function useBulkAction() {
|
||||
ids: number[];
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
tag_id?: number;
|
||||
}) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Bulk action failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
if (variables.action === "split") {
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
}
|
||||
if (variables.action === "tag" || variables.action === "untag") {
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticipants() {
|
||||
return useQuery<{ id: number; name: string; created_at: string }[]>({
|
||||
queryKey: ["participants"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/participants");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useParticipantBalances() {
|
||||
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
|
||||
queryKey: ["participant-balances"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/participants/balances");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedTransactions() {
|
||||
return useQuery({
|
||||
queryKey: ["shared-transactions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/shared-transactions");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransactionSplits(transactionId: number) {
|
||||
return useQuery({
|
||||
queryKey: ["splits", transactionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/splits`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSplits() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transactionId,
|
||||
splits,
|
||||
}: {
|
||||
transactionId: number;
|
||||
splits: { participant_id: number; share_percent: number }[];
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/splits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ splits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to set splits");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSettleSplits() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => {
|
||||
const res = await fetch("/api/splits/settle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery<CurrentUser>({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/me");
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStatement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, owner_id }: { id: number; owner_id: number }) => {
|
||||
const res = await fetch(`/api/statements/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ owner_id }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["statements"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQuery<(TagRow & { transaction_count: number })[]>({
|
||||
queryKey: ["tags"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/tags");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, color }: { name: string; color?: string }) => {
|
||||
const res = await fetch("/api/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to create tag");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["tags"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/tags/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTransactionTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => {
|
||||
await fetch(`/api/transactions/${transactionId}/tags`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_id: tagId }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveTransactionTag() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ transactionId, tagId }: { transactionId: number; tagId: number }) => {
|
||||
await fetch(`/api/transactions/${transactionId}/tags`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_id: tagId }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateParticipant() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, email }: { name: string; email?: string }) => {
|
||||
const res = await fetch("/api/participants", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to create participant");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["participants"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user