Compare commits
2 Commits
35a5be97b0
...
925ed114a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 925ed114a6 | |||
| 93450f7caa |
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
name TEXT NOT NULL,
|
||||
conditions JSONB NOT NULL DEFAULT '[]',
|
||||
actions JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id);
|
||||
@@ -15,3 +15,77 @@ model transaction_overrides {
|
||||
notes String?
|
||||
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])
|
||||
}
|
||||
|
||||
model rules {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
conditions Json @default("[]")
|
||||
actions Json @default("{}")
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
model budgets {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
category String
|
||||
month DateTime @db.Date
|
||||
amount_limit Decimal @db.Decimal(10, 2)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([owner_id, category, month])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw, prisma } from "@/lib/db";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const existing = await queryRaw<{ id: number }>(
|
||||
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const updated = await prisma.rules.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||
...(body.actions !== undefined && { actions: body.actions }),
|
||||
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||
...(body.priority !== undefined && { priority: body.priority }),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await queryRaw<{ id: number }>(
|
||||
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await prisma.rules.delete({ where: { id: Number(id) } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
|
||||
interface Condition {
|
||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
|
||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
set_category?: string;
|
||||
add_tag_ids?: number[];
|
||||
set_merchant?: string;
|
||||
}
|
||||
|
||||
interface TxFields {
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
description: string;
|
||||
bank_name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
if (cond.field === "amount") {
|
||||
const numVal = Number(tx.amount);
|
||||
const numCond = Number(cond.value);
|
||||
switch (cond.operator) {
|
||||
case "equals": return numVal === numCond;
|
||||
case "not_equals": return numVal !== numCond;
|
||||
case "gt": return numVal > numCond;
|
||||
case "lt": return numVal < numCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
let fieldVal: string;
|
||||
switch (cond.field) {
|
||||
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||
case "description": fieldVal = tx.description || ""; break;
|
||||
case "category": fieldVal = tx.effective_category || ""; break;
|
||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
const strVal = fieldVal.toLowerCase();
|
||||
const strCond = cond.value.toLowerCase();
|
||||
switch (cond.operator) {
|
||||
case "contains": return strVal.includes(strCond);
|
||||
case "equals": return strVal === strCond;
|
||||
case "starts_with": return strVal.startsWith(strCond);
|
||||
case "not_equals": return strVal !== strCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||
|
||||
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||
|
||||
let matched = 0;
|
||||
const affectedIds = new Set<number>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const conditions = (typeof rule.conditions === "string"
|
||||
? JSON.parse(rule.conditions)
|
||||
: rule.conditions) as Condition[];
|
||||
const actions = (typeof rule.actions === "string"
|
||||
? JSON.parse(rule.actions)
|
||||
: rule.actions) as Actions;
|
||||
|
||||
for (const tx of transactions) {
|
||||
const allMatch =
|
||||
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||
if (!allMatch) continue;
|
||||
|
||||
matched++;
|
||||
affectedIds.add(tx.id);
|
||||
|
||||
if (actions.set_category || actions.set_merchant) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (transaction_id) DO UPDATE SET
|
||||
category_override = COALESCE($2, transaction_overrides.category_override),
|
||||
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
||||
updated_at = NOW()`,
|
||||
[tx.id, actions.set_category || null, actions.set_merchant || null]
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.add_tag_ids?.length) {
|
||||
for (const tagId of actions.add_tag_ids) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[tx.id, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw, prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const rows = await queryRaw<{
|
||||
id: number;
|
||||
name: string;
|
||||
conditions: unknown;
|
||||
actions: unknown;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
||||
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||
[user.id]
|
||||
);
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
const rule = await prisma.rules.create({
|
||||
data: {
|
||||
owner_id: user.id,
|
||||
name,
|
||||
conditions: conditions ?? [],
|
||||
actions: actions ?? {},
|
||||
enabled,
|
||||
priority,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(rule, { status: 201 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+323
-2
@@ -1,8 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const FIELDS = [
|
||||
{ value: "merchant_normalized", label: "Merchant" },
|
||||
{ value: "description", label: "Description" },
|
||||
{ value: "category", label: "Category" },
|
||||
{ value: "bank_name", label: "Bank" },
|
||||
{ value: "amount", label: "Amount" },
|
||||
] as const;
|
||||
|
||||
const TEXT_OPS = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "starts_with", label: "starts with" },
|
||||
{ value: "not_equals", label: "not equals" },
|
||||
];
|
||||
const AMOUNT_OPS = [
|
||||
{ value: "equals", label: "=" },
|
||||
{ value: "not_equals", label: "≠" },
|
||||
{ value: "gt", label: ">" },
|
||||
{ value: "lt", label: "<" },
|
||||
];
|
||||
|
||||
type Condition = { field: string; operator: string; value: string };
|
||||
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||
|
||||
function humanCondition(c: Condition): string {
|
||||
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
|
||||
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
||||
return `${fieldLabel} ${opText} "${c.value}"`;
|
||||
}
|
||||
|
||||
function humanAction(a: Actions, tagNames: Map<number, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`);
|
||||
if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`);
|
||||
if (a.add_tag_ids?.length) {
|
||||
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
||||
parts.push(`add tags: ${names}`);
|
||||
}
|
||||
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
|
||||
}
|
||||
|
||||
export default function RulesPage() {
|
||||
const { data: rules = [], isLoading } = useRules();
|
||||
const { data: tags = [] } = useTags();
|
||||
const createRule = useCreateRule();
|
||||
const updateRule = useUpdateRule();
|
||||
const deleteRule = useDeleteRule();
|
||||
const applyRules = useApplyRules();
|
||||
|
||||
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||
const [actions, setActions] = useState<Actions>({});
|
||||
const [priority, setPriority] = useState(0);
|
||||
|
||||
function addCondition() {
|
||||
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
|
||||
}
|
||||
|
||||
function updateCondition(i: number, patch: Partial<Condition>) {
|
||||
setConditions(conditions.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
||||
}
|
||||
|
||||
function removeCondition(i: number) {
|
||||
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
|
||||
setName("");
|
||||
setConditions([]);
|
||||
setActions({});
|
||||
setPriority(0);
|
||||
setShowForm(false);
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const result = await applyRules.mutateAsync();
|
||||
setApplyResult(result);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Rules</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applyRules.isPending}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{showForm ? "Cancel" : "New Rule"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{applyResult && (
|
||||
<div className="p-4 bg-emerald-900/30 border border-emerald-700 rounded-lg text-sm">
|
||||
Applied: <strong>{applyResult.matched}</strong> condition matches across{" "}
|
||||
<strong>{applyResult.transactions_affected}</strong> transactions.
|
||||
<button onClick={() => setApplyResult(null)} className="ml-4 text-zinc-400 hover:text-white">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-semibold text-sm text-zinc-300">New Rule</h3>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Rules</h2>
|
||||
<p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Tag Woolworths as groceries"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs text-zinc-500">Conditions (ALL must match)</label>
|
||||
<button type="button" onClick={addCondition} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||
+ Add condition
|
||||
</button>
|
||||
</div>
|
||||
{conditions.map((cond, i) => {
|
||||
const isAmount = cond.field === "amount";
|
||||
const ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
|
||||
return (
|
||||
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||
<select
|
||||
value={cond.field}
|
||||
onChange={(e) =>
|
||||
updateCondition(i, {
|
||||
field: e.target.value,
|
||||
operator: e.target.value === "amount" ? "equals" : "contains",
|
||||
})
|
||||
}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{FIELDS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(i, { operator: e.target.value })}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{ops.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(i)}
|
||||
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{conditions.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No conditions — rule will match ALL transactions.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Set Category (optional)</label>
|
||||
<select
|
||||
value={actions.set_category || ""}
|
||||
onChange={(e) => setActions({ ...actions, set_category: e.target.value || undefined })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— no change —</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{formatCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Set Merchant (optional)</label>
|
||||
<input
|
||||
value={actions.set_merchant || ""}
|
||||
onChange={(e) => setActions({ ...actions, set_merchant: e.target.value || undefined })}
|
||||
placeholder="Normalized name"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Add Tags (optional)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const selected = (actions.add_tag_ids || []).includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const ids = actions.add_tag_ids || [];
|
||||
setActions({
|
||||
...actions,
|
||||
add_tag_ids: selected ? ids.filter((id) => id !== tag.id) : [...ids, tag.id],
|
||||
});
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
||||
selected ? "border-transparent text-white" : "border-zinc-700 text-zinc-400"
|
||||
}`}
|
||||
style={selected ? { backgroundColor: tag.color } : {}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{tags.length === 0 && <p className="text-xs text-zinc-600">No tags created yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRule.isPending}
|
||||
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createRule.isPending ? "Creating..." : "Create Rule"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||
) : rules.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No rules yet. Create one to auto-classify transactions.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => {
|
||||
const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
|
||||
const acts =
|
||||
rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {};
|
||||
return (
|
||||
<div key={rule.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||
rule.enabled ? "bg-indigo-600" : "bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
|
||||
rule.enabled ? "translate-x-4" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
||||
}}
|
||||
className="text-zinc-500 hover:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+304
-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,317 @@ 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Rules ---
|
||||
|
||||
export interface RuleRow {
|
||||
id: number;
|
||||
name: string;
|
||||
conditions: { field: string; operator: string; value: string }[];
|
||||
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useRules() {
|
||||
return useQuery<RuleRow[]>({
|
||||
queryKey: ["rules"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/rules");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Omit<RuleRow, "id" | "created_at">) => {
|
||||
const res = await fetch("/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create rule");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: Partial<RuleRow> & { id: number }) => {
|
||||
const res = await fetch(`/api/rules/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update rule");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/rules/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyRules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/rules/apply", { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed to apply rules");
|
||||
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["rules"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+124
-14
@@ -1,5 +1,11 @@
|
||||
import { queryRaw } from "./db";
|
||||
|
||||
export interface TagRow {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface TransactionRow {
|
||||
id: number;
|
||||
statement_id: number;
|
||||
@@ -23,6 +29,10 @@ export interface TransactionRow {
|
||||
effective_merchant: string;
|
||||
// statement context
|
||||
bank_name: string;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
// tags
|
||||
tags: TagRow[];
|
||||
}
|
||||
|
||||
export interface StatementRow {
|
||||
@@ -31,6 +41,7 @@ export interface StatementRow {
|
||||
card_name: string | null;
|
||||
account_number: string;
|
||||
account_type: string | null;
|
||||
account_holder_name: string | null;
|
||||
billing_start_date: string | null;
|
||||
billing_end_date: string | null;
|
||||
total_amount_due: number;
|
||||
@@ -45,6 +56,8 @@ export interface StatementRow {
|
||||
credit_limit: number | null;
|
||||
currency: string;
|
||||
tier_used: string | null;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
created_at: string;
|
||||
transaction_count: number;
|
||||
}
|
||||
@@ -56,16 +69,17 @@ interface TransactionFilters {
|
||||
bank_name?: string;
|
||||
search?: string;
|
||||
statement_id?: string;
|
||||
tag_id?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function getTransactions(filters: TransactionFilters) {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||
const conditions: string[] = [`s.owner_id = $1`];
|
||||
const params: unknown[] = [ownerId];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (filters.from) {
|
||||
conditions.push(`t.transaction_date >= $${paramIdx++}`);
|
||||
@@ -92,15 +106,18 @@ export async function getTransactions(filters: TransactionFilters) {
|
||||
conditions.push(`t.statement_id = $${paramIdx++}`);
|
||||
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 sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
// Count query
|
||||
const countSql = `
|
||||
SELECT COUNT(*)::int as total
|
||||
FROM transactions t
|
||||
@@ -111,23 +128,35 @@ export async function getTransactions(filters: TransactionFilters) {
|
||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
// Data query
|
||||
const dataSql = `
|
||||
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.bank_name, s.owner_id,
|
||||
p.name as owner_name,
|
||||
txn_tags.tags
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.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}
|
||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||
`;
|
||||
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 };
|
||||
}
|
||||
@@ -138,31 +167,38 @@ export async function getTransactionById(id: number) {
|
||||
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.bank_name, s.owner_id,
|
||||
p.name as owner_name
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p ON p.id = s.owner_id
|
||||
WHERE t.id = $1
|
||||
`;
|
||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getStatements() {
|
||||
export async function getStatements(ownerId: number) {
|
||||
const sql = `
|
||||
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
|
||||
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
|
||||
`;
|
||||
return queryRaw<StatementRow>(sql);
|
||||
return queryRaw<StatementRow>(sql, [ownerId]);
|
||||
}
|
||||
|
||||
export async function getStatementById(id: number) {
|
||||
const sql = `
|
||||
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
|
||||
LEFT JOIN participants p ON p.id = s.owner_id
|
||||
WHERE s.id = $1
|
||||
`;
|
||||
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`;
|
||||
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