Compare commits
6 Commits
0985c38be8
...
8076d1a949
| Author | SHA1 | Date | |
|---|---|---|---|
| 8076d1a949 | |||
| aeaca84cc7 | |||
| 278e57354c | |||
| 9f90d8726f | |||
| 859043f5a5 | |||
| fc22a61a43 |
@@ -0,0 +1,118 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Personal finance tracker. Bank statements are ingested via an N8N workflow (in the smarthome repo at `docker/automation/workflows/cc-statement-processor-paperless.json`) that sends PDFs to Gemini 2.5 Flash for extraction, then inserts into PostgreSQL.
|
||||
|
||||
- **App**: Next.js 16 App Router, TypeScript, Tailwind CSS
|
||||
- **DB**: PostgreSQL container `postgres-personal`, database `personal`, user `personal`
|
||||
- **Auth**: `X-Forwarded-User` header (email) set by Traefik → `participants.email`. In dev/fallback: participant id=1 ("Me")
|
||||
- **Runs at**: port 3000 inside container, exposed on host port 4100, proxied at `https://finance.bosecamp.com`
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Build and deploy (from smarthome repo root)
|
||||
docker compose --env-file docker/common.env --env-file docker/finance/.env \
|
||||
-f docker/finance/docker-compose.yml up -d --build
|
||||
|
||||
# IMPORTANT: docker restart does NOT pick up a new image — always use the compose command above
|
||||
|
||||
# DB access
|
||||
docker exec postgres-personal psql -U personal -d personal
|
||||
|
||||
# View logs
|
||||
docker logs finance -f
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/db.ts` | `queryRaw<T>()` — the only DB query function; uses `pg` directly |
|
||||
| `src/lib/queries.ts` | All SQL query functions (no ORM); import `queryRaw` from `@/lib/db` |
|
||||
| `src/lib/hooks.ts` | TanStack Query hooks for all API calls |
|
||||
| `src/lib/auth.ts` | `getCurrentUser()` — reads `X-Forwarded-User` header |
|
||||
| `src/lib/categories.ts` | Canonical category list (`CATEGORIES` array + `formatCategory()`) |
|
||||
| `src/app/api/*/route.ts` | API route handlers |
|
||||
| `src/components/` | Shared UI components |
|
||||
|
||||
### Data Flow
|
||||
|
||||
- All queries in `src/lib/queries.ts` use raw SQL via `queryRaw` from `src/lib/db.ts`
|
||||
- API routes call query functions and return `NextResponse.json()`
|
||||
- Frontend uses hooks from `src/lib/hooks.ts` (TanStack Query) — never fetches directly
|
||||
- Auth is always checked first in every API route: `const user = await getCurrentUser(req)`
|
||||
|
||||
### Owner Scoping
|
||||
|
||||
All data is scoped by `owner_id`. The effective owner of a transaction is:
|
||||
```sql
|
||||
COALESCE(t.owner_id, s.owner_id)
|
||||
```
|
||||
- Statement-linked transactions: owner comes from `statements.owner_id`
|
||||
- Manual transactions: `statement_id IS NULL`, owner stored directly in `transactions.owner_id`
|
||||
|
||||
The effective merchant and category always prefer overrides:
|
||||
```sql
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merchant
|
||||
COALESCE(o.category_override, t.category) -- category
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
```bash
|
||||
# Schema inspection
|
||||
docker exec postgres-personal psql -U personal -d personal -c "\d transactions"
|
||||
|
||||
# Apply a migration SQL file
|
||||
docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<name>/migration.sql
|
||||
```
|
||||
|
||||
### Key Tables
|
||||
|
||||
- `statements` — one row per billing period per bank account
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry)
|
||||
- `transaction_overrides` — user corrections to AI-extracted data (category, merchant, notes)
|
||||
- `transaction_splits` — shared expense tracking (participant, share_percent, settled)
|
||||
- `transaction_tags` — many-to-many join to `tags`
|
||||
- `rules` — auto-categorisation rules (JSONB conditions + actions)
|
||||
- `participants` — people; `id=1` is "Me" (the primary user)
|
||||
- `account_owner_mappings` — persists bank+account → owner assignments
|
||||
|
||||
### Rules System
|
||||
|
||||
Conditions are AND-evaluated. Fields: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`. Operators: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`. Actions: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`.
|
||||
|
||||
`contains` and `equals` operators are case-insensitive (both sides `.toLowerCase()`).
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Adding a new API route
|
||||
|
||||
1. Create `src/app/api/<resource>/route.ts`
|
||||
2. Always call `getCurrentUser(req)` first; return 403 if null
|
||||
3. Write SQL in `src/lib/queries.ts` using `queryRaw`
|
||||
4. Add a TanStack Query hook in `src/lib/hooks.ts`
|
||||
|
||||
### Adding a new condition field to rules
|
||||
|
||||
Two files only:
|
||||
- `src/app/api/rules/apply/route.ts` — add to `Condition.field` union, `TxFields` interface, and `evaluateCondition()` switch
|
||||
- `src/app/rules/page.tsx` — add to `FIELDS` array; add special rendering if needed (e.g. enum dropdown for `transaction_type`)
|
||||
|
||||
### Modifying queries
|
||||
|
||||
- All JOINs to `statements` must be `LEFT JOIN` (manual transactions have no statement)
|
||||
- Owner filter pattern: `WHERE COALESCE(t.owner_id, s.owner_id) = $1`
|
||||
- Bank name pattern: `COALESCE(s.bank_name, 'Manual') as bank_name`
|
||||
|
||||
## Known Gaps / TODOs
|
||||
|
||||
See `README.md` → **Known Gaps / TODOs** for full details.
|
||||
|
||||
**Payment provider tracking**: `merchant_normalized` currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add `payment_provider` column, update Gemini prompt to extract it separately, backfill from `merchant_name` patterns, surface in UI filters.
|
||||
@@ -58,7 +58,8 @@ One row per line item within a statement. Cascade-deleted when the parent statem
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `statement_id` | int FK → `statements` | Parent statement |
|
||||
| `statement_id` | int FK → `statements` (nullable) | Parent statement; NULL for manually-entered transactions |
|
||||
| `owner_id` | int FK → `participants` (nullable) | Owner for manual transactions (no statement); statement-linked transactions derive owner from `statements.owner_id` |
|
||||
| `transaction_date` | date | Date of transaction |
|
||||
| `description` | text | Raw description from the statement |
|
||||
| `amount` | numeric | Original amount in statement currency |
|
||||
@@ -164,8 +165,9 @@ Saved auto-categorisation rules. Applied in bulk via the Rules page.
|
||||
| `enabled` | bool | |
|
||||
| `priority` | int | Higher priority rules run first |
|
||||
|
||||
**Condition fields**: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`
|
||||
**Condition fields**: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`
|
||||
**Condition operators**: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`
|
||||
**Actions**: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`
|
||||
|
||||
---
|
||||
|
||||
@@ -289,6 +291,26 @@ docker exec postgres-personal psql -U personal -d personal \
|
||||
| `0007_cashflow` | `amount_aud`, `exchange_rate_to_aud` on transactions; `exchange_rate_to_aud` on statements |
|
||||
|
||||
> `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file).
|
||||
> `owner_id` on transactions and `statement_id` made nullable were applied directly (March 2026) to support manual transaction entry without a fake statement.
|
||||
|
||||
---
|
||||
|
||||
## Known Gaps / TODOs
|
||||
|
||||
### Payment Provider tracking
|
||||
|
||||
Currently `merchant_normalized` conflates the *payment provider* with the *merchant*. Transactions processed through PayPal, Afterpay, Zip, Alipay, etc. end up with the provider as the merchant when the real merchant can't be recovered.
|
||||
|
||||
**What's been done so far:**
|
||||
- PayPal entries that embed the merchant name (e.g. `PAYPAL *BUNNINGSGRO`) were cleaned up — the real merchant was extracted during the March 2026 consolidation pass.
|
||||
- Pure PayPal/Afterpay/Zip entries where the merchant is unrecoverable were left as-is.
|
||||
- A one-time SQL consolidation pass normalised ~50 merchant name variant groups (March 2026).
|
||||
|
||||
**Remaining work:**
|
||||
1. **DB migration**: `ALTER TABLE transactions ADD COLUMN payment_provider text` and same on `transaction_overrides`.
|
||||
2. **Gemini prompt**: add `payment_provider` to the `responseSchema` so the AI extracts it separately (`"PayPal"`, `"Afterpay"`, `"Zip"`, `null`, etc.) — the raw bank description usually contains enough signal.
|
||||
3. **Backfill**: for existing transactions, derive `payment_provider` from `merchant_name` patterns (`PAYPAL *`, `AFTERPAY`, `ZIP/ZIPPAY`, `BPAY`).
|
||||
4. **App**: surface `payment_provider` as a filter/column in the transactions view; exclude payment providers from merchant analytics so they don't inflate the merchant list.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "transaction_overrides" ADD COLUMN "my_share_percent" DECIMAL(5,2);
|
||||
@@ -13,6 +13,7 @@ model transaction_overrides {
|
||||
merchant_normalized String?
|
||||
category_override String?
|
||||
notes String?
|
||||
my_share_percent Decimal? @db.Decimal(5, 2)
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,12 @@ export async function GET(req: NextRequest) {
|
||||
t.transaction_type,
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END::numeric(12,2) AS my_amount,
|
||||
s.bank_name
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
WHERE s.owner_id = $1
|
||||
|
||||
@@ -32,9 +32,9 @@ export async function GET(
|
||||
t.amount_aud,
|
||||
CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||
-(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
-(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
ELSE
|
||||
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
END::numeric(10,2) as my_amount,
|
||||
t.transaction_type,
|
||||
COALESCE(o.category_override, t.category) as category,
|
||||
|
||||
@@ -3,12 +3,11 @@ import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
// Split-adjusted amount helper (positive for spend, negative for refunds)
|
||||
const MY_AMOUNT = `CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END`;
|
||||
const SPEND_EXPR = `
|
||||
CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||
-(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
ELSE
|
||||
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${MY_AMOUNT})
|
||||
ELSE (${MY_AMOUNT})
|
||||
END
|
||||
`;
|
||||
|
||||
@@ -44,18 +43,18 @@ export async function GET(req: NextRequest) {
|
||||
COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||
CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END
|
||||
${MY_AMOUNT}
|
||||
ELSE 0 END
|
||||
), 0)::numeric(12,2) as gross_spend,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
|
||||
CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END
|
||||
${MY_AMOUNT}
|
||||
ELSE 0 END
|
||||
), 0)::numeric(12,2) as total_refunds,
|
||||
SUM(${SPEND_EXPR})::numeric(12,2) as net_spend,
|
||||
AVG(
|
||||
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
|
||||
CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END
|
||||
${MY_AMOUNT}
|
||||
END
|
||||
)::numeric(10,2) as avg_debit,
|
||||
MIN(t.transaction_date)::text as first_seen,
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function GET(req: NextRequest) {
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END
|
||||
)::numeric(12,2) as total_spent,
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest) {
|
||||
t.transaction_date,
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END AS my_amount
|
||||
FROM transactions t
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
if (type === "banks") {
|
||||
const banks = await getBankNames();
|
||||
return NextResponse.json(banks.map((b) => b.bank_name));
|
||||
return NextResponse.json(banks);
|
||||
}
|
||||
|
||||
if (!search) return NextResponse.json([]);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
interface SnapshotEntry {
|
||||
transaction_id: number;
|
||||
had_override: boolean;
|
||||
prev_category_override: string | null;
|
||||
prev_merchant_normalized: string | null;
|
||||
prev_tag_ids: number[];
|
||||
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||
}
|
||||
|
||||
export async function POST(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 runId = Number(id);
|
||||
|
||||
const rows = await queryRaw<{
|
||||
id: number;
|
||||
owner_id: number;
|
||||
reverted_at: string | null;
|
||||
snapshot: unknown;
|
||||
}>(
|
||||
`SELECT id, owner_id, reverted_at, snapshot FROM rule_apply_runs WHERE id = $1`,
|
||||
[runId]
|
||||
);
|
||||
|
||||
if (!rows.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||
const run = rows[0];
|
||||
if (run.owner_id !== user.id) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
if (run.reverted_at) return NextResponse.json({ error: "Already reverted" }, { status: 409 });
|
||||
|
||||
const snapshot = (typeof run.snapshot === "string"
|
||||
? JSON.parse(run.snapshot)
|
||||
: run.snapshot) as SnapshotEntry[];
|
||||
|
||||
for (const entry of snapshot) {
|
||||
const txId = entry.transaction_id;
|
||||
|
||||
// Restore overrides
|
||||
if (entry.had_override) {
|
||||
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 = $2,
|
||||
merchant_normalized = $3,
|
||||
updated_at = NOW()`,
|
||||
[txId, entry.prev_category_override, entry.prev_merchant_normalized]
|
||||
);
|
||||
} else {
|
||||
// No override existed before — remove any that were created
|
||||
await queryRaw(
|
||||
`DELETE FROM transaction_overrides WHERE transaction_id = $1
|
||||
AND category_override IS NULL AND merchant_normalized IS NULL`,
|
||||
[txId]
|
||||
);
|
||||
// If override row exists but was only partially set by this run, clear those fields
|
||||
await queryRaw(
|
||||
`UPDATE transaction_overrides SET
|
||||
category_override = NULL,
|
||||
merchant_normalized = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE transaction_id = $1`,
|
||||
[txId]
|
||||
);
|
||||
}
|
||||
|
||||
// Restore tags: remove any that weren't there before, don't touch pre-existing ones
|
||||
const prevTagIds = entry.prev_tag_ids;
|
||||
if (prevTagIds.length > 0) {
|
||||
await queryRaw(
|
||||
`DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id != ALL($2::int[])`,
|
||||
[txId, prevTagIds]
|
||||
);
|
||||
} else {
|
||||
// No tags existed before — remove all tags (they were all added by this run)
|
||||
// Note: this only removes tags on transactions that matched this run
|
||||
await queryRaw(`DELETE FROM transaction_tags WHERE transaction_id = $1`, [txId]);
|
||||
}
|
||||
|
||||
// Restore splits
|
||||
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [txId]);
|
||||
for (const s of entry.prev_splits) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||
[txId, s.participant_id, s.share_percent, s.settled]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRaw(
|
||||
`UPDATE rule_apply_runs SET reverted_at = NOW() WHERE id = $1`,
|
||||
[runId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ reverted: snapshot.length });
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { queryRaw } from "@/lib/db";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
|
||||
interface Condition {
|
||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
|
||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type";
|
||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
@@ -21,12 +21,22 @@ interface Actions {
|
||||
apply_split?: SplitEntry[];
|
||||
}
|
||||
|
||||
interface SnapshotEntry {
|
||||
transaction_id: number;
|
||||
had_override: boolean;
|
||||
prev_category_override: string | null;
|
||||
prev_merchant_normalized: string | null;
|
||||
prev_tag_ids: number[];
|
||||
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||
}
|
||||
|
||||
interface TxFields {
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
description: string;
|
||||
bank_name: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
}
|
||||
|
||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
@@ -48,6 +58,7 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
case "description": fieldVal = tx.description || ""; break;
|
||||
case "category": fieldVal = tx.effective_category || ""; break;
|
||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
@@ -62,6 +73,26 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const runs = await queryRaw<{
|
||||
id: number;
|
||||
applied_at: string;
|
||||
split_from: string | null;
|
||||
matched: number;
|
||||
transactions_affected: number;
|
||||
reverted_at: string | null;
|
||||
}>(
|
||||
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
|
||||
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
return NextResponse.json(runs);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
@@ -73,22 +104,81 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||
|
||||
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null };
|
||||
const splitFrom = body.splitFrom || null;
|
||||
|
||||
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||
|
||||
// --- Pre-pass: find all transactions that will match any rule ---
|
||||
const parsedRules = rules.map((r) => ({
|
||||
conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[],
|
||||
actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions,
|
||||
}));
|
||||
|
||||
const matchedIds = new Set<number>();
|
||||
for (const tx of transactions) {
|
||||
for (const { conditions } of parsedRules) {
|
||||
if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) {
|
||||
matchedIds.add(tx.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Capture before-state for all matched transactions (batched) ---
|
||||
const snapshot: SnapshotEntry[] = [];
|
||||
if (matchedIds.size > 0) {
|
||||
const ids = Array.from(matchedIds);
|
||||
const idList = ids.join(",");
|
||||
|
||||
const overrides = await queryRaw<{ transaction_id: number; category_override: string | null; merchant_normalized: string | null }>(
|
||||
`SELECT transaction_id, category_override, merchant_normalized FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
|
||||
[ids]
|
||||
);
|
||||
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
|
||||
|
||||
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
|
||||
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
|
||||
[ids]
|
||||
);
|
||||
const tagMap = new Map<number, number[]>();
|
||||
for (const row of tagRows) {
|
||||
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
|
||||
tagMap.get(row.transaction_id)!.push(row.tag_id);
|
||||
}
|
||||
|
||||
const splitRows = await queryRaw<{ transaction_id: number; participant_id: number; share_percent: number; settled: boolean }>(
|
||||
`SELECT transaction_id, participant_id, share_percent, settled FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
|
||||
[ids]
|
||||
);
|
||||
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
||||
for (const row of splitRows) {
|
||||
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
|
||||
splitMap.get(row.transaction_id)!.push({ participant_id: row.participant_id, share_percent: row.share_percent, settled: row.settled });
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
const ov = overrideMap.get(id);
|
||||
snapshot.push({
|
||||
transaction_id: id,
|
||||
had_override: !!ov,
|
||||
prev_category_override: ov?.category_override ?? null,
|
||||
prev_merchant_normalized: ov?.merchant_normalized ?? null,
|
||||
prev_tag_ids: tagMap.get(id) ?? [],
|
||||
prev_splits: splitMap.get(id) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
void idList; // suppress unused warning
|
||||
}
|
||||
|
||||
// --- Apply rules ---
|
||||
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 { conditions, actions } of parsedRules) {
|
||||
for (const tx of transactions) {
|
||||
const allMatch =
|
||||
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||
const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||
if (!allMatch) continue;
|
||||
|
||||
matched++;
|
||||
@@ -116,7 +206,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (actions.apply_split?.length) {
|
||||
// Delete existing splits then insert new ones
|
||||
if (splitFrom && tx.transaction_date < splitFrom) continue;
|
||||
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]);
|
||||
for (const s of actions.apply_split) {
|
||||
await queryRaw(
|
||||
@@ -129,5 +219,12 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
|
||||
// --- Save run record ---
|
||||
const run = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
|
||||
);
|
||||
|
||||
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||
}
|
||||
|
||||
@@ -23,13 +23,44 @@ export async function PATCH(
|
||||
const transactionId = Number(id);
|
||||
const body = await req.json();
|
||||
|
||||
const { category, merchant_normalized, notes, transaction_type } = body as {
|
||||
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date } = body as {
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
transaction_type?: string;
|
||||
my_share_percent?: number | null;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
};
|
||||
|
||||
if (my_share_percent !== undefined && my_share_percent !== null) {
|
||||
if (typeof my_share_percent !== "number" || my_share_percent <= 0 || my_share_percent > 100) {
|
||||
return NextResponse.json({ error: "my_share_percent must be between 1 and 100" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Direct field edits — only allowed for manual transactions (statement_id IS NULL)
|
||||
const directFields = [description, amount, transaction_date].filter((v) => v !== undefined);
|
||||
if (directFields.length > 0) {
|
||||
const txRows = await queryRaw<{ statement_id: number | null }>(
|
||||
`SELECT statement_id FROM transactions WHERE id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
if (!txRows[0]?.statement_id) {
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (description !== undefined) { setClauses.push(`description = $${idx++}`); params.push(description); }
|
||||
if (amount !== undefined) { setClauses.push(`amount = $${idx++}`); params.push(amount); }
|
||||
if (transaction_date !== undefined) { setClauses.push(`transaction_date = $${idx++}`); params.push(transaction_date); }
|
||||
if (setClauses.length) {
|
||||
params.push(transactionId);
|
||||
await queryRaw(`UPDATE transactions SET ${setClauses.join(", ")} WHERE id = $${idx}`, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// transaction_type is a direct correction on the transactions table
|
||||
if (transaction_type !== undefined) {
|
||||
if (!VALID_TYPES.includes(transaction_type)) {
|
||||
@@ -41,8 +72,8 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
|
||||
// category/merchant/notes go through the overrides table
|
||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined;
|
||||
// category/merchant/notes/my_share_percent go through the overrides table
|
||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined;
|
||||
if (!hasOverride) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -51,6 +82,7 @@ export async function PATCH(
|
||||
if (category !== undefined) data.category_override = category;
|
||||
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
||||
if (notes !== undefined) data.notes = notes;
|
||||
if (my_share_percent !== undefined) data.my_share_percent = my_share_percent;
|
||||
|
||||
const override = await prisma.transaction_overrides.upsert({
|
||||
where: { transaction_id: transactionId },
|
||||
@@ -60,6 +92,7 @@ export async function PATCH(
|
||||
category_override: category || null,
|
||||
merchant_normalized: merchant_normalized || null,
|
||||
notes: notes || null,
|
||||
my_share_percent: my_share_percent != null ? String(my_share_percent) : null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
@@ -23,3 +24,54 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const body = await req.json() as {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type?: string;
|
||||
merchant_normalized?: string;
|
||||
category?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
};
|
||||
|
||||
if (!body.date || !body.description || body.amount == null) {
|
||||
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
|
||||
const txRows = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, row_index)
|
||||
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, (
|
||||
SELECT COALESCE(MAX(row_index), -1) + 1 FROM transactions WHERE owner_id = $1 AND statement_id IS NULL
|
||||
))
|
||||
RETURNING id`,
|
||||
[
|
||||
user.id,
|
||||
body.date,
|
||||
body.description,
|
||||
body.amount,
|
||||
body.transaction_type || "debit",
|
||||
body.merchant_normalized || null,
|
||||
body.category || null,
|
||||
]
|
||||
);
|
||||
const transactionId = txRows[0].id;
|
||||
|
||||
// Insert splits if provided
|
||||
if (body.splits?.length) {
|
||||
for (const s of body.splits) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[transactionId, s.participant_id, s.share_percent]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: transactionId }, { status: 201 });
|
||||
}
|
||||
|
||||
+272
-40
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend,
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const COMMITTED_CATEGORIES = new Set(["rent", "utilities", "insurance", "subscriptions"]);
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
|
||||
}
|
||||
function fmtTx(amount: number, type: string) {
|
||||
const formatted = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(amount);
|
||||
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
||||
}
|
||||
function fmtExact(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
|
||||
}
|
||||
@@ -46,16 +50,234 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
}
|
||||
|
||||
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||||
function CommittedTooltip({ active, payload, label }: any) {
|
||||
function RegularTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const committed = payload.find((p: any) => p.dataKey === "committed")?.value ?? 0;
|
||||
const discretionary = payload.find((p: any) => p.dataKey === "discretionary")?.value ?? 0;
|
||||
const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0;
|
||||
const occasional = payload.find((p: any) => p.dataKey === "occasional")?.value ?? 0;
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
|
||||
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
||||
<div className="flex justify-between gap-4"><span className="text-indigo-400">Committed</span><span>{fmt(committed)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Discretionary</span><span>{fmt(discretionary)}</span></div>
|
||||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(committed + discretionary)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-indigo-400">Regular</span><span>{fmt(regular)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span>{fmt(occasional)}</span></div>
|
||||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(regular + occasional)}</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Drill-down row ──────────────────────────────────────────────────
|
||||
function DrillDownRow({
|
||||
category,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
category: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
|
||||
const updateTx = useUpdateTransaction();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={2} className="px-4 py-3 text-xs text-zinc-500">Loading...</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const txns = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={2} className="px-0 py-0">
|
||||
<div className="bg-zinc-950 border-t border-zinc-800/50">
|
||||
{txns.length === 0 ? (
|
||||
<p className="px-6 py-3 text-xs text-zinc-600">No transactions found.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-2 text-zinc-600 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Merchant</th>
|
||||
<th className="text-right px-4 py-2 text-zinc-600 font-medium">My share</th>
|
||||
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Category</th>
|
||||
<th className="text-right px-4 py-2 text-zinc-600 font-medium">% mine</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txns.map((t) => {
|
||||
const sharePct = t.my_share_percent ?? 100;
|
||||
const effectiveAmt = t.amount * sharePct / 100;
|
||||
const isDebit = SPEND_TYPES.has(t.transaction_type);
|
||||
return (
|
||||
<tr key={t.id} className="border-b border-zinc-800/30 hover:bg-zinc-900/30">
|
||||
<td className="px-6 py-2 text-zinc-500 whitespace-nowrap">
|
||||
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-300">{t.merchant_name || t.description}</td>
|
||||
<td className={`px-4 py-2 text-right tabular-nums ${isDebit ? "text-zinc-200" : "text-green-400"}`}>
|
||||
{fmtTx(effectiveAmt, t.transaction_type)}
|
||||
{sharePct < 100 && (
|
||||
<span className="text-zinc-600 ml-1 line-through">{fmtExact(t.amount)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<select
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
|
||||
defaultValue={t.effective_category ?? "other"}
|
||||
onChange={(e) => updateTx.mutate({ id: t.id, category: e.target.value })}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{formatCategory(c)}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<select
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
|
||||
defaultValue={t.my_share_percent ?? 100}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
updateTx.mutate({ id: t.id, my_share_percent: val === 100 ? null : val });
|
||||
}}
|
||||
>
|
||||
<option value={100}>100%</option>
|
||||
<option value={75}>75%</option>
|
||||
<option value={67}>67%</option>
|
||||
<option value={50}>50%</option>
|
||||
<option value={33}>33%</option>
|
||||
<option value={25}>25%</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Monthly Spend Breakdown ─────────────────────────────────────────
|
||||
function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typeof useMonthlyAnalytics>["data"]> }) {
|
||||
// analytics.months is newest-first; show last 6
|
||||
const months = useMemo(() => analytics.months.slice(0, 6), [analytics.months]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>(months[0] ?? "");
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
|
||||
// Reset expanded when month changes
|
||||
const handleSelectMonth = (m: string) => {
|
||||
setSelectedMonth(m);
|
||||
setExpandedCategory(null);
|
||||
};
|
||||
|
||||
const from = selectedMonth + "-01";
|
||||
const lastDay = new Date(parseInt(selectedMonth.slice(0, 4)), parseInt(selectedMonth.slice(5, 7)), 0).getDate();
|
||||
const to = selectedMonth + "-" + String(lastDay).padStart(2, "0");
|
||||
|
||||
const categoryData = useMemo(() => {
|
||||
return analytics.rows
|
||||
.map((row) => ({ category: row.category, amount: Number(row.spent[selectedMonth] ?? 0) }))
|
||||
.filter((r) => r.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}, [analytics.rows, selectedMonth]);
|
||||
|
||||
const regularRows = categoryData.filter((r) => REGULAR_CATEGORIES.has(r.category as any));
|
||||
const occasionalRows = categoryData.filter((r) => !REGULAR_CATEGORIES.has(r.category as any));
|
||||
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
|
||||
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
function monthLabel(m: string) {
|
||||
const [year, month] = m.split("-");
|
||||
return new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-AU", { month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
function renderRows(rows: typeof categoryData, dotClass: string) {
|
||||
return rows.map((row) => (
|
||||
<>
|
||||
<tr
|
||||
key={row.category}
|
||||
className="border-b border-zinc-800/50 hover:bg-zinc-800/20 cursor-pointer transition-colors"
|
||||
onClick={() => setExpandedCategory(expandedCategory === row.category ? null : row.category)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<span className={`w-2 h-2 rounded-full inline-block flex-shrink-0 ${dotClass}`} />
|
||||
{formatCategory(row.category)}
|
||||
<span className="text-zinc-600 text-xs">{expandedCategory === row.category ? "▲" : "▼"}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-sm text-zinc-200">{fmt(row.amount)}</td>
|
||||
</tr>
|
||||
{expandedCategory === row.category && (
|
||||
<DrillDownRow
|
||||
key={`${row.category}-drill`}
|
||||
category={row.category}
|
||||
from={from}
|
||||
to={to}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Month tabs */}
|
||||
<div className="flex gap-1 mb-3 flex-wrap">
|
||||
{months.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => handleSelectMonth(m)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
m === selectedMonth
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{monthLabel(m)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Spend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{regularRows.length > 0 && (
|
||||
<>
|
||||
<tr className="bg-zinc-900/30">
|
||||
<td className="px-4 py-1.5 text-xs text-indigo-400 font-medium tracking-wide uppercase">Regular</td>
|
||||
<td className="px-4 py-1.5 text-right text-xs text-indigo-400 font-medium">{fmt(regularTotal)}</td>
|
||||
</tr>
|
||||
{renderRows(regularRows, "bg-indigo-500")}
|
||||
</>
|
||||
)}
|
||||
{occasionalRows.length > 0 && (
|
||||
<>
|
||||
<tr className="bg-zinc-900/30">
|
||||
<td className="px-4 py-1.5 text-xs text-zinc-500 font-medium tracking-wide uppercase">Occasional</td>
|
||||
<td className="px-4 py-1.5 text-right text-xs text-zinc-500 font-medium">{fmt(occasionalTotal)}</td>
|
||||
</tr>
|
||||
{renderRows(occasionalRows, "bg-zinc-500")}
|
||||
</>
|
||||
)}
|
||||
{categoryData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={2} className="px-4 py-6 text-center text-xs text-zinc-600">No spend data for this month.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,35 +285,36 @@ function CommittedTooltip({ active, payload, label }: any) {
|
||||
// ─── Main page ────────────────────────────────────────────────────────
|
||||
export default function InsightsPage() {
|
||||
const { data: analytics } = useMonthlyAnalytics(12);
|
||||
const { data: analytics6 } = useMonthlyAnalytics(6);
|
||||
const { data: subData } = useSubscriptions();
|
||||
const { data: feesData } = useFees();
|
||||
|
||||
// Build committed/discretionary chart data
|
||||
// Build regular/occasional chart data
|
||||
const chartData = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
return [...analytics.months].reverse().map((month) => {
|
||||
let committed = 0;
|
||||
let discretionary = 0;
|
||||
let regular = 0;
|
||||
let occasional = 0;
|
||||
for (const row of analytics.rows) {
|
||||
const spend = Number(row.spent[month] ?? 0);
|
||||
if (COMMITTED_CATEGORIES.has(row.category)) committed += spend;
|
||||
else discretionary += spend;
|
||||
if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend;
|
||||
else occasional += spend;
|
||||
}
|
||||
return {
|
||||
month: month.slice(5) + "/" + month.slice(2, 4),
|
||||
committed: Math.round(committed),
|
||||
discretionary: Math.round(discretionary),
|
||||
total: Math.round(committed + discretionary),
|
||||
regular: Math.round(regular),
|
||||
occasional: Math.round(occasional),
|
||||
total: Math.round(regular + occasional),
|
||||
};
|
||||
});
|
||||
}, [analytics]);
|
||||
|
||||
const committedValues = chartData.map((d) => d.committed);
|
||||
const committedTrend = trend(committedValues);
|
||||
const avgCommitted = committedValues.length
|
||||
? Math.round(committedValues.reduce((a, b) => a + b, 0) / committedValues.length)
|
||||
const regularValues = chartData.map((d) => d.regular);
|
||||
const regularTrend = trend(regularValues);
|
||||
const avgRegular = regularValues.length
|
||||
? Math.round(regularValues.reduce((a, b) => a + b, 0) / regularValues.length)
|
||||
: 0;
|
||||
const latestCommitted = committedValues[committedValues.length - 1] ?? 0;
|
||||
const latestRegular = regularValues[regularValues.length - 1] ?? 0;
|
||||
|
||||
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
||||
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
||||
@@ -100,21 +323,21 @@ export default function InsightsPage() {
|
||||
<div className="max-w-4xl">
|
||||
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
||||
|
||||
{/* ── 1. Committed vs Discretionary ── */}
|
||||
<Section title="Committed vs Discretionary Spend">
|
||||
{/* ── 1. Regular vs Occasional ── */}
|
||||
<Section title="Regular vs Occasional Spend">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">This month — committed floor</div>
|
||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestCommitted)}</div>
|
||||
<div className="text-xs text-zinc-500 mb-1">This month — regular spend</div>
|
||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestRegular)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">12-month avg committed</div>
|
||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgCommitted)}</div>
|
||||
<div className="text-xs text-zinc-500 mb-1">12-month avg regular</div>
|
||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgRegular)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
|
||||
<div className={`text-xl font-semibold ${committedTrend.dir === "up" ? "text-red-400" : committedTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
||||
{committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}%
|
||||
<div className={`text-xl font-semibold ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
||||
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,20 +346,29 @@ export default function InsightsPage() {
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||||
<Tooltip content={<CommittedTooltip />} />
|
||||
<Bar dataKey="committed" stackId="a" fill="#6366f1" name="Committed" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="discretionary" stackId="a" fill="#3f3f46" name="Discretionary" radius={[3, 3, 0, 0]} />
|
||||
<Line type="monotone" dataKey="committed" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||
<Tooltip content={<RegularTooltip />} />
|
||||
<Bar dataKey="regular" stackId="a" fill="#6366f1" name="Regular" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="occasional" stackId="a" fill="#3f3f46" name="Occasional" radius={[3, 3, 0, 0]} />
|
||||
<Line type="monotone" dataKey="regular" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex gap-4 mt-2 justify-end">
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Committed (rent, utilities, insurance, subscriptions)</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Discretionary</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Regular (groceries, dining, transport…)</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Occasional</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 2. Recurring Charges ── */}
|
||||
{/* ── 2. Monthly Spend Breakdown ── */}
|
||||
<Section title="Monthly Spend Breakdown">
|
||||
{!analytics6 ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : (
|
||||
<MonthlyBreakdown analytics={analytics6} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 3. Recurring Charges ── */}
|
||||
<Section title="Recurring Charges">
|
||||
{!subData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
@@ -186,7 +418,7 @@ export default function InsightsPage() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 3. Fees & Interest ── */}
|
||||
{/* ── 4. Fees & Interest ── */}
|
||||
<Section title="Fees & Interest">
|
||||
{!feesData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
|
||||
+205
-31
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks";
|
||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const FIELDS = [
|
||||
@@ -10,6 +10,7 @@ const FIELDS = [
|
||||
{ value: "category", label: "Category" },
|
||||
{ value: "bank_name", label: "Bank" },
|
||||
{ value: "amount", label: "Amount" },
|
||||
{ value: "transaction_type", label: "Transaction Type" },
|
||||
] as const;
|
||||
|
||||
const TEXT_OPS = [
|
||||
@@ -24,18 +25,24 @@ const AMOUNT_OPS = [
|
||||
{ value: "gt", label: ">" },
|
||||
{ value: "lt", label: "<" },
|
||||
];
|
||||
const ENUM_OPS = [
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "not_equals", label: "not equals" },
|
||||
];
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
type Condition = { field: string; operator: string; value: string };
|
||||
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||
type SplitEntry = { participant_id: number; share_percent: number };
|
||||
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] };
|
||||
|
||||
function humanCondition(c: Condition): string {
|
||||
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
|
||||
const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_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 {
|
||||
function humanAction(a: Actions, tagNames: Map<number, string>, participantNames: 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}`);
|
||||
@@ -43,26 +50,62 @@ function humanAction(a: Actions, tagNames: Map<number, string>): string {
|
||||
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
||||
parts.push(`add tags: ${names}`);
|
||||
}
|
||||
if (a.apply_split?.length) {
|
||||
const splits = a.apply_split.map((s) => `${participantNames.get(s.participant_id) || `#${s.participant_id}`} ${s.share_percent}%`).join(", ");
|
||||
parts.push(`split: ${splits}`);
|
||||
}
|
||||
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
|
||||
}
|
||||
|
||||
const EMPTY_ACTIONS: Actions = {};
|
||||
|
||||
export default function RulesPage() {
|
||||
const { data: rules = [], isLoading } = useRules();
|
||||
const { data: tags = [] } = useTags();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const createRule = useCreateRule();
|
||||
const updateRule = useUpdateRule();
|
||||
const deleteRule = useDeleteRule();
|
||||
const applyRules = useApplyRules();
|
||||
const { data: runs = [] } = useRuleRuns();
|
||||
const revertRun = useRevertRuleRun();
|
||||
|
||||
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||
const participantNames = new Map(participants.map((p) => [p.id, p.name]));
|
||||
|
||||
const [applyFrom, setApplyFrom] = useState("2026-01-08");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
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 [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
||||
const [priority, setPriority] = useState(0);
|
||||
|
||||
function openNewForm() {
|
||||
setEditingId(null);
|
||||
setName("");
|
||||
setConditions([]);
|
||||
setActions(EMPTY_ACTIONS);
|
||||
setPriority(0);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) {
|
||||
setEditingId(rule.id);
|
||||
setName(rule.name);
|
||||
setConditions(Array.isArray(rule.conditions) ? rule.conditions : []);
|
||||
setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS);
|
||||
setPriority(rule.priority);
|
||||
setShowForm(true);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
function addCondition() {
|
||||
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
|
||||
}
|
||||
@@ -75,26 +118,54 @@ export default function RulesPage() {
|
||||
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function addSplitEntry() {
|
||||
if (!participants.length) return;
|
||||
const existing = actions.apply_split || [];
|
||||
setActions({ ...actions, apply_split: [...existing, { participant_id: participants[0].id, share_percent: 0 }] });
|
||||
}
|
||||
|
||||
function updateSplitEntry(i: number, patch: Partial<SplitEntry>) {
|
||||
const entries = (actions.apply_split || []).map((s, idx) => (idx === i ? { ...s, ...patch } : s));
|
||||
setActions({ ...actions, apply_split: entries });
|
||||
}
|
||||
|
||||
function removeSplitEntry(i: number) {
|
||||
const entries = (actions.apply_split || []).filter((_, idx) => idx !== i);
|
||||
setActions({ ...actions, apply_split: entries.length ? entries : undefined });
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
|
||||
setName("");
|
||||
setConditions([]);
|
||||
setActions({});
|
||||
setPriority(0);
|
||||
setShowForm(false);
|
||||
const payload = { name, conditions, actions, enabled: true, priority };
|
||||
if (editingId !== null) {
|
||||
await updateRule.mutateAsync({ id: editingId, ...payload });
|
||||
} else {
|
||||
await createRule.mutateAsync(payload);
|
||||
}
|
||||
closeForm();
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const result = await applyRules.mutateAsync();
|
||||
const result = await applyRules.mutateAsync(applyFrom || undefined);
|
||||
setApplyResult(result);
|
||||
}
|
||||
|
||||
const splitTotal = (actions.apply_split || []).reduce((sum, s) => sum + (s.share_percent || 0), 0);
|
||||
const isPending = editingId !== null ? updateRule.isPending : createRule.isPending;
|
||||
|
||||
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">
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className="text-xs text-zinc-500 whitespace-nowrap">Splits from</label>
|
||||
<input
|
||||
type="date"
|
||||
value={applyFrom}
|
||||
onChange={(e) => setApplyFrom(e.target.value)}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions."
|
||||
/>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applyRules.isPending}
|
||||
@@ -103,7 +174,7 @@ export default function RulesPage() {
|
||||
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
onClick={showForm ? closeForm : openNewForm}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{showForm ? "Cancel" : "New Rule"}
|
||||
@@ -123,7 +194,7 @@ export default function RulesPage() {
|
||||
|
||||
{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>
|
||||
<h3 className="font-semibold text-sm text-zinc-300">{editingId !== null ? "Edit Rule" : "New Rule"}</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||
@@ -145,17 +216,20 @@ export default function RulesPage() {
|
||||
</div>
|
||||
{conditions.map((cond, i) => {
|
||||
const isAmount = cond.field === "amount";
|
||||
const ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
|
||||
const isEnum = cond.field === "transaction_type";
|
||||
const ops = isAmount ? AMOUNT_OPS : isEnum ? ENUM_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",
|
||||
})
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newField = e.target.value;
|
||||
const patch: Partial<Condition> = { field: newField };
|
||||
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
|
||||
else if (newField === "transaction_type") { patch.operator = "equals"; patch.value = "debit"; }
|
||||
else { patch.operator = "contains"; patch.value = ""; }
|
||||
updateCondition(i, patch);
|
||||
}}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{FIELDS.map((f) => (
|
||||
@@ -175,12 +249,24 @@ export default function RulesPage() {
|
||||
</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"
|
||||
/>
|
||||
{isEnum ? (
|
||||
<select
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</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)}
|
||||
@@ -252,6 +338,56 @@ export default function RulesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Apply Split (optional)
|
||||
{(actions.apply_split?.length ?? 0) > 0 && (
|
||||
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||
{splitTotal}% total
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{participants.length > 0 && (
|
||||
<button type="button" onClick={addSplitEntry} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||
+ Add participant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(actions.apply_split || []).map((entry, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||
<select
|
||||
value={entry.participant_id}
|
||||
onChange={(e) => updateSplitEntry(i, { participant_id: Number(e.target.value) })}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={entry.share_percent}
|
||||
onChange={(e) => updateSplitEntry(i, { share_percent: Number(e.target.value) })}
|
||||
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSplitEntry(i)}
|
||||
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{participants.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No participants created yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
|
||||
@@ -264,15 +400,47 @@ export default function RulesPage() {
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRule.isPending}
|
||||
disabled={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"}
|
||||
{isPending ? "Saving..." : editingId !== null ? "Save Changes" : "Create Rule"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{runs.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
||||
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
|
||||
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
|
||||
</div>
|
||||
{run.reverted_at ? (
|
||||
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
|
||||
revertRun.mutate(run.id);
|
||||
}
|
||||
}}
|
||||
disabled={revertRun.isPending}
|
||||
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||
) : rules.length === 0 ? (
|
||||
@@ -294,7 +462,7 @@ export default function RulesPage() {
|
||||
<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>
|
||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
@@ -309,6 +477,12 @@ export default function RulesPage() {
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority })}
|
||||
className="text-zinc-400 hover:text-white text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
||||
|
||||
+109
-28
@@ -5,6 +5,7 @@ import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
useSettleSplits,
|
||||
useCreateParticipant,
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
|
||||
@@ -12,8 +13,67 @@ function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return `$${Number(n).toFixed(2)}`;
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
|
||||
function formatAmount(n: number, type?: string) {
|
||||
const formatted = `$${Number(n).toFixed(2)}`;
|
||||
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
|
||||
}
|
||||
|
||||
function AddParticipantForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const create = useCreateParticipant();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (!name.trim()) { setError("Name is required"); return; }
|
||||
try {
|
||||
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Add Participant</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email (optional)"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{create.isPending ? "Adding..." : "Add"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SharedPage() {
|
||||
@@ -21,6 +81,7 @@ export default function SharedPage() {
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
|
||||
const settle = useSettleSplits();
|
||||
const [settling, setSettling] = useState<number | null>(null);
|
||||
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||
|
||||
async function handleSettleParticipant(participantId: number) {
|
||||
setSettling(participantId);
|
||||
@@ -28,40 +89,60 @@ export default function SharedPage() {
|
||||
setSettling(null);
|
||||
}
|
||||
|
||||
const others = balances.filter((b) => b.name !== "Me");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||
{!addingParticipant && (
|
||||
<button
|
||||
onClick={() => setAddingParticipant(true)}
|
||||
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||||
>
|
||||
+ Add Participant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addingParticipant && (
|
||||
<AddParticipantForm onDone={() => setAddingParticipant(false)} />
|
||||
)}
|
||||
|
||||
{/* Balance summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{balLoading ? (
|
||||
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
||||
) : (
|
||||
others.map((b) => (
|
||||
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-medium">{b.name}</p>
|
||||
<p className="text-xs text-zinc-500">{b.unsettled_count} unsettled</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold text-amber-400">{formatAmount(b.total_owed)}</p>
|
||||
<p className="text-xs text-zinc-500">owes you</p>
|
||||
balances.map((b) => {
|
||||
const theyOweMe = b.total_owed > 0;
|
||||
const net = Math.abs(b.total_owed);
|
||||
return (
|
||||
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-medium">{b.name}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{theyOweMe ? `${b.unsettled_count} unsettled` : "you owe"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-lg font-semibold ${theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
|
||||
${net.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">{theyOweMe ? "owes you" : "you owe"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{theyOweMe && b.unsettled_count > 0 && (
|
||||
<button
|
||||
onClick={() => handleSettleParticipant(b.id)}
|
||||
disabled={settling === b.id}
|
||||
className="w-full py-1.5 text-xs font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{settling === b.id ? "Settling..." : "Mark All Settled"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{b.unsettled_count > 0 && (
|
||||
<button
|
||||
onClick={() => handleSettleParticipant(b.id)}
|
||||
disabled={settling === b.id}
|
||||
className="w-full py-1.5 text-xs font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{settling === b.id ? "Settling..." : "Mark All Settled"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -100,8 +181,8 @@ export default function SharedPage() {
|
||||
<p className="font-medium truncate max-w-48">{tx.effective_merchant || tx.description}</p>
|
||||
<p className="text-xs text-zinc-500 truncate max-w-48">{tx.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium tabular-nums">
|
||||
{formatAmount(tx.amount)}
|
||||
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||
{formatAmount(tx.amount, tx.transaction_type)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { SplitModal } from "@/components/split-modal";
|
||||
import { TagPicker } from "@/components/tag-picker";
|
||||
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import type { TransactionRow } from "@/lib/queries";
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", {
|
||||
@@ -232,6 +235,8 @@ function TransactionsContent() {
|
||||
const [bulkCategory, setBulkCategory] = useState("");
|
||||
const [bulkTagId, setBulkTagId] = useState("");
|
||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
|
||||
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
||||
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
|
||||
const [rulePrompt, setRulePrompt] = useState<{
|
||||
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||
field: "category" | "merchant";
|
||||
@@ -283,7 +288,15 @@ function TransactionsContent() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Transactions</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Transactions</h2>
|
||||
<button
|
||||
onClick={() => setAddModal({})}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
+ Add Transaction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Statement context banner */}
|
||||
{filters.statement_id && statementInfo && (
|
||||
@@ -483,7 +496,12 @@ function TransactionsContent() {
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
|
||||
<td className="p-2 max-w-xs truncate" title={t.description}>{t.description}</td>
|
||||
<td className="p-2 max-w-xs">
|
||||
<p className="truncate" title={t.description}>{t.description}</p>
|
||||
{t.notes && (
|
||||
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 max-w-[150px]">
|
||||
<div className="relative">
|
||||
<InlineEdit
|
||||
@@ -540,13 +558,54 @@ function TransactionsContent() {
|
||||
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<td className="p-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{t.splits?.filter((s) => s.name !== "Me").map((s) => (
|
||||
<span
|
||||
key={s.participant_id}
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
|
||||
}`}
|
||||
title={`${s.name}: ${s.share_percent}%${s.settled ? " (settled)" : ""}`}
|
||||
>
|
||||
{s.name} {s.share_percent}%
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
|
||||
className={`text-xs px-2 py-0.5 rounded transition-colors ${
|
||||
t.splits?.some((s) => s.name !== "Me")
|
||||
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
|
||||
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
|
||||
}`}
|
||||
title="Split this transaction"
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
|
||||
onClick={() => setEditModal(t)}
|
||||
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"
|
||||
title="Edit this transaction"
|
||||
>
|
||||
Split
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddModal({
|
||||
title: "Duplicate Transaction",
|
||||
prefill: {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
transaction_type: t.transaction_type,
|
||||
merchant_normalized: t.effective_merchant || undefined,
|
||||
category: t.effective_category || undefined,
|
||||
},
|
||||
})}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Duplicate this transaction"
|
||||
>
|
||||
Dupe
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -568,6 +627,21 @@ function TransactionsContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{addModal && (
|
||||
<AddTransactionModal
|
||||
prefill={addModal.prefill}
|
||||
title={addModal.title}
|
||||
onClose={() => setAddModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editModal && (
|
||||
<EditTransactionModal
|
||||
transaction={editModal}
|
||||
onClose={() => setEditModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rulePrompt && (
|
||||
<SaveAsRulePrompt
|
||||
tx={rulePrompt.tx}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCreateTransaction, useParticipants } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
interface Prefill {
|
||||
date?: string;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_type?: string;
|
||||
merchant_normalized?: string;
|
||||
category?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
}
|
||||
|
||||
export function AddTransactionModal({
|
||||
prefill,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
prefill?: Prefill;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const createTransaction = useCreateTransaction();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
|
||||
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
|
||||
const [description, setDescription] = useState(prefill?.description ?? "");
|
||||
const [amount, setAmount] = useState(prefill?.amount != null ? String(prefill.amount) : "");
|
||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||
prefill?.splits ?? []
|
||||
);
|
||||
|
||||
function addSplit() {
|
||||
if (!participants.length) return;
|
||||
setSplits([...splits, { participant_id: participants[0].id, share_percent: 50 }]);
|
||||
}
|
||||
|
||||
function updateSplit(i: number, patch: Partial<{ participant_id: number; share_percent: number }>) {
|
||||
setSplits(splits.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||||
}
|
||||
|
||||
function removeSplit(i: number) {
|
||||
setSplits(splits.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createTransaction.mutateAsync({
|
||||
date,
|
||||
description,
|
||||
amount: parseFloat(amount),
|
||||
transaction_type: type,
|
||||
merchant_normalized: merchant || undefined,
|
||||
category: category || undefined,
|
||||
splits: splits.length ? splits : undefined,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
const splitTotal = splits.reduce((s, e) => s + (e.share_percent || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-semibold text-sm text-zinc-300">{title ?? "Add Transaction"}</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Description</label>
|
||||
<input
|
||||
required
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Coles Wyndham Vale"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Type</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Category</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{formatCategory(c)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Merchant (optional)</label>
|
||||
<input
|
||||
value={merchant}
|
||||
onChange={(e) => setMerchant(e.target.value)}
|
||||
placeholder="Normalized merchant name"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Splits */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Splits (optional)
|
||||
{splits.length > 0 && (
|
||||
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||
{splitTotal}%
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{participants.length > 0 && (
|
||||
<button type="button" onClick={addSplit} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{splits.map((s, i) => (
|
||||
<div key={i} className="flex gap-2 mb-1.5 items-center">
|
||||
<select
|
||||
value={s.participant_id}
|
||||
onChange={(e) => updateSplit(i, { participant_id: Number(e.target.value) })}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={s.share_percent}
|
||||
onChange={(e) => updateSplit(i, { share_percent: Number(e.target.value) })}
|
||||
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">%</span>
|
||||
<button type="button" onClick={() => removeSplit(i)} className="text-zinc-500 hover:text-red-400 text-lg leading-none">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createTransaction.isPending}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{createTransaction.isPending ? "Saving..." : "Save Transaction"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useUpdateTransaction,
|
||||
useTags,
|
||||
useAddTransactionTag,
|
||||
useRemoveTransactionTag,
|
||||
useTransactionSplits,
|
||||
} from "@/lib/hooks";
|
||||
import { SplitModal } from "./split-modal";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import type { TransactionRow, TagRow } from "@/lib/queries";
|
||||
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
|
||||
function formatAmount(amount: number, type: string) {
|
||||
const formatted = `$${Number(amount).toFixed(2)}`;
|
||||
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
||||
}
|
||||
|
||||
function InlineTags({ transactionId, initialTags }: { transactionId: number; initialTags: TagRow[] }) {
|
||||
const { data: allTags = [] } = useTags();
|
||||
const addTag = useAddTransactionTag();
|
||||
const removeTag = useRemoveTransactionTag();
|
||||
const [tags, setTags] = useState<TagRow[]>(initialTags);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const available = allTags.filter((t) => !tags.find((ct) => ct.id === t.id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white"
|
||||
style={{ backgroundColor: tag.color + "99" }}
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
removeTag.mutate({ transactionId, tagId: tag.id });
|
||||
setTags((prev) => prev.filter((t) => t.id !== tag.id));
|
||||
}}
|
||||
className="ml-0.5 text-white/60 hover:text-white leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{available.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPicker((v) => !v)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1.5 py-0.5 rounded hover:bg-zinc-800"
|
||||
>
|
||||
+ Add tag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showPicker && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{available.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
addTag.mutate({ transactionId, tagId: tag.id });
|
||||
setTags((prev) => [...prev, tag]);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-xs font-medium text-white hover:brightness-125"
|
||||
style={{ backgroundColor: tag.color + "66" }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditTransactionModal({
|
||||
transaction,
|
||||
onClose,
|
||||
}: {
|
||||
transaction: TransactionRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isManual = !transaction.statement_id;
|
||||
const updateTxn = useUpdateTransaction();
|
||||
|
||||
// Editable override fields
|
||||
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
|
||||
const [category, setCategory] = useState(transaction.effective_category ?? "");
|
||||
const [type, setType] = useState(transaction.transaction_type);
|
||||
const [notes, setNotes] = useState(transaction.notes ?? "");
|
||||
|
||||
// Manual-only direct fields
|
||||
const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? "");
|
||||
const [description, setDescription] = useState(transaction.description);
|
||||
const [amount, setAmount] = useState(String(transaction.amount));
|
||||
|
||||
// Splits — live via hook so they refresh after SplitModal saves
|
||||
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
|
||||
|
||||
const [showSplitModal, setShowSplitModal] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSave() {
|
||||
setError("");
|
||||
try {
|
||||
const patch: Parameters<typeof updateTxn.mutateAsync>[0] = { id: transaction.id };
|
||||
|
||||
// Override fields (always)
|
||||
if (merchant !== (transaction.merchant_override ?? transaction.merchant_normalized ?? ""))
|
||||
patch.merchant_normalized = merchant;
|
||||
if (category !== (transaction.effective_category ?? ""))
|
||||
patch.category = category;
|
||||
if (type !== transaction.transaction_type)
|
||||
patch.transaction_type = type;
|
||||
if (notes !== (transaction.notes ?? ""))
|
||||
patch.notes = notes;
|
||||
|
||||
// Direct fields (manual only)
|
||||
if (isManual) {
|
||||
if (date !== transaction.transaction_date?.slice(0, 10))
|
||||
patch.transaction_date = date;
|
||||
if (description !== transaction.description)
|
||||
patch.description = description;
|
||||
if (parseFloat(amount) !== transaction.amount)
|
||||
patch.amount = parseFloat(amount);
|
||||
}
|
||||
|
||||
await updateTxn.mutateAsync(patch);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl flex flex-col max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
|
||||
<h3 className="font-semibold text-sm text-zinc-300">Edit Transaction</h3>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{transaction.bank_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
|
||||
|
||||
{/* Core fields — read-only for statement, editable for manual */}
|
||||
{isManual ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Description</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-800/50 rounded-lg px-3 py-2.5 space-y-1">
|
||||
<p className="text-sm font-medium">{transaction.description}</p>
|
||||
<p className={`text-sm font-mono ${SPEND_TYPES.has(transaction.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||
{formatAmount(transaction.amount, transaction.transaction_type)}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{new Date(transaction.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Override fields */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Type</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Category</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{formatCategory(c)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Merchant</label>
|
||||
<input
|
||||
value={merchant}
|
||||
onChange={(e) => setMerchant(e.target.value)}
|
||||
placeholder="Normalized merchant name"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Additional context about this transaction…"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1.5">Tags</p>
|
||||
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Splits */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-xs text-zinc-500">Splits</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSplitModal(true)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{liveSplits.length > 0 ? "Edit splits" : "Add split"}
|
||||
</button>
|
||||
</div>
|
||||
{liveSplits.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{liveSplits.map((s: { participant_id: number; name: string; share_percent: number; settled: boolean }) => (
|
||||
<span
|
||||
key={s.participant_id}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
|
||||
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
|
||||
}`}
|
||||
>
|
||||
{s.name} {s.share_percent}%
|
||||
{s.settled && <span className="text-emerald-500">✓</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-zinc-600 italic">No splits</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2">
|
||||
{error && <p className="text-red-400 text-xs flex-1 self-center">{error}</p>}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateTxn.isPending}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{updateTxn.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSplitModal && (
|
||||
<SplitModal
|
||||
transactionId={transaction.id}
|
||||
amount={transaction.amount}
|
||||
description={transaction.description}
|
||||
merchant={transaction.effective_merchant || undefined}
|
||||
onClose={() => setShowSplitModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
export const REGULAR_CATEGORIES = new Set([
|
||||
"rent", "utilities", "insurance", "subscriptions",
|
||||
"groceries", "dining", "transport", "fuel",
|
||||
"health", "personal_care", "government", "charity", "pets",
|
||||
] as const);
|
||||
|
||||
export const CATEGORIES = [
|
||||
"groceries",
|
||||
"dining",
|
||||
|
||||
+77
-3
@@ -83,6 +83,35 @@ export function useBanks() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type?: string;
|
||||
merchant_normalized?: string;
|
||||
category?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
}) => {
|
||||
const res = await fetch("/api/transactions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed to create transaction");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -95,6 +124,10 @@ export function useUpdateTransaction() {
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
transaction_type?: string;
|
||||
my_share_percent?: number | null;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -106,6 +139,7 @@ export function useUpdateTransaction() {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -421,14 +455,54 @@ export function useDeleteRule() {
|
||||
export function useApplyRules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/rules/apply", { method: "POST" });
|
||||
mutationFn: async (splitFrom?: string) => {
|
||||
const res = await fetch("/api/rules/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ splitFrom: splitFrom || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to apply rules");
|
||||
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
||||
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["rules"] });
|
||||
qc.invalidateQueries({ queryKey: ["rule-runs"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RuleRun {
|
||||
id: number;
|
||||
applied_at: string;
|
||||
split_from: string | null;
|
||||
matched: number;
|
||||
transactions_affected: number;
|
||||
reverted_at: string | null;
|
||||
}
|
||||
|
||||
export function useRuleRuns() {
|
||||
return useQuery({
|
||||
queryKey: ["rule-runs"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/rules/apply");
|
||||
if (!res.ok) throw new Error("Failed to fetch rule runs");
|
||||
return res.json() as Promise<RuleRun[]>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevertRuleRun() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (runId: number) => {
|
||||
const res = await fetch(`/api/rules/apply/${runId}/revert`, { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed to revert run");
|
||||
return res.json() as Promise<{ reverted: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["rule-runs"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+81
-33
@@ -8,7 +8,7 @@ export interface TagRow {
|
||||
|
||||
export interface TransactionRow {
|
||||
id: number;
|
||||
statement_id: number;
|
||||
statement_id: number | null;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
@@ -26,14 +26,17 @@ export interface TransactionRow {
|
||||
category_override: string | null;
|
||||
merchant_override: string | null;
|
||||
notes: string | null;
|
||||
my_share_percent: number | null;
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
// statement context
|
||||
// statement context (null for manual transactions)
|
||||
bank_name: string;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
// tags
|
||||
tags: TagRow[];
|
||||
// splits
|
||||
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||
}
|
||||
|
||||
export interface StatementRow {
|
||||
@@ -79,7 +82,7 @@ interface TransactionFilters {
|
||||
}
|
||||
|
||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||
const conditions: string[] = [`s.owner_id = $1`];
|
||||
const conditions: string[] = [`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`];
|
||||
const params: unknown[] = [ownerId];
|
||||
let paramIdx = 2;
|
||||
|
||||
@@ -96,11 +99,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
params.push(filters.category);
|
||||
}
|
||||
if (filters.bank_name) {
|
||||
conditions.push(`s.bank_name = $${paramIdx++}`);
|
||||
params.push(filters.bank_name);
|
||||
if (filters.bank_name === "Manual") {
|
||||
conditions.push(`t.statement_id IS NULL`);
|
||||
} else {
|
||||
conditions.push(`s.bank_name = $${paramIdx++}`);
|
||||
params.push(filters.bank_name);
|
||||
}
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
|
||||
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
@@ -124,7 +131,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
SELECT COUNT(*)::int as total
|
||||
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 statements s ON s.id = t.statement_id
|
||||
${where}
|
||||
`;
|
||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||
@@ -132,32 +139,41 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
|
||||
const dataSql = `
|
||||
SELECT t.*,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||
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,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p.name as owner_name,
|
||||
txn_tags.tags
|
||||
txn_tags.tags,
|
||||
txn_splits.splits
|
||||
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 statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p ON p.id = COALESCE(t.owner_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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
||||
FROM transaction_splits ts
|
||||
JOIN participants sp ON sp.id = ts.participant_id
|
||||
WHERE ts.transaction_id = t.id
|
||||
) txn_splits ON true
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
|
||||
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(dataSql, params);
|
||||
const data = raw.map((r) => ({
|
||||
...r,
|
||||
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||
})) as TransactionRow[];
|
||||
|
||||
return { data, total, limit, offset };
|
||||
@@ -166,15 +182,16 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
export async function getTransactionById(id: number) {
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||
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,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as 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
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||
WHERE t.id = $1
|
||||
`;
|
||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||
@@ -220,8 +237,13 @@ export async function getMerchantSuggestions(search: string) {
|
||||
}
|
||||
|
||||
export async function getBankNames() {
|
||||
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
||||
return queryRaw<{ bank_name: string }>(sql);
|
||||
const [bankRows, manualCount] = await Promise.all([
|
||||
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
|
||||
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
|
||||
]);
|
||||
const banks = bankRows.map((r) => r.bank_name);
|
||||
if (manualCount[0]?.count > 0) banks.push("Manual");
|
||||
return banks;
|
||||
}
|
||||
|
||||
export interface ParticipantBalance {
|
||||
@@ -234,13 +256,35 @@ export interface ParticipantBalance {
|
||||
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
|
||||
COALESCE(SUM(combined.signed_amount), 0)::numeric(12,2) as total_owed,
|
||||
COALESCE(SUM(combined.unsettled_count), 0)::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)
|
||||
LEFT JOIN (
|
||||
-- They owe me: their splits on transactions I own
|
||||
SELECT ts.participant_id AS pid,
|
||||
CASE WHEN ts.settled = false
|
||||
THEN (CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100
|
||||
ELSE 0 END AS signed_amount,
|
||||
CASE WHEN ts.settled = false AND t.transaction_type IN ('debit', 'fee', 'interest') THEN 1 ELSE 0 END AS unsettled_count
|
||||
FROM transaction_splits ts
|
||||
JOIN transactions t ON t.id = ts.transaction_id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- I owe them: my splits on transactions they own
|
||||
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
||||
CASE WHEN ts.settled = false
|
||||
THEN -(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100
|
||||
ELSE 0 END AS signed_amount,
|
||||
0 AS unsettled_count
|
||||
FROM transaction_splits ts
|
||||
JOIN transactions t ON t.id = ts.transaction_id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||
) combined ON combined.pid = p.id
|
||||
WHERE p.id != $1
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY p.name
|
||||
`, [ownerId]);
|
||||
@@ -267,7 +311,8 @@ export async function getSharedTransactions(ownerId: 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.owner_id,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p_owner.name as owner_name,
|
||||
json_agg(json_build_object(
|
||||
'split_id', ts.id,
|
||||
@@ -280,13 +325,16 @@ export async function getSharedTransactions(ownerId: number) {
|
||||
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'
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||
WHERE (
|
||||
-- I own this transaction and at least one other person has a split
|
||||
COALESCE(t.owner_id, s.owner_id) = $1
|
||||
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
|
||||
) OR (
|
||||
-- Someone else owns this transaction and I have a split on it
|
||||
COALESCE(t.owner_id, s.owner_id) != $1
|
||||
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user