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,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||||
|
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (transaction_id, tag_id)
|
||||||
|
);
|
||||||
@@ -15,3 +15,53 @@ model transaction_overrides {
|
|||||||
notes String?
|
notes String?
|
||||||
updated_at DateTime @default(now()) @updatedAt
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model participants {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
email String? @unique
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
splits transaction_splits[]
|
||||||
|
account_owner_mappings account_owner_mappings[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model account_owner_mappings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
bank_name String
|
||||||
|
account_number String
|
||||||
|
owner_id Int
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
owner participants @relation(fields: [owner_id], references: [id])
|
||||||
|
|
||||||
|
@@unique([bank_name, account_number])
|
||||||
|
}
|
||||||
|
|
||||||
|
model transaction_splits {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
transaction_id Int
|
||||||
|
participant_id Int
|
||||||
|
share_percent Decimal @db.Decimal(5, 2)
|
||||||
|
settled Boolean @default(false)
|
||||||
|
settled_at DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
participant participants @relation(fields: [participant_id], references: [id])
|
||||||
|
|
||||||
|
@@unique([transaction_id, participant_id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model tags {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
color String @default("#6366f1")
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
transaction_tags transaction_tags[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model transaction_tags {
|
||||||
|
transaction_id Int
|
||||||
|
tag_id Int
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
tag tags @relation(fields: [tag_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([transaction_id, tag_id])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma, queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.json();
|
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;
|
action: string;
|
||||||
ids: number[];
|
ids: number[];
|
||||||
category?: string;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
|
tag_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
@@ -38,5 +40,42 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ updated: ids.length });
|
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 });
|
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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Tags</h2>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
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 { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import { SplitModal } from "@/components/split-modal";
|
||||||
|
import { TagPicker } from "@/components/tag-picker";
|
||||||
|
|
||||||
function formatDate(d: string) {
|
function formatDate(d: string) {
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
return new Date(d).toLocaleDateString("en-AU", {
|
||||||
@@ -102,6 +104,7 @@ export default function TransactionsPage() {
|
|||||||
category: "",
|
category: "",
|
||||||
bank_name: "",
|
bank_name: "",
|
||||||
search: "",
|
search: "",
|
||||||
|
tag_id: "",
|
||||||
sort_by: "transaction_date",
|
sort_by: "transaction_date",
|
||||||
sort_dir: "desc",
|
sort_dir: "desc",
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@@ -109,9 +112,12 @@ export default function TransactionsPage() {
|
|||||||
});
|
});
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [bulkCategory, setBulkCategory] = useState("");
|
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, isLoading } = useTransactions(filters);
|
||||||
const { data: banks } = useBanks();
|
const { data: banks } = useBanks();
|
||||||
|
const { data: tags } = useTags();
|
||||||
const updateTxn = useUpdateTransaction();
|
const updateTxn = useUpdateTransaction();
|
||||||
const bulkAction = useBulkAction();
|
const bulkAction = useBulkAction();
|
||||||
|
|
||||||
@@ -196,6 +202,16 @@ export default function TransactionsPage() {
|
|||||||
<option key={b} value={b}>{b}</option>
|
<option key={b} value={b}>{b}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
@@ -224,6 +240,39 @@ export default function TransactionsPage() {
|
|||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setSelected(new Set())}
|
onClick={() => setSelected(new Set())}
|
||||||
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
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">Type</th>
|
||||||
<th className="p-2 text-left">Category</th>
|
<th className="p-2 text-left">Category</th>
|
||||||
<th className="p-2 text-left">Bank</th>
|
<th className="p-2 text-left">Bank</th>
|
||||||
|
<th className="p-2 text-left">Tags</th>
|
||||||
|
<th className="p-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{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 ? (
|
) : !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) => (
|
data.data.map((t) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -319,6 +370,29 @@ export default function TransactionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-zinc-400 whitespace-nowrap">{t.bank_name}</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>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -326,6 +400,17 @@ export default function TransactionsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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 */}
|
{/* Pagination */}
|
||||||
{data && data.total > filters.limit && (
|
{data && data.total > filters.limit && (
|
||||||
<div className="flex items-center justify-between mt-4">
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useTags, useAddTransactionTag, useRemoveTransactionTag } from "@/lib/hooks";
|
||||||
|
import type { TagRow } from "@/lib/queries";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transactionId: number;
|
||||||
|
currentTags: TagRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagPicker({ transactionId, currentTags }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const { data: allTags } = useTags();
|
||||||
|
const addTag = useAddTransactionTag();
|
||||||
|
const removeTag = useRemoveTransactionTag();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const currentIds = new Set(currentTags.map((t) => t.id));
|
||||||
|
|
||||||
|
const toggle = (tag: TagRow) => {
|
||||||
|
if (currentIds.has(tag.id)) {
|
||||||
|
removeTag.mutate({ transactionId, tagId: tag.id });
|
||||||
|
} else {
|
||||||
|
addTag.mutate({ transactionId, tagId: tag.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative inline-block">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="text-zinc-600 hover:text-zinc-300 transition-colors text-xs px-1"
|
||||||
|
title="Add tag"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl w-44 py-1">
|
||||||
|
{!allTags?.length ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-zinc-500">No tags yet — create on Tags page</p>
|
||||||
|
) : (
|
||||||
|
allTags.map((tag) => {
|
||||||
|
const active = currentIds.has(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggle(tag)}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<span className={active ? "text-white" : "text-zinc-400"}>{tag.name}</span>
|
||||||
|
{active && <span className="ml-auto text-zinc-500">✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+225
-1
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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 {
|
interface TransactionsResponse {
|
||||||
data: TransactionRow[];
|
data: TransactionRow[];
|
||||||
@@ -17,6 +18,7 @@ interface TransactionFilters {
|
|||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
statement_id?: string;
|
statement_id?: string;
|
||||||
|
tag_id?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -115,16 +117,238 @@ export function useBulkAction() {
|
|||||||
ids: number[];
|
ids: number[];
|
||||||
category?: string;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
|
tag_id?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await fetch("/api/transactions/bulk", {
|
const res = await fetch("/api/transactions/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
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();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
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"] });
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+124
-14
@@ -1,5 +1,11 @@
|
|||||||
import { queryRaw } from "./db";
|
import { queryRaw } from "./db";
|
||||||
|
|
||||||
|
export interface TagRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransactionRow {
|
export interface TransactionRow {
|
||||||
id: number;
|
id: number;
|
||||||
statement_id: number;
|
statement_id: number;
|
||||||
@@ -23,6 +29,10 @@ export interface TransactionRow {
|
|||||||
effective_merchant: string;
|
effective_merchant: string;
|
||||||
// statement context
|
// statement context
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
|
owner_id: number;
|
||||||
|
owner_name: string;
|
||||||
|
// tags
|
||||||
|
tags: TagRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatementRow {
|
export interface StatementRow {
|
||||||
@@ -31,6 +41,7 @@ export interface StatementRow {
|
|||||||
card_name: string | null;
|
card_name: string | null;
|
||||||
account_number: string;
|
account_number: string;
|
||||||
account_type: string | null;
|
account_type: string | null;
|
||||||
|
account_holder_name: string | null;
|
||||||
billing_start_date: string | null;
|
billing_start_date: string | null;
|
||||||
billing_end_date: string | null;
|
billing_end_date: string | null;
|
||||||
total_amount_due: number;
|
total_amount_due: number;
|
||||||
@@ -45,6 +56,8 @@ export interface StatementRow {
|
|||||||
credit_limit: number | null;
|
credit_limit: number | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
tier_used: string | null;
|
tier_used: string | null;
|
||||||
|
owner_id: number;
|
||||||
|
owner_name: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
transaction_count: number;
|
transaction_count: number;
|
||||||
}
|
}
|
||||||
@@ -56,16 +69,17 @@ interface TransactionFilters {
|
|||||||
bank_name?: string;
|
bank_name?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
statement_id?: string;
|
statement_id?: string;
|
||||||
|
tag_id?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactions(filters: TransactionFilters) {
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [`s.owner_id = $1`];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [ownerId];
|
||||||
let paramIdx = 1;
|
let paramIdx = 2;
|
||||||
|
|
||||||
if (filters.from) {
|
if (filters.from) {
|
||||||
conditions.push(`t.transaction_date >= $${paramIdx++}`);
|
conditions.push(`t.transaction_date >= $${paramIdx++}`);
|
||||||
@@ -92,15 +106,18 @@ export async function getTransactions(filters: TransactionFilters) {
|
|||||||
conditions.push(`t.statement_id = $${paramIdx++}`);
|
conditions.push(`t.statement_id = $${paramIdx++}`);
|
||||||
params.push(Number(filters.statement_id));
|
params.push(Number(filters.statement_id));
|
||||||
}
|
}
|
||||||
|
if (filters.tag_id) {
|
||||||
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
|
||||||
|
params.push(Number(filters.tag_id));
|
||||||
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
|
||||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
||||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||||
const limit = filters.limit || 50;
|
const limit = filters.limit || 50;
|
||||||
const offset = filters.offset || 0;
|
const offset = filters.offset || 0;
|
||||||
|
|
||||||
// Count query
|
|
||||||
const countSql = `
|
const countSql = `
|
||||||
SELECT COUNT(*)::int as total
|
SELECT COUNT(*)::int as total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
@@ -111,23 +128,35 @@ export async function getTransactions(filters: TransactionFilters) {
|
|||||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||||
const total = countResult[0]?.total || 0;
|
const total = countResult[0]?.total || 0;
|
||||||
|
|
||||||
// Data query
|
|
||||||
const dataSql = `
|
const dataSql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name
|
s.bank_name, s.owner_id,
|
||||||
|
p.name as owner_name,
|
||||||
|
txn_tags.tags
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN participants p ON p.id = s.owner_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||||
|
FROM transaction_tags tt
|
||||||
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tt.transaction_id = t.id
|
||||||
|
) txn_tags ON true
|
||||||
${where}
|
${where}
|
||||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||||
`;
|
`;
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
|
|
||||||
const data = await queryRaw<TransactionRow>(dataSql, params);
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
|
||||||
|
const data = raw.map((r) => ({
|
||||||
|
...r,
|
||||||
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||||
|
})) as TransactionRow[];
|
||||||
|
|
||||||
return { data, total, limit, offset };
|
return { data, total, limit, offset };
|
||||||
}
|
}
|
||||||
@@ -138,31 +167,38 @@ export async function getTransactionById(id: number) {
|
|||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name
|
s.bank_name, s.owner_id,
|
||||||
|
p.name as owner_name
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN participants p ON p.id = s.owner_id
|
||||||
WHERE t.id = $1
|
WHERE t.id = $1
|
||||||
`;
|
`;
|
||||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatements() {
|
export async function getStatements(ownerId: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
|
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
|
||||||
|
p.name as owner_name
|
||||||
FROM statements s
|
FROM statements s
|
||||||
|
LEFT JOIN participants p ON p.id = s.owner_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
|
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
|
||||||
`;
|
`;
|
||||||
return queryRaw<StatementRow>(sql);
|
return queryRaw<StatementRow>(sql, [ownerId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatementById(id: number) {
|
export async function getStatementById(id: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
|
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
|
||||||
|
p.name as owner_name
|
||||||
FROM statements s
|
FROM statements s
|
||||||
|
LEFT JOIN participants p ON p.id = s.owner_id
|
||||||
WHERE s.id = $1
|
WHERE s.id = $1
|
||||||
`;
|
`;
|
||||||
const rows = await queryRaw<StatementRow>(sql, [id]);
|
const rows = await queryRaw<StatementRow>(sql, [id]);
|
||||||
@@ -185,3 +221,77 @@ export async function getBankNames() {
|
|||||||
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
||||||
return queryRaw<{ bank_name: string }>(sql);
|
return queryRaw<{ bank_name: string }>(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParticipantBalance {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
total_owed: number;
|
||||||
|
unsettled_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getParticipantBalances(ownerId: number) {
|
||||||
|
return queryRaw<ParticipantBalance>(`
|
||||||
|
SELECT p.id, p.name,
|
||||||
|
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
|
||||||
|
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
|
||||||
|
FROM participants p
|
||||||
|
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
|
||||||
|
LEFT JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE (s.owner_id = $1 OR s.id IS NULL)
|
||||||
|
GROUP BY p.id, p.name
|
||||||
|
ORDER BY p.name
|
||||||
|
`, [ownerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedTransactionRow extends TransactionRow {
|
||||||
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTags() {
|
||||||
|
return queryRaw<TagRow & { transaction_count: number }>(`
|
||||||
|
SELECT tg.id, tg.name, tg.color,
|
||||||
|
COUNT(tt.transaction_id)::int as transaction_count
|
||||||
|
FROM tags tg
|
||||||
|
LEFT JOIN transaction_tags tt ON tt.tag_id = tg.id
|
||||||
|
GROUP BY tg.id
|
||||||
|
ORDER BY tg.name
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSharedTransactions(ownerId: number) {
|
||||||
|
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
||||||
|
SELECT t.*,
|
||||||
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||||
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
|
s.bank_name, s.owner_id,
|
||||||
|
p_owner.name as owner_name,
|
||||||
|
json_agg(json_build_object(
|
||||||
|
'split_id', ts.id,
|
||||||
|
'participant_id', ts.participant_id,
|
||||||
|
'name', p.name,
|
||||||
|
'share_percent', ts.share_percent,
|
||||||
|
'settled', ts.settled
|
||||||
|
) ORDER BY p.name) as split_data
|
||||||
|
FROM transactions t
|
||||||
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM transaction_splits ts2
|
||||||
|
JOIN participants p2 ON p2.id = ts2.participant_id
|
||||||
|
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
|
||||||
|
)
|
||||||
|
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
||||||
|
ORDER BY t.transaction_date DESC
|
||||||
|
`, [ownerId]);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user